You've already forked mariadb-columnstore-engine
mirror of
https://github.com/mariadb-corporation/mariadb-columnstore-engine.git
synced 2025-10-31 18:30:33 +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)
|