You've already forked mariadb-columnstore-engine
							
							
				mirror of
				https://github.com/mariadb-corporation/mariadb-columnstore-engine.git
				synced 2025-11-03 17:13:17 +03:00 
			
		
		
		
	Implements the initial upgrade capability across CMAPI and the CLI, including
repository setup, package operations, environment prechecks, and coordinated
cluster steps with progress reporting.
Details:
- CMAPI upgrade manager:
  - Add `cmapi/cmapi_server/managers/upgrade/` modules:
    - `repo.py`, `packages.py`, `preinstall.py`, `upgrade.py`, `utils.py` and `__init__.py`
  - Extend endpoints and routing to expose upgrade operations and status:
    - `cmapi_server/controllers/{endpoints.py, dispatcher.py, api_clients.py}`
    - `cmapi_server/managers/{application.py, process.py}`
    - Add improved constants and helpers for upgrade flow
- Backup/restore and safety:
  - Add `cmapi_server/managers/backup_restore.py`
  - Fix pre-upgrade backup regressions (due to `mcs_backup_manager.sh 3.17 changes`)
  - Improve cluster version validation; add `ignore_missmatch` override
- CLI enhancements:
  - Progress UI and richer feedback (`mcs_cluster_tool/tools_commands.py`, `README.md`, `mcs.1`)
  - Add steps to start MDB and start MCS during/after upgrade
  - Improved error surfacing for version validation
- Platform and packaging:
  - Ubuntu and Rocky Linux support
  - RHEL/DNF dry-run support
  - Distro detection and platform-dependent logic hardened
  - Logging improvements
- Updater service:
  - Add `cmapi/updater/cmapi_updater.service.template` and `cmapi_updater.sh` to make CMAPI update itself
- Docs:
  - Update mcs cli README and mcs.1 man file
  - Add `cmapi/updater/README.md`
		
	
		
			
				
	
	
		
			974 lines
		
	
	
		
			35 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			974 lines
		
	
	
		
			35 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
import logging
 | 
						|
import os
 | 
						|
import secrets
 | 
						|
import sys
 | 
						|
import time
 | 
						|
from datetime import datetime, timedelta
 | 
						|
from typing import Optional
 | 
						|
import ast
 | 
						|
from collections import Counter
 | 
						|
 | 
						|
import requests
 | 
						|
import typer
 | 
						|
from typing_extensions import Annotated
 | 
						|
from rich.console import Console
 | 
						|
from rich.progress import (
 | 
						|
    BarColumn, Progress, SpinnerColumn, TimeElapsedColumn,
 | 
						|
)
 | 
						|
from rich.table import Table
 | 
						|
 | 
						|
 | 
						|
from cmapi_server.constants import (
 | 
						|
    MCS_DATA_PATH, MCS_SECRETS_FILENAME, REQUEST_TIMEOUT, TRANSACTION_TIMEOUT,
 | 
						|
    CMAPI_CONF_PATH, CMAPI_PORT,
 | 
						|
)
 | 
						|
from cmapi_server.controllers.api_clients import (
 | 
						|
    AppControllerClient, ClusterControllerClient, NodeControllerClient
 | 
						|
)
 | 
						|
from cmapi_server.exceptions import CEJError, CMAPIBasicError
 | 
						|
from cmapi_server.handlers.cej import CEJPasswordHandler
 | 
						|
from cmapi_server.helpers import get_active_nodes, get_config_parser
 | 
						|
from cmapi_server.managers.transaction import TransactionManager
 | 
						|
from cmapi_server.managers.upgrade.utils import ComparableVersion
 | 
						|
from cmapi_server.process_dispatchers.base import BaseDispatcher
 | 
						|
from mcs_cluster_tool.constants import MCS_COLUMNSTORE_REVIEW_SH, INSTALL_ES_LOG_FILEPATH
 | 
						|
from mcs_cluster_tool.decorators import handle_output
 | 
						|
from mcs_cluster_tool.helpers import cook_sh_arg
 | 
						|
 | 
						|
 | 
						|
 | 
						|
logger = logging.getLogger('mcs_cli')
 | 
						|
# pylint: disable=unused-argument, too-many-arguments, too-many-locals
 | 
						|
# pylint: disable=invalid-name, line-too-long
 | 
						|
 | 
						|
 | 
						|
@handle_output
 | 
						|
def cskeys(
 | 
						|
    user: Annotated[
 | 
						|
        str,
 | 
						|
        typer.Option(
 | 
						|
            '-u', '--user',
 | 
						|
            help='Designate the owner of the generated file.',
 | 
						|
        )
 | 
						|
    ] = 'mysql',
 | 
						|
    directory: Annotated[
 | 
						|
        str,
 | 
						|
        typer.Argument(
 | 
						|
            help='The directory where to store the file in.',
 | 
						|
        )
 | 
						|
    ] = MCS_DATA_PATH
 | 
						|
):
 | 
						|
    """
 | 
						|
    This utility generates a random AES encryption key and init vector
 | 
						|
    and writes them to disk. The data is written to the file '.secrets',
 | 
						|
    in the specified directory. The key and init vector are used by
 | 
						|
    the utility 'cspasswd' to encrypt passwords used in Columnstore
 | 
						|
    configuration files, as well as by Columnstore itself to decrypt the
 | 
						|
    passwords.
 | 
						|
 | 
						|
    WARNING: Re-creating the file invalidates all existing encrypted
 | 
						|
    passwords in the configuration files.
 | 
						|
    """
 | 
						|
    filepath = os.path.join(directory, MCS_SECRETS_FILENAME)
 | 
						|
    if CEJPasswordHandler().secretsfile_exists(directory=directory):
 | 
						|
        typer.echo(
 | 
						|
            (
 | 
						|
                f'Secrets file "{filepath}" already exists. '
 | 
						|
                'Delete it before generating a new encryption key.'
 | 
						|
            ),
 | 
						|
            color='red',
 | 
						|
        )
 | 
						|
        raise typer.Exit(code=1)
 | 
						|
    elif not os.path.exists(os.path.dirname(filepath)):
 | 
						|
        typer.echo(
 | 
						|
            f'Directory "{directory}" does not exist.',
 | 
						|
            color='red'
 | 
						|
        )
 | 
						|
        raise typer.Exit(code=1)
 | 
						|
 | 
						|
    new_secrets_data = CEJPasswordHandler().generate_secrets_data()
 | 
						|
    try:
 | 
						|
        CEJPasswordHandler().save_secrets(
 | 
						|
            new_secrets_data, owner=user, directory=directory
 | 
						|
        )
 | 
						|
        typer.echo(f'Permissions of "{filepath}" set to owner:read.')
 | 
						|
        typer.echo(f'Ownership of "{filepath}" given to {user}.')
 | 
						|
    except CEJError as cej_error:
 | 
						|
        typer.echo(cej_error.message, color='red')
 | 
						|
        raise typer.Exit(code=2)
 | 
						|
    raise typer.Exit(code=0)
 | 
						|
 | 
						|
 | 
						|
@handle_output
 | 
						|
def cspasswd(
 | 
						|
    password: Annotated[
 | 
						|
        str,
 | 
						|
        typer.Option(
 | 
						|
            help='Password to encrypt/decrypt',
 | 
						|
            prompt=True, confirmation_prompt=True, hide_input=True
 | 
						|
        )
 | 
						|
    ],
 | 
						|
    decrypt: Annotated[
 | 
						|
        bool,
 | 
						|
        typer.Option(
 | 
						|
            '--decrypt',
 | 
						|
            help='Decrypt an encrypted password instead.',
 | 
						|
        )
 | 
						|
    ] = False
 | 
						|
):
 | 
						|
    """
 | 
						|
    Encrypt a Columnstore plaintext password using the encryption key in
 | 
						|
    the key file.
 | 
						|
    """
 | 
						|
    if decrypt:
 | 
						|
        try:
 | 
						|
            decrypted_password = CEJPasswordHandler().decrypt_password(
 | 
						|
                password
 | 
						|
            )
 | 
						|
        except CEJError as cej_error:
 | 
						|
            typer.echo(cej_error.message, color='red')
 | 
						|
            raise typer.Exit(code=1)
 | 
						|
        typer.echo(f'Decoded password: {decrypted_password}', color='green')
 | 
						|
    else:
 | 
						|
        try:
 | 
						|
            encoded_password = CEJPasswordHandler().encrypt_password(password)
 | 
						|
        except CEJError as cej_error:
 | 
						|
            typer.echo(cej_error.message, color='red')
 | 
						|
            raise typer.Exit(code=1)
 | 
						|
        typer.echo(f'Encoded password: {encoded_password}', color='green')
 | 
						|
    raise typer.Exit(code=0)
 | 
						|
 | 
						|
 | 
						|
@handle_output
 | 
						|
def bootstrap_single_node(
 | 
						|
    key: Annotated[
 | 
						|
        str,
 | 
						|
        typer.Option(
 | 
						|
            '--api-key',
 | 
						|
            help='API key to set.',
 | 
						|
        )
 | 
						|
    ] = ''
 | 
						|
):
 | 
						|
    """Bootstrap a single node (localhost) Columnstore instance."""
 | 
						|
    node = 'localhost'
 | 
						|
    client = ClusterControllerClient(request_timeout=REQUEST_TIMEOUT)
 | 
						|
    if not key:
 | 
						|
        # Generate API key if not provided
 | 
						|
        key = secrets.token_urlsafe(32)  #pylint: disable=no-member
 | 
						|
    # handle_output decorator will catch, show and log errors
 | 
						|
    api_key_set_resp = client.set_api_key(key)
 | 
						|
    # if operation takes minutes, then it is better to raise by timeout
 | 
						|
    with TransactionManager(
 | 
						|
        timeout=TRANSACTION_TIMEOUT, handle_signals=True,
 | 
						|
        extra_nodes=[node]
 | 
						|
    ):
 | 
						|
        add_node_resp = client.add_node({'node': node})
 | 
						|
 | 
						|
    result = {
 | 
						|
        'timestamp': str(datetime.now()),
 | 
						|
        'set_api_key_resp': api_key_set_resp,
 | 
						|
        'add_node_resp': add_node_resp,
 | 
						|
    }
 | 
						|
    return result
 | 
						|
 | 
						|
 | 
						|
@handle_output
 | 
						|
def review(
 | 
						|
    _version: Annotated[
 | 
						|
        Optional[bool],
 | 
						|
        typer.Option(
 | 
						|
            '--version',
 | 
						|
            help='Only show the header with version information.',
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = None,
 | 
						|
    _logs: Annotated[
 | 
						|
        Optional[bool],
 | 
						|
        typer.Option(
 | 
						|
            '--logs',
 | 
						|
            help=(
 | 
						|
                'Create a compressed archive of logs for MariaDB Support '
 | 
						|
                'Ticket'
 | 
						|
            ),
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = None,
 | 
						|
    _path: Annotated[
 | 
						|
        Optional[str],
 | 
						|
        typer.Option(
 | 
						|
            '--path',
 | 
						|
            help=(
 | 
						|
                'Define the path for where to save files/tarballs and outputs '
 | 
						|
                'of this script.'
 | 
						|
            ),
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = None,
 | 
						|
    _backupdbrm: Annotated[
 | 
						|
        Optional[bool],
 | 
						|
        typer.Option(
 | 
						|
            '--backupdbrm',
 | 
						|
            help=(
 | 
						|
                'Takes a compressed backup of extent map files in dbrm '
 | 
						|
                'directory.'
 | 
						|
            ),
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = None,
 | 
						|
    _testschema: Annotated[
 | 
						|
        Optional[bool],
 | 
						|
        typer.Option(
 | 
						|
            '--testschema',
 | 
						|
            help=(
 | 
						|
                'Creates a test schema, tables, imports, queries, drops '
 | 
						|
                'schema.'
 | 
						|
            ),
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = None,
 | 
						|
    _testschemakeep: Annotated[
 | 
						|
        Optional[bool],
 | 
						|
        typer.Option(
 | 
						|
            '--testschemakeep',
 | 
						|
            help=(
 | 
						|
                'Creates a test schema, tables, imports, queries, does not '
 | 
						|
                'drop.'
 | 
						|
            ),
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = None,
 | 
						|
    _ldlischema: Annotated[
 | 
						|
        Optional[bool],
 | 
						|
        typer.Option(
 | 
						|
            '--ldlischema',
 | 
						|
            help=(
 | 
						|
                'Using ldli, creates test schema, tables, imports, queries, '
 | 
						|
                'drops schema.'
 | 
						|
            ),
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = None,
 | 
						|
    _ldlischemakeep: Annotated[
 | 
						|
        Optional[bool],
 | 
						|
        typer.Option(
 | 
						|
            '--ldlischemakeep',
 | 
						|
            help=(
 | 
						|
                'Using ldli, creates test schema, tables, imports, queries, '
 | 
						|
                'does not drop.'
 | 
						|
            ),
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = None,
 | 
						|
    _emptydirs: Annotated[
 | 
						|
        Optional[bool],
 | 
						|
        typer.Option(
 | 
						|
            '--emptydirs',
 | 
						|
            help='Searches /var/lib/columnstore for empty directories.',
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = None,
 | 
						|
    _notmysqldirs: Annotated[
 | 
						|
        Optional[bool],
 | 
						|
        typer.Option(
 | 
						|
            '--notmysqldirs',
 | 
						|
            help=(
 | 
						|
                'Searches /var/lib/columnstore for directories not owned by '
 | 
						|
                'mysql.'
 | 
						|
            ),
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = None,
 | 
						|
    _emcheck: Annotated[
 | 
						|
        Optional[bool],
 | 
						|
        typer.Option(
 | 
						|
            '--emcheck',
 | 
						|
            help='Checks the extent map for orphaned and missing files.',
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = None,
 | 
						|
    _s3check: Annotated[
 | 
						|
        Optional[bool],
 | 
						|
        typer.Option(
 | 
						|
            '--s3check',
 | 
						|
            help='Checks the extent map against S3 storage.',
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = None,
 | 
						|
    _pscs: Annotated[
 | 
						|
        Optional[bool],
 | 
						|
        typer.Option(
 | 
						|
            '--pscs',
 | 
						|
            help=(
 | 
						|
                'Adds the pscs command. pscs lists running columnstore '
 | 
						|
                'processes.'
 | 
						|
            ),
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = None,
 | 
						|
    _schemasync: Annotated[
 | 
						|
        Optional[bool],
 | 
						|
        typer.Option(
 | 
						|
            '--schemasync',
 | 
						|
            help='Fix out-of-sync columnstore tables (CAL0009).',
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = None,
 | 
						|
    _tmpdir: Annotated[
 | 
						|
        Optional[bool],
 | 
						|
        typer.Option(
 | 
						|
            '--tmpdir',
 | 
						|
            help=(
 | 
						|
                'Ensure owner of temporary dir after reboot (MCOL-4866 & '
 | 
						|
                'MCOL-5242).'
 | 
						|
            ),
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = None,
 | 
						|
    _checkports: Annotated[
 | 
						|
        Optional[bool],
 | 
						|
        typer.Option(
 | 
						|
            '--checkports',
 | 
						|
            help='Checks if ports needed by Columnstore are opened.',
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = None,
 | 
						|
    _eustack: Annotated[
 | 
						|
        Optional[bool],
 | 
						|
        typer.Option(
 | 
						|
            '--eustack',
 | 
						|
            help='Dumps the stack of Columnstore processes.',
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = None,
 | 
						|
    _clearrollback: Annotated[
 | 
						|
        Optional[bool],
 | 
						|
        typer.Option(
 | 
						|
            '--clearrollback',
 | 
						|
            help='Clear any rollback fragments from dbrm files.',
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = None,
 | 
						|
    _killcolumnstore: Annotated[
 | 
						|
        Optional[bool],
 | 
						|
        typer.Option(
 | 
						|
            '--killcolumnstore',
 | 
						|
            help=(
 | 
						|
                'Stop columnstore processes gracefully, then kill remaining '
 | 
						|
                'processes.'
 | 
						|
            ),
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = None,
 | 
						|
    _color: Annotated[
 | 
						|
        Optional[str],
 | 
						|
        typer.Option(
 | 
						|
            '--color',
 | 
						|
            help=(
 | 
						|
                'print headers in color. Options: [none,red,blue,green,yellow,'
 | 
						|
                'magenta,cyan, none] prefix color with l for light.'
 | 
						|
            ),
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = None,
 | 
						|
):
 | 
						|
    """
 | 
						|
    This script performs various maintenance and diagnostic tasks for
 | 
						|
    MariaDB ColumnStore, including log archiving, extent map backups,
 | 
						|
    schema and table testing, directory and ownership checks, extent map
 | 
						|
    validation, S3 storage comparison, process management, table
 | 
						|
    synchronization, port availability checks, stack dumps, cleanup of
 | 
						|
    rollback fragments, and graceful process termination.
 | 
						|
 | 
						|
    If database is up, this script will connect as root@localhost via socket.
 | 
						|
    """
 | 
						|
 | 
						|
    arguments = []
 | 
						|
    for arg_name, value in locals().items():
 | 
						|
        sh_arg = cook_sh_arg(arg_name, value, separator='=')
 | 
						|
        if sh_arg is None:
 | 
						|
            continue
 | 
						|
        # columnstore_review.sh accepts only --arg=value format
 | 
						|
        arguments.append(sh_arg)
 | 
						|
    cmd = f'{MCS_COLUMNSTORE_REVIEW_SH} {" ".join(arguments)}'
 | 
						|
    success, _ = BaseDispatcher.exec_command(cmd, stdout=sys.stdout)
 | 
						|
    if not success:
 | 
						|
        raise typer.Exit(code=1)
 | 
						|
    raise typer.Exit(code=0)
 | 
						|
 | 
						|
 | 
						|
# Sentry subcommand app
 | 
						|
sentry_app = typer.Typer(help='Manage Sentry DSN configuration for error tracking.')
 | 
						|
 | 
						|
 | 
						|
@sentry_app.command()
 | 
						|
@handle_output
 | 
						|
def show():
 | 
						|
    """Show current Sentry DSN configuration."""
 | 
						|
    try:
 | 
						|
        # Read existing config
 | 
						|
        cfg_parser = get_config_parser(CMAPI_CONF_PATH)
 | 
						|
 | 
						|
        if not cfg_parser.has_section('Sentry'):
 | 
						|
            typer.echo('Sentry is disabled (no configuration found).', color='yellow')
 | 
						|
            raise typer.Exit(code=0)
 | 
						|
 | 
						|
        dsn = cfg_parser.get('Sentry', 'dsn', fallback='').strip().strip("'\"")
 | 
						|
        environment = cfg_parser.get('Sentry', 'environment', fallback='development').strip().strip("'\"")
 | 
						|
 | 
						|
        if not dsn:
 | 
						|
            typer.echo('Sentry is disabled (DSN is empty).', color='yellow')
 | 
						|
        else:
 | 
						|
            typer.echo('Sentry is enabled:', color='green')
 | 
						|
            typer.echo(f'  DSN: {dsn}')
 | 
						|
            typer.echo(f'  Environment: {environment}')
 | 
						|
 | 
						|
    except Exception as e:
 | 
						|
        typer.echo(f'Error reading configuration: {str(e)}', color='red')
 | 
						|
        raise typer.Exit(code=1)
 | 
						|
 | 
						|
    raise typer.Exit(code=0)
 | 
						|
 | 
						|
 | 
						|
@sentry_app.command()
 | 
						|
@handle_output
 | 
						|
def enable(
 | 
						|
    dsn: Annotated[
 | 
						|
        str,
 | 
						|
        typer.Argument(
 | 
						|
            help='Sentry DSN URL to enable for error tracking.',
 | 
						|
        )
 | 
						|
    ],
 | 
						|
    environment: Annotated[
 | 
						|
        str,
 | 
						|
        typer.Option(
 | 
						|
            '--environment', '-e',
 | 
						|
            help='Sentry environment name (default: development).',
 | 
						|
        )
 | 
						|
    ] = 'development'
 | 
						|
):
 | 
						|
    """Enable Sentry error tracking with the provided DSN."""
 | 
						|
    if not dsn:
 | 
						|
        typer.echo('DSN cannot be empty.', color='red')
 | 
						|
        raise typer.Exit(code=1)
 | 
						|
 | 
						|
    try:
 | 
						|
        # Read existing config
 | 
						|
        cfg_parser = get_config_parser(CMAPI_CONF_PATH)
 | 
						|
 | 
						|
        # Add or update Sentry section
 | 
						|
        if not cfg_parser.has_section('Sentry'):
 | 
						|
            cfg_parser.add_section('Sentry')
 | 
						|
 | 
						|
        cfg_parser.set('Sentry', 'dsn', f"'{dsn}'")
 | 
						|
        cfg_parser.set('Sentry', 'environment', f"'{environment}'")
 | 
						|
 | 
						|
        # Write config back to file
 | 
						|
        with open(CMAPI_CONF_PATH, 'w') as config_file:
 | 
						|
            cfg_parser.write(config_file)
 | 
						|
 | 
						|
        typer.echo('Sentry error tracking enabled successfully.', color='green')
 | 
						|
        typer.echo(f'DSN: {dsn}', color='green')
 | 
						|
        typer.echo(f'Environment: {environment}', color='green')
 | 
						|
        typer.echo('Note: Restart cmapi service for changes to take effect.', color='yellow')
 | 
						|
 | 
						|
    except Exception as e:
 | 
						|
        typer.echo(f'Error updating configuration: {str(e)}', color='red')
 | 
						|
        raise typer.Exit(code=1)
 | 
						|
 | 
						|
    raise typer.Exit(code=0)
 | 
						|
 | 
						|
 | 
						|
@sentry_app.command()
 | 
						|
@handle_output
 | 
						|
def disable():
 | 
						|
    """Disable Sentry error tracking by removing the configuration."""
 | 
						|
    try:
 | 
						|
        # Read existing config
 | 
						|
        cfg_parser = get_config_parser(CMAPI_CONF_PATH)
 | 
						|
 | 
						|
        if not cfg_parser.has_section('Sentry'):
 | 
						|
            typer.echo('Sentry is already disabled (no configuration found).', color='yellow')
 | 
						|
            raise typer.Exit(code=0)
 | 
						|
 | 
						|
        # Remove the entire Sentry section
 | 
						|
        cfg_parser.remove_section('Sentry')
 | 
						|
 | 
						|
        # Write config back to file
 | 
						|
        with open(CMAPI_CONF_PATH, 'w') as config_file:
 | 
						|
            cfg_parser.write(config_file)
 | 
						|
 | 
						|
        typer.echo('Sentry error tracking disabled successfully.', color='green')
 | 
						|
        typer.echo('Note: Restart cmapi service for changes to take effect.', color='yellow')
 | 
						|
 | 
						|
    except Exception as e:
 | 
						|
        typer.echo(f'Error updating configuration: {str(e)}', color='red')
 | 
						|
        raise typer.Exit(code=1)
 | 
						|
 | 
						|
    raise typer.Exit(code=0)
 | 
						|
 | 
						|
 | 
						|
@handle_output
 | 
						|
def healthcheck():
 | 
						|
    """Check the health of the MCS cluster."""
 | 
						|
    with TransactionManager(
 | 
						|
        timeout=timedelta(minutes=5).total_seconds(), handle_signals=True,
 | 
						|
    ):
 | 
						|
        client = ClusterControllerClient(request_timeout=REQUEST_TIMEOUT)
 | 
						|
        result = client.get_health({'in_transaction': True})
 | 
						|
        # TODO: just a placeholder for now
 | 
						|
        #       need to implement result in a table format with color
 | 
						|
        typer.echo(
 | 
						|
            'Cluster health check completed successfully.',
 | 
						|
            color='green'
 | 
						|
        )
 | 
						|
    raise typer.Exit(code=0)
 | 
						|
 | 
						|
 | 
						|
@handle_output
 | 
						|
def install_es(
 | 
						|
    token: Annotated[
 | 
						|
        str,
 | 
						|
        typer.Option(
 | 
						|
            '--token',
 | 
						|
            help='ES API Token to use for the upgrade.',
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ],
 | 
						|
    target_version: Annotated[
 | 
						|
        str,
 | 
						|
        typer.Option(
 | 
						|
            '-v', '--version',
 | 
						|
            help='ES version to upgdate.',
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = 'latest',
 | 
						|
    ignore_mismatch: Annotated[
 | 
						|
        bool,
 | 
						|
        typer.Option(
 | 
						|
            '--ignore-mismatch',
 | 
						|
            help='Proceed even if nodes report different installed package versions (use majority as baseline).',
 | 
						|
            show_default=False
 | 
						|
        )
 | 
						|
    ] = False,
 | 
						|
):
 | 
						|
    """
 | 
						|
    [Beta]
 | 
						|
    Install the specified MDB ES version.
 | 
						|
    If the version is 'latest', it will upgrade to the latest tested version
 | 
						|
    available.
 | 
						|
    """
 | 
						|
    new_handler = logging.FileHandler(INSTALL_ES_LOG_FILEPATH, mode='w')
 | 
						|
    new_handler.setLevel(logging.DEBUG)
 | 
						|
    new_handler.setFormatter(logging.getLogger('mcs_cli').handlers[0].formatter)
 | 
						|
    for logger_name in ("", "mcs_cli"):
 | 
						|
        current_logger = logging.getLogger(logger_name)
 | 
						|
        current_logger.addHandler(new_handler)
 | 
						|
    console = Console()
 | 
						|
    console.clear()
 | 
						|
    console.rule('[bold green][Beta] MariaDB ES Installer')
 | 
						|
 | 
						|
    console.print('This utility is now in Beta.', style='yellow underline')
 | 
						|
    console.print(
 | 
						|
        (
 | 
						|
            'Downgrades are supported up to MariaDB 10.6.9-5 and Columnstore 22.08.4.'
 | 
						|
            'Make sure you have a backup of your data before proceeding. '
 | 
						|
            'If you encounter any issues, please report them to MariaDB Support.'
 | 
						|
        ),
 | 
						|
        style='underline'
 | 
						|
    )
 | 
						|
 | 
						|
    # Collect output (tables/messages) to render AFTER the progress bar finishes
 | 
						|
    post_output: list = []  # items can be strings with rich markup or Rich renderables
 | 
						|
    exit_code: int = 0
 | 
						|
    def post_print(msg: str, color: Optional[str] = None):
 | 
						|
        if color:
 | 
						|
            post_output.append(f'[{color}]{msg}[/{color}]')
 | 
						|
        else:
 | 
						|
            post_output.append(msg)
 | 
						|
 | 
						|
    active_nodes = get_active_nodes()
 | 
						|
 | 
						|
    node_api_client = NodeControllerClient()
 | 
						|
    cluster_api_client = ClusterControllerClient()
 | 
						|
    app_api_client = AppControllerClient()
 | 
						|
 | 
						|
 | 
						|
    node_api_client.validate_es_token(token)
 | 
						|
    if target_version == 'latest':
 | 
						|
        response = node_api_client.get_latest_mdb_version()
 | 
						|
        target_version = response['latest_mdb_version']
 | 
						|
    else:
 | 
						|
        try:
 | 
						|
            node_api_client.validate_mdb_version(token, target_version, throw_real_exp=True)
 | 
						|
        except requests.exceptions.HTTPError as exc:
 | 
						|
            resp = exc.response
 | 
						|
            error_msg = str(exc)
 | 
						|
            if resp.status_code == 422:
 | 
						|
                try:
 | 
						|
                    resp_json = resp.json()
 | 
						|
                    error_msg = resp_json.get('error', resp_json)
 | 
						|
                except requests.exceptions.JSONDecodeError:
 | 
						|
                    error_msg = resp.text
 | 
						|
            console.print('ERROR:', style='red')
 | 
						|
            console.print(error_msg, style='underline')
 | 
						|
            console.rule()
 | 
						|
            raise typer.Exit(code=1)
 | 
						|
 | 
						|
    # Retrieve current versions; if nodes are mismatched, prettify the server error.
 | 
						|
    # If --ignore-mismatch is passed we will continue, choosing the majority version
 | 
						|
    # of each package as the baseline "current" version.
 | 
						|
    try:
 | 
						|
        versions = cluster_api_client.get_versions()
 | 
						|
    except CMAPIBasicError as exc:  # custom API client error
 | 
						|
        msg = exc.message
 | 
						|
        mismatch_marker = 'Packages versions:'
 | 
						|
        if mismatch_marker in msg:
 | 
						|
            try:
 | 
						|
                dict_part = msg.split(mismatch_marker, 1)[1].strip()
 | 
						|
                packages_versions = ast.literal_eval(dict_part)
 | 
						|
            except Exception:  # pragma: no cover - defensive
 | 
						|
                # Could not parse, fall back to original behavior
 | 
						|
                console.print(f"[red]{msg}[/red]")
 | 
						|
                raise typer.Exit(code=1)
 | 
						|
 | 
						|
            console.print('Detected package version mismatch across nodes:', style='yellow')
 | 
						|
            mismatch_table = Table('Node', 'Server', 'Columnstore', 'CMAPI')
 | 
						|
 | 
						|
            server_vals = [v.get('server_version') for v in packages_versions.values()]
 | 
						|
            cs_vals = [v.get('columnstore_version') for v in packages_versions.values()]
 | 
						|
            cmapi_vals = [v.get('cmapi_version') for v in packages_versions.values()]
 | 
						|
            server_common = Counter(server_vals).most_common(1)[0][0] if server_vals else None
 | 
						|
            cs_common = Counter(cs_vals).most_common(1)[0][0] if cs_vals else None
 | 
						|
            cmapi_common = Counter(cmapi_vals).most_common(1)[0][0] if cmapi_vals else None
 | 
						|
 | 
						|
            def style(val, common):
 | 
						|
                if val is None:
 | 
						|
                    return '[red]-[/red]'
 | 
						|
                return f'[green]{val}[/green]' if val == common else f'[red]{val}[/red]'
 | 
						|
 | 
						|
            for node, vers in sorted(packages_versions.items()):
 | 
						|
                mismatch_table.add_row(
 | 
						|
                    node,
 | 
						|
                    style(vers.get('server_version'), server_common),
 | 
						|
                    style(vers.get('columnstore_version'), cs_common),
 | 
						|
                    style(vers.get('cmapi_version'), cmapi_common),
 | 
						|
                )
 | 
						|
            # Print after progress unless we're going to exit early
 | 
						|
            if not ignore_mismatch:
 | 
						|
                # No progress has started yet; render now and exit
 | 
						|
                console.print(mismatch_table)
 | 
						|
                console.print('[yellow]All nodes must have identical package versions before running install-es. '
 | 
						|
                               'Please align versions (upgrade/downgrade individual nodes) and retry, '
 | 
						|
                               'or rerun with --ignore-mismatch to force.[/yellow]')
 | 
						|
                raise typer.Exit(code=1)
 | 
						|
 | 
						|
            console.print(mismatch_table)
 | 
						|
            # Forced continuation path
 | 
						|
            console.print(
 | 
						|
                (
 | 
						|
                    'Proceeding despite mismatch ( --ignore-mismatch ). Using majority versions '
 | 
						|
                    'as baseline.'
 | 
						|
                ),
 | 
						|
                style='yellow'
 | 
						|
            )
 | 
						|
            versions = {
 | 
						|
                'server_version': server_common or server_vals[0],
 | 
						|
                'columnstore_version': cs_common or cs_vals[0],
 | 
						|
                'cmapi_version': cmapi_common or cmapi_vals[0],
 | 
						|
            }
 | 
						|
        else:
 | 
						|
            # Not a mismatch we recognize; rethrow for decorator to handle
 | 
						|
            raise
 | 
						|
    mdb_curr_ver = versions['server_version']
 | 
						|
    mcs_curr_ver = versions['columnstore_version']
 | 
						|
    cmapi_curr_ver = versions['cmapi_version']
 | 
						|
    mdb_curr_ver_comp = ComparableVersion(mdb_curr_ver)
 | 
						|
    mdb_target_ver_comp = ComparableVersion(target_version)
 | 
						|
 | 
						|
    console.print('Currently installed vesions:', style='green')
 | 
						|
    table = Table('ES version', 'Columnstore version', 'CMAPI version')
 | 
						|
    table.add_row(mdb_curr_ver, mcs_curr_ver, cmapi_curr_ver)
 | 
						|
    console.print(table)
 | 
						|
    is_downgrade = False
 | 
						|
    if mdb_curr_ver_comp == mdb_target_ver_comp:
 | 
						|
        console.print('[green]The target MariaDB ES version is already installed.[/green]')
 | 
						|
        raise typer.Exit(code=0)
 | 
						|
    elif mdb_curr_ver_comp > mdb_target_ver_comp:
 | 
						|
        downgrade = typer.confirm(
 | 
						|
            'Target version is older than currently installed. '
 | 
						|
            'Are you sure you really want to downgrade?\n'
 | 
						|
            'WARNING: Could cause data loss and/or broken cluster.',
 | 
						|
            prompt_suffix=' '
 | 
						|
        )
 | 
						|
        if not downgrade:
 | 
						|
            raise typer.Exit(code=1)
 | 
						|
        is_downgrade = True
 | 
						|
    elif mdb_curr_ver_comp < mdb_target_ver_comp:
 | 
						|
        upgrade = typer.confirm(
 | 
						|
            f'Are you sure you really want to upgrade to {target_version}?',
 | 
						|
            prompt_suffix=' '
 | 
						|
        )
 | 
						|
        if not upgrade:
 | 
						|
            raise typer.Exit(code=1)
 | 
						|
 | 
						|
    if not active_nodes:
 | 
						|
        post_print('No active nodes found, using localhost only.', 'yellow')
 | 
						|
        active_nodes.append('localhost')
 | 
						|
 | 
						|
    with Progress(
 | 
						|
        SpinnerColumn(),
 | 
						|
        '[progress.description]{task.description}',
 | 
						|
        BarColumn(),
 | 
						|
        TimeElapsedColumn(),
 | 
						|
        console=console,
 | 
						|
    ) as progress:
 | 
						|
        step1_stop_cluster = progress.add_task('Stopping MCS cluster...', total=None)
 | 
						|
        with TransactionManager(
 | 
						|
            timeout=timedelta(days=1).total_seconds(), handle_signals=True
 | 
						|
        ):
 | 
						|
            cluster_stop_resp = cluster_api_client.shutdown_cluster(
 | 
						|
                {'in_transaction': True}
 | 
						|
            )
 | 
						|
        progress.update(
 | 
						|
            step1_stop_cluster, description='[green]MCS Cluster stopped ✓', total=100,
 | 
						|
            completed=True
 | 
						|
        )
 | 
						|
        progress.stop_task(step1_stop_cluster)
 | 
						|
 | 
						|
        step2_stop_mariadb = progress.add_task('Stopping MariaDB server...', total=None)
 | 
						|
        # TODO: put MaxScale into maintainance mode
 | 
						|
        mariadb_stop_resp = cluster_api_client.stop_mariadb(
 | 
						|
            {'in_transaction': True}
 | 
						|
        )
 | 
						|
        progress.update(
 | 
						|
            step2_stop_mariadb, description='[green]MariaDB server stopped ✓', total=100,
 | 
						|
            completed=True
 | 
						|
        )
 | 
						|
        progress.stop_task(step2_stop_mariadb)
 | 
						|
 | 
						|
        step3_install_es_repo = progress.add_task(
 | 
						|
            'Installing MariaDB ES repository...', total=None
 | 
						|
        )
 | 
						|
        inst_repo_response = cluster_api_client.install_repo(
 | 
						|
            token=token, mariadb_version=target_version
 | 
						|
        )
 | 
						|
        progress.update(
 | 
						|
            step3_install_es_repo, description='[green]Repository installed ✓', total=100,
 | 
						|
            completed=True
 | 
						|
        )
 | 
						|
        progress.stop_task(step3_install_es_repo)
 | 
						|
 | 
						|
        if target_version == 'latest':
 | 
						|
            # PackageManager accepts latest versions so no need to get numeric
 | 
						|
            mdb_target_ver = mcs_target_ver = cmapi_target_ver = 'latest'
 | 
						|
        else:
 | 
						|
            step3_5_get_available_versions = progress.add_task(
 | 
						|
                'Getting available versions of packages...', total=None
 | 
						|
            )
 | 
						|
            available_versions_resp = node_api_client.repo_pkg_versions()
 | 
						|
            mdb_target_ver = available_versions_resp['server_version']
 | 
						|
            mcs_target_ver = available_versions_resp['columnstore_version']
 | 
						|
            cmapi_target_ver = available_versions_resp['cmapi_version']
 | 
						|
            progress.update(
 | 
						|
                step3_5_get_available_versions,
 | 
						|
                description=(
 | 
						|
                    f'[green]Available versions: ES {mdb_target_ver}, '
 | 
						|
                    f'Columnstore {mcs_target_ver}, CMAPI {cmapi_target_ver} ✓'
 | 
						|
                ),
 | 
						|
                total=100, completed=True
 | 
						|
            )
 | 
						|
            progress.stop_task(step3_5_get_available_versions)
 | 
						|
 | 
						|
        step4_preupgrade_backup = progress.add_task(
 | 
						|
            'Starting pre-upgrade backup DBRM and configs on each node...', total=None
 | 
						|
        )
 | 
						|
        backup_response = cluster_api_client.preupgrade_backup()
 | 
						|
        progress.update(
 | 
						|
            step4_preupgrade_backup, description='[green]PreUpgrade Backup completed ✓',
 | 
						|
            total=100, completed=True
 | 
						|
        )
 | 
						|
        progress.stop_task(step4_preupgrade_backup)
 | 
						|
 | 
						|
        step5_upgrade_mdb_mcs = progress.add_task(
 | 
						|
            'Upgrading MariaDB and Columnstore on each node...', total=None
 | 
						|
        )
 | 
						|
        mdb_mcs_upgrade_response = cluster_api_client.upgrade_mdb_mcs(
 | 
						|
            mariadb_version=mdb_target_ver, columnstore_version=mcs_target_ver
 | 
						|
        )
 | 
						|
        progress.update(
 | 
						|
            step5_upgrade_mdb_mcs,
 | 
						|
            description=f'[green]Upgraded to MariaDB {mdb_target_ver} and Columnstore {mcs_target_ver} ✓',
 | 
						|
            total=100, completed=True
 | 
						|
        )
 | 
						|
        progress.stop_task(step5_upgrade_mdb_mcs)
 | 
						|
 | 
						|
        step6_install_cmapi = progress.add_task('Upgrading CMAPI on each node...', total=None)
 | 
						|
        try:
 | 
						|
            cmapi_upgrade_response = cluster_api_client.upgrade_cmapi(version=cmapi_target_ver)
 | 
						|
            # cmapi_updater service has 5 s timeout to give CMAPI time to handle response,
 | 
						|
            # we need to wait when API become unreachable after CMAPI stop.
 | 
						|
            time.sleep(6)
 | 
						|
        except requests.exceptions.ConnectionError:
 | 
						|
            # during upgrade the connection drop is expected
 | 
						|
            pass
 | 
						|
 | 
						|
        # Prepare per-node readiness tracking
 | 
						|
        progress.update(
 | 
						|
            step6_install_cmapi, description='Waiting CMAPI to be ready on each node...',
 | 
						|
            completed=None
 | 
						|
        )
 | 
						|
        start_time = datetime.now()
 | 
						|
        timeout_seconds = 300
 | 
						|
        # status per node: {'status': 'PENDING'|'READY'|'ERROR'|'TIMEOUT', 'details': str}
 | 
						|
        node_states = {
 | 
						|
            node: {'status': 'PENDING', 'details': ''} for node in active_nodes
 | 
						|
        }
 | 
						|
 | 
						|
        # Build a dedicated client per node (localhost already covered)
 | 
						|
        per_node_clients: dict[str, AppControllerClient] = {}
 | 
						|
        for node in active_nodes:
 | 
						|
            if node in ('localhost', '127.0.0.1'):
 | 
						|
                per_node_clients[node] = AppControllerClient()
 | 
						|
            else:
 | 
						|
                per_node_clients[node] = AppControllerClient(
 | 
						|
                    base_url=f'https://{node}:{CMAPI_PORT}'
 | 
						|
                )
 | 
						|
 | 
						|
        ready_count_prev = -1
 | 
						|
        while (datetime.now() - start_time) < timedelta(seconds=timeout_seconds):
 | 
						|
            ready_count = 0
 | 
						|
            for node, client_obj in per_node_clients.items():
 | 
						|
                # Skip nodes that already finalized (READY or ERROR)
 | 
						|
                if node_states[node]['status'] in ('READY', 'ERROR'):
 | 
						|
                    if node_states[node]['status'] == 'READY':
 | 
						|
                        ready_count += 1
 | 
						|
                    continue
 | 
						|
                try:
 | 
						|
                    node_response = client_obj.get_ready()
 | 
						|
                    if node_response.get('started') is True:
 | 
						|
                        node_states[node]['status'] = 'READY'
 | 
						|
                        node_states[node]['details'] = 'Service started'
 | 
						|
                        ready_count += 1
 | 
						|
                except requests.exceptions.HTTPError as err:
 | 
						|
                    # 503 means not ready yet, anything else mark as ERROR
 | 
						|
                    if err.response.status_code == 503:
 | 
						|
                        node_states[node]['details'] = 'Starting...'
 | 
						|
                    else:
 | 
						|
                        node_states[node]['status'] = 'ERROR'
 | 
						|
                        node_states[node]['details'] = f'HTTP {err.response.status_code}'
 | 
						|
                except requests.exceptions.ConnectionError:
 | 
						|
                    # still restarting
 | 
						|
                    node_states[node]['details'] = 'Connection refused'
 | 
						|
                except FileNotFoundError as fnf_err:  # pragma: no cover - defensive
 | 
						|
                    # Transient race: config file not yet created; do not fail immediately
 | 
						|
                    missing_path = str(fnf_err).split(":")[-1].strip()
 | 
						|
                    node_states[node]['details'] = f'Config pending ({missing_path})'
 | 
						|
                except Exception as err:  # pragma: no cover - defensive
 | 
						|
                    node_states[node]['status'] = 'ERROR'
 | 
						|
                    node_states[node]['details'] = f'Unexpected: {err}'
 | 
						|
 | 
						|
            # Update progress description only when count changes to reduce flicker
 | 
						|
            if ready_count != ready_count_prev:
 | 
						|
                progress.update(
 | 
						|
                    step6_install_cmapi,
 | 
						|
                    description=(
 | 
						|
                        f'Waiting CMAPI to be ready on each node... '
 | 
						|
                        f'({ready_count}/{len(active_nodes)} ready)'
 | 
						|
                    ),
 | 
						|
                    completed=None
 | 
						|
                )
 | 
						|
                ready_count_prev = ready_count
 | 
						|
 | 
						|
            if ready_count == len(active_nodes):
 | 
						|
                break
 | 
						|
            time.sleep(1)
 | 
						|
 | 
						|
        # Mark TIMEOUT for nodes still pending
 | 
						|
        for node, state in node_states.items():
 | 
						|
            if state['status'] == 'PENDING':
 | 
						|
                state['status'] = 'TIMEOUT'
 | 
						|
                state['details'] = f'Not ready after {timeout_seconds}s'
 | 
						|
 | 
						|
        # Display per-node table after progress ends
 | 
						|
        status_table = Table('Node', 'Status', 'Details')
 | 
						|
        failures = False
 | 
						|
        for node, state in sorted(node_states.items()):
 | 
						|
            status = state['status']
 | 
						|
            details = state['details']
 | 
						|
            color_map = {
 | 
						|
                'READY': 'green',
 | 
						|
                'PENDING': 'yellow',
 | 
						|
                'TIMEOUT': 'red',
 | 
						|
                'ERROR': 'red',
 | 
						|
            }
 | 
						|
            style = color_map.get(status, 'white')
 | 
						|
            status_table.add_row(node, f'[{style}]{status}[/{style}]', details)
 | 
						|
            if status in ('TIMEOUT', 'ERROR'):
 | 
						|
                failures = True
 | 
						|
        # Defer table rendering
 | 
						|
        post_output.append(status_table)
 | 
						|
 | 
						|
        if failures:
 | 
						|
            progress.update(
 | 
						|
                step6_install_cmapi,
 | 
						|
                description='[red]CMAPI did not start successfully on all nodes ✗',
 | 
						|
                total=100,
 | 
						|
                completed=True
 | 
						|
            )
 | 
						|
            progress.stop_task(step6_install_cmapi)
 | 
						|
            exit_code = 1
 | 
						|
            post_print('CMAPI did not start successfully on all nodes.', 'red')
 | 
						|
        else:
 | 
						|
            progress.update(
 | 
						|
                step6_install_cmapi,
 | 
						|
                description='[green]CMAPI is ready on all nodes ✓',
 | 
						|
                total=100,
 | 
						|
                completed=True
 | 
						|
            )
 | 
						|
            progress.stop_task(step6_install_cmapi)
 | 
						|
 | 
						|
        if failures:
 | 
						|
            # skip any automatic restarts on failure
 | 
						|
            pass
 | 
						|
        elif is_downgrade:
 | 
						|
            note_panel = Table('Action', 'Status')
 | 
						|
            note_panel.add_row('Automatic restart (MariaDB, Cluster, Health)', '[yellow]SKIPPED (downgrade)')
 | 
						|
            post_output.append(note_panel)
 | 
						|
            post_print(
 | 
						|
                'Downgrade detected: automatic service restarts were skipped. '
 | 
						|
                'Please manually start MariaDB and the ColumnStore cluster, and verify health.',
 | 
						|
                'yellow'
 | 
						|
            )
 | 
						|
            post_print('Suggested manual sequence:', 'yellow')
 | 
						|
            post_print('  1) systemctl start mariadb', 'yellow')
 | 
						|
            post_print('  2) Use mcs-cluster tool to start cluster if needed', 'yellow')
 | 
						|
            exit_code = 0
 | 
						|
        else:
 | 
						|
            step7_start_mariadb = progress.add_task('Starting MariaDB server...', total=None)
 | 
						|
            # TODO: put MaxScale from maintainance into working mode
 | 
						|
            mariadb_start_resp = cluster_api_client.start_mariadb({'in_transaction': True})
 | 
						|
            progress.update(
 | 
						|
                step7_start_mariadb, description='[green]MariaDB server started ✓', completed=True
 | 
						|
            )
 | 
						|
            progress.stop_task(step7_start_mariadb)
 | 
						|
 | 
						|
            step8_start_cluster = progress.add_task('Starting MCS cluster...', total=None)
 | 
						|
            cluster_start_resp = cluster_api_client.start_cluster(
 | 
						|
                {'in_transaction': True}
 | 
						|
            )
 | 
						|
            with TransactionManager(
 | 
						|
                timeout=timedelta(days=1).total_seconds(), handle_signals=True
 | 
						|
            ):
 | 
						|
                cluster_start_resp = cluster_api_client.start_cluster({'in_transaction': True})
 | 
						|
            progress.update(
 | 
						|
                step8_start_cluster, description='[green]MCS Cluster started ✓', completed=True
 | 
						|
            )
 | 
						|
            progress.stop_task(step8_start_cluster)
 | 
						|
            post_print('Upgrade completed and services restarted successfully.', 'green')
 | 
						|
 | 
						|
    # Render any deferred output now that the progress bar is complete
 | 
						|
    for item in post_output:
 | 
						|
        console.print(item)
 | 
						|
 | 
						|
    raise typer.Exit(code=exit_code)
 |