Your IP : 216.73.216.215


Current Path : /proc/21978/root/opt/dedrads/
Upload File :
Current File : //proc/21978/root/opt/dedrads/cpanel_postgres_manager.py

#!/usr/lib/rads/venv/bin/python3
"""
Install, reset, or logically upgrade PostgreSQL for cPanel on AlmaLinux 8/9.

When a PostgreSQL version is passed, the script resets/enables that dnf module
stream before running /scripts/installpostgres. When no version is passed, it
prompts for one before continuing.
"""

import argparse
from contextlib import contextmanager
import datetime as _dt
import logging
import os
import re
import shutil
import socket
import subprocess
import sys
from pathlib import Path
from typing import Dict, Iterator, List, Optional, Sequence, Tuple


DATA_DIR = Path("/var/lib/pgsql/data")
PG_HBA = DATA_DIR / "pg_hba.conf"
PGSQL_BASE_DIR = DATA_DIR.parent
POSTGRES_SERVICE = "postgresql"
CPANEL_INSTALLPOSTGRES = Path("/scripts/installpostgres")
BACKUP_DIR = Path("/root/postgresql-backups")
POSTGRES_USER_COMMAND = ["runuser", "-u", "postgres", "--"]
POSTGRESQL_PACKAGE_QUERY_PATTERNS = [
    "postgresql",
    "postgresql-server",
    "postgresql-libs",
    "postgresql-private-libs",
    "postgresql[0-9]*",
]
YUM_REPO_DIR = Path("/etc/yum.repos.d")
EXTERNAL_POSTGRES_REPO_PATTERNS = (
    "pgdg",
    "postgresql.org",
    "yum.postgresql.org",
    "ftp.postgresql.org",
)
LOGGER = logging.getLogger(__name__)


class Fatal(RuntimeError):
    pass


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Install, reset, or logically upgrade PostgreSQL for cPanel."
    )
    parser.add_argument(
        "version",
        nargs="?",
        type=int,
        help=(
            "PostgreSQL module stream to enable before install. Omit to display "
            "available streams and choose interactively."
        ),
    )
    parser.add_argument(
        "--upgrade",
        action="store_true",
        help="Backup the current cluster, reinstall/init the target version, then restore.",
    )
    parser.add_argument(
        "--allow-logical-downgrade",
        action="store_true",
        help=(
            "Allow --upgrade to attempt a pg_dumpall restore into an older major "
            "version. This is best-effort and may fail if the dump uses newer "
            "PostgreSQL features."
        ),
    )
    parser.add_argument(
        "--reset-data",
        action="store_true",
        help="Destroy /var/lib/pgsql/data contents and initialize a fresh cluster.",
    )
    parser.add_argument(
        "--backup-dir",
        default=str(BACKUP_DIR),
        help=f"Directory for pg_dumpall backups. Default: {BACKUP_DIR}",
    )
    parser.add_argument(
        "--skip-backup",
        action="store_true",
        help="Allow destructive reset without creating a pg_dumpall backup.",
    )
    parser.add_argument(
        "--yes",
        action="store_true",
        help="Answer yes to required prompts. Destructive prompts still require --i-understand-data-loss.",
    )
    parser.add_argument(
        "--i-understand-data-loss",
        action="store_true",
        help="Required with --yes for destructive data directory reset.",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Log commands without running them.",
    )
    return parser.parse_args()


def run(
    command: Sequence[str],
    *,
    dry_run: bool = False,
    check: bool = True,
    capture: bool = False,
    env: Optional[Dict[str, str]] = None,
) -> subprocess.CompletedProcess:
    printable = " ".join(str(part) for part in command)
    LOGGER.info("+ %s", printable)
    if dry_run:
        return subprocess.CompletedProcess(command, 0, "", "")
    return subprocess.run(
        list(command),
        check=check,
        text=True,
        stdout=subprocess.PIPE if capture else None,
        stderr=subprocess.PIPE if capture else None,
        env=env,
    )


def require_root() -> None:
    if os.geteuid() != 0:
        raise Fatal("Run this script as root.")


def command_exists(name: str) -> bool:
    return shutil.which(name) is not None


def service_is_active(dry_run: bool) -> bool:
    if dry_run:
        return False
    result = run(["systemctl", "is-active", "--quiet", POSTGRES_SERVICE], check=False)
    return result.returncode == 0


def existing_data_dir_has_cluster() -> bool:
    return (DATA_DIR / "PG_VERSION").exists()


def existing_cluster_version() -> Optional[str]:
    pg_version = DATA_DIR / "PG_VERSION"
    if pg_version.exists():
        return pg_version.read_text().strip()
    if command_exists("postgres"):
        result = run(["postgres", "--version"], check=False, capture=True)
        match = re.search(r"(\d+)(?:\.\d+)?", result.stdout or "")
        if match:
            return match.group(1)
    return None


def installed_postgres_server_version() -> Optional[str]:
    if not command_exists("postgres"):
        return None
    result = run(["postgres", "--version"], check=False, capture=True)
    match = re.search(r"(\d+)(?:\.\d+)?", result.stdout or "")
    if match:
        return match.group(1)
    return None


def require_installed_server_matches_target(version: Optional[int]) -> str:
    installed = installed_postgres_server_version()
    if installed is None:
        raise Fatal("postgres executable was not found after package installation.")
    if version is not None and installed != str(version):
        raise Fatal(
            f"Installed PostgreSQL server is version {installed}, but target module "
            f"stream is {version}. Refusing to initialize, start, restore, or run "
            "cPanel setup against the data directory. Check the enabled DNF module "
            "stream and repository state."
        )
    return installed


def require_data_dir_matches_installed_server() -> None:
    if not existing_data_dir_has_cluster():
        return
    cluster = existing_cluster_version()
    installed = installed_postgres_server_version()
    if installed is None or cluster is None:
        return
    if cluster != installed:
        raise Fatal(
            f"Existing PostgreSQL data directory is version {cluster}, but the "
            f"installed PostgreSQL server is version {installed}. Refusing to run "
            f"cPanel setup or start {POSTGRES_SERVICE} with an incompatible cluster. "
            "Use --upgrade for a logical dump/restore or --reset-data for a fresh "
            "cluster."
        )


def prompt(message: str, *, default_no: bool = True, assume_yes: bool = False) -> bool:
    if assume_yes:
        LOGGER.info("%s yes", message)
        return True
    suffix = " [y/N]: " if default_no else " [Y/n]: "
    answer = input(message + suffix).strip().lower()
    if not answer:
        return not default_no
    return answer in {"y", "yes"}


def available_postgresql_versions() -> List[Tuple[int, str]]:
    result = run(["dnf", "module", "list", "postgresql"], check=False, capture=True)
    versions: List[Tuple[int, str]] = []
    seen = set()
    for line in (result.stdout or "").splitlines():
        parts = line.split()
        if len(parts) < 2 or parts[0] != "postgresql":
            continue
        if not parts[1].isdigit():
            continue
        version = int(parts[1])
        if version in seen:
            continue
        seen.add(version)
        flags = parts[2] if len(parts) > 2 and parts[2].startswith("[") else ""
        versions.append((version, flags))
    return versions


def version_flag_labels(flags: str) -> str:
    labels = []
    if "e" in flags:
        labels.append("enabled")
    if "d" in flags:
        labels.append("default")
    if "i" in flags:
        labels.append("installed")
    if "x" in flags:
        labels.append("disabled")
    return ", ".join(labels)


def log_available_postgresql_versions(versions: Sequence[Tuple[int, str]]) -> None:
    if not versions:
        LOGGER.info("No PostgreSQL module streams were found in DNF output.")
        return
    LOGGER.info("Available PostgreSQL module streams:")
    for version, flags in versions:
        labels = version_flag_labels(flags)
        suffix = f" ({labels})" if labels else ""
        LOGGER.info("  %s%s", version, suffix)


def prompt_for_version() -> Optional[int]:
    versions = available_postgresql_versions()
    log_available_postgresql_versions(versions)
    answer = input(
        "PostgreSQL version to install [press Enter for repository default]: "
    ).strip()
    if not answer:
        return None
    if not answer.isdigit():
        raise Fatal(f"Invalid PostgreSQL version: {answer}")
    version = int(answer)
    available = {available_version for available_version, _ in versions}
    if available and version not in available:
        available_list = ", ".join(str(available_version) for available_version in sorted(available))
        raise Fatal(
            f"PostgreSQL version {version} is not in the available module streams: "
            f"{available_list}"
        )
    return version


def validate_selected_version(version: Optional[int]) -> None:
    if version is None:
        return
    versions = available_postgresql_versions()
    available = {available_version for available_version, _ in versions}
    if available and version not in available:
        available_list = ", ".join(str(available_version) for available_version in sorted(available))
        raise Fatal(
            f"PostgreSQL version {version} is not in the available module streams: "
            f"{available_list}"
        )


def require_cpanel() -> None:
    if not CPANEL_INSTALLPOSTGRES.exists():
        raise Fatal(f"{CPANEL_INSTALLPOSTGRES} was not found. This does not look like cPanel.")


def ensure_backup_directory(path: Path, dry_run: bool) -> None:
    if dry_run:
        LOGGER.info("+ mkdir -p %s", path)
        return
    path.mkdir(mode=0o700, parents=True, exist_ok=True)


def backup_cluster(backup_dir: Path, dry_run: bool) -> Optional[Path]:
    ensure_backup_directory(backup_dir, dry_run)
    stamp = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
    backup_file = backup_dir / f"postgresql-all-{stamp}.sql"
    if not command_exists("pg_dumpall"):
        raise Fatal("pg_dumpall was not found; cannot create a PostgreSQL backup.")

    if not service_is_active(dry_run):
        run(["systemctl", "start", POSTGRES_SERVICE], dry_run=dry_run)

    LOGGER.info("Creating logical backup at %s", backup_file)
    with local_postgres_peer_auth(dry_run):
        with backup_file.open("w") if not dry_run else open(os.devnull, "w") as output:
            LOGGER.info("+ runuser -u postgres -- pg_dumpall")
            if not dry_run:
                subprocess.run(
                    [*POSTGRES_USER_COMMAND, "pg_dumpall"],
                    check=True,
                    cwd="/tmp",
                    stdout=output,
                    text=True,
                )
    return backup_file


def stop_postgresql(dry_run: bool) -> None:
    run(["systemctl", "stop", POSTGRES_SERVICE], dry_run=dry_run, check=False)


def reload_postgresql(dry_run: bool) -> None:
    run(["systemctl", "reload", POSTGRES_SERVICE], dry_run=dry_run, check=False)


@contextmanager
def local_postgres_peer_auth(dry_run: bool) -> Iterator[None]:
    rule = "local all postgres peer\n"
    if dry_run:
        LOGGER.info("+ temporarily prepend to %s: %s", PG_HBA, rule.strip())
        reload_postgresql(dry_run)
        try:
            yield
        finally:
            LOGGER.info("+ restore original %s", PG_HBA)
            reload_postgresql(dry_run)
        return

    if not PG_HBA.exists():
        yield
        return

    original = PG_HBA.read_text()
    if original.startswith(rule):
        yield
        return

    PG_HBA.write_text(rule + original)
    reload_postgresql(dry_run)
    try:
        yield
    finally:
        PG_HBA.write_text(original)
        reload_postgresql(dry_run)


def remove_external_postgres_libraries(dry_run: bool) -> None:
    if dry_run:
        LOGGER.info("+ rpm -q libpq5")
        LOGGER.info("+ rpm -e --nodeps libpq5  # only if installed")
        LOGGER.info("+ rpm -qa 'postgresql*-libs'")
        LOGGER.info("+ rpm -e --nodeps postgresqlNN-libs  # only for PGDG versioned libs")
        return

    installed = run(["rpm", "-q", "libpq5"], check=False, capture=True)
    if installed.returncode == 0:
        package = installed.stdout.strip().splitlines()[0]
        LOGGER.info("Removing installed %s; it obsoletes AlmaLinux libpq.", package)
        run(["rpm", "-e", "--nodeps", "libpq5"])

    versioned_libs = run(["rpm", "-qa", "postgresql*-libs"], check=False, capture=True)
    for package in (versioned_libs.stdout or "").splitlines():
        if re.match(r"^postgresql\d+-libs-", package):
            LOGGER.info(
                "Removing installed %s; it is from an external PostgreSQL repo.",
                package,
            )
            run(["rpm", "-e", "--nodeps", package])


def installed_postgresql_packages(dry_run: bool) -> List[str]:
    if dry_run:
        LOGGER.info(
            "+ rpm -qa postgresql postgresql-server postgresql-libs "
            "postgresql-private-libs 'postgresql[0-9]*'"
        )
        return ["postgresql", "postgresql-server", "postgresql-libs"]

    packages: List[str] = []
    seen = set()
    for pattern in POSTGRESQL_PACKAGE_QUERY_PATTERNS:
        result = run(["rpm", "-qa", pattern], check=False, capture=True)
        for package in (result.stdout or "").splitlines():
            if package and package not in seen:
                seen.add(package)
                packages.append(package)
    return packages


def remove_installed_postgresql_packages(dry_run: bool) -> None:
    packages = installed_postgresql_packages(dry_run)
    if not packages:
        LOGGER.info("No installed PostgreSQL RPM packages found to remove.")
        return

    LOGGER.info(
        "Removing installed PostgreSQL RPM packages before installing the selected "
        "module stream."
    )
    stop_postgresql(dry_run)
    run(["rpm", "-e", "--nodeps", *packages], dry_run=dry_run)


def repo_section_matches(lines: Sequence[str]) -> bool:
    text = "\n".join(lines).lower()
    return any(pattern in text for pattern in EXTERNAL_POSTGRES_REPO_PATTERNS)


def disable_matching_repo_sections(text: str) -> Tuple[str, List[str]]:
    lines = text.splitlines(keepends=True)
    output: List[str] = []
    changed_sections: List[str] = []
    section: List[str] = []
    section_name: Optional[str] = None

    def flush() -> None:
        nonlocal section, section_name
        if not section:
            return
        if section_name is None or not repo_section_matches(section):
            output.extend(section)
            section = []
            section_name = None
            return

        saw_enabled = False
        changed = False
        for index, line in enumerate(section):
            if re.match(r"^\s*enabled\s*=", line):
                saw_enabled = True
                if not re.match(r"^\s*enabled\s*=\s*0\s*(?:[#;].*)?$", line):
                    newline = "\n" if line.endswith("\n") else ""
                    section[index] = "enabled=0" + newline
                    changed = True
                break

        if not saw_enabled:
            insert_at = 1 if section and section[0].lstrip().startswith("[") else len(section)
            section.insert(insert_at, "enabled=0\n")
            changed = True

        if changed:
            changed_sections.append(section_name)
        output.extend(section)
        section = []
        section_name = None

    for line in lines:
        match = re.match(r"^\s*\[([^\]]+)\]\s*$", line)
        if match:
            flush()
            section = [line]
            section_name = match.group(1)
        else:
            section.append(line)
    flush()
    return "".join(output), changed_sections


@contextmanager
def external_postgres_repos_disabled(dry_run: bool) -> Iterator[None]:
    if dry_run:
        LOGGER.info("+ temporarily disable external PostgreSQL repos during cPanel install")
        yield
        return

    originals: Dict[Path, str] = {}
    changed: List[str] = []
    for repo_file in sorted(YUM_REPO_DIR.glob("*.repo")):
        original = repo_file.read_text()
        updated, sections = disable_matching_repo_sections(original)
        if sections:
            originals[repo_file] = original
            repo_file.write_text(updated)
            changed.extend(f"{repo_file.name}:{section}" for section in sections)

    if changed:
        LOGGER.info(
            "Temporarily disabled external PostgreSQL repos: %s", ", ".join(changed)
        )
    try:
        yield
    finally:
        for repo_file, original in originals.items():
            repo_file.write_text(original)
        if changed:
            LOGGER.info("Restored external PostgreSQL repo configuration.")


def installpostgres_with_recovery(dry_run: bool, assume_yes: bool) -> None:
    if not dry_run:
        require_data_dir_matches_installed_server()
    command = [str(CPANEL_INSTALLPOSTGRES)]
    if assume_yes:
        command.append("--yes")
    result = run(command, dry_run=dry_run, check=False)
    if result.returncode == 0:
        if not dry_run:
            require_data_dir_matches_installed_server()
        return

    if existing_data_dir_has_cluster():
        if not dry_run:
            require_data_dir_matches_installed_server()
        raise subprocess.CalledProcessError(result.returncode, result.args)
    if not command_exists("postgresql-setup"):
        raise subprocess.CalledProcessError(result.returncode, result.args)

    LOGGER.info(
        "cPanel installpostgres failed before a PostgreSQL cluster existed; "
        "initializing the cluster and rerunning cPanel configuration."
    )
    initdb_and_start(dry_run)
    run(command, dry_run=dry_run)
    if not dry_run:
        require_data_dir_matches_installed_server()


def finalize_cpanel_postgresql(dry_run: bool, assume_yes: bool) -> None:
    LOGGER.info("Finalizing PostgreSQL configuration for cPanel")
    with external_postgres_repos_disabled(dry_run):
        installpostgres_with_recovery(dry_run, assume_yes)


def ensure_cluster_running(dry_run: bool) -> None:
    if not existing_data_dir_has_cluster():
        initdb_and_start(dry_run)
        return
    if not dry_run:
        require_data_dir_matches_installed_server()
    run(["systemctl", "enable", "--now", POSTGRES_SERVICE], dry_run=dry_run)
    run(["systemctl", "status", "--no-pager", POSTGRES_SERVICE], dry_run=dry_run, check=False)


def reset_module_and_install(
    version: Optional[int], dry_run: bool, assume_yes: bool
) -> None:
    if version is not None:
        remove_installed_postgresql_packages(dry_run)
    run(["dnf", "-y", "module", "reset", "postgresql"], dry_run=dry_run)
    if version is not None:
        run(["dnf", "-y", "module", "enable", f"postgresql:{version}"], dry_run=dry_run)
    else:
        LOGGER.info("No PostgreSQL module stream selected; using the repository default.")
    remove_external_postgres_libraries(dry_run)
    with external_postgres_repos_disabled(dry_run):
        installpostgres_with_recovery(dry_run, assume_yes)
    if not dry_run:
        installed = require_installed_server_matches_target(version)
        LOGGER.info("Installed PostgreSQL server version: %s", installed)


def clear_data_dir(dry_run: bool) -> None:
    if not DATA_DIR.exists():
        return
    LOGGER.info("Clearing %s contents", DATA_DIR)
    if dry_run:
        LOGGER.info("+ remove all entries under %s", DATA_DIR)
        return
    for child in DATA_DIR.iterdir():
        if child.is_dir() and not child.is_symlink():
            shutil.rmtree(child)
        else:
            child.unlink()


def move_data_dir_aside(reason: str, dry_run: bool) -> Optional[Path]:
    if not DATA_DIR.exists():
        return None
    stamp = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
    destination = PGSQL_BASE_DIR / f"data.{reason}.{stamp}"
    LOGGER.info("Moving existing PostgreSQL data directory to %s", destination)
    if dry_run:
        LOGGER.info("+ mv %s %s", DATA_DIR, destination)
        return destination
    DATA_DIR.rename(destination)
    return destination


def initdb_and_start(dry_run: bool) -> None:
    ensure_pgsql_base_dir(dry_run)
    run(["postgresql-setup", "--initdb"], dry_run=dry_run)
    if not dry_run:
        require_data_dir_matches_installed_server()
    run(["systemctl", "enable", "--now", POSTGRES_SERVICE], dry_run=dry_run)
    run(["systemctl", "status", "--no-pager", POSTGRES_SERVICE], dry_run=dry_run, check=False)


def ensure_pgsql_base_dir(dry_run: bool) -> None:
    if dry_run:
        LOGGER.info("+ mkdir -p %s", PGSQL_BASE_DIR)
        LOGGER.info("+ chown postgres:postgres %s", PGSQL_BASE_DIR)
        LOGGER.info("+ chmod 700 %s", PGSQL_BASE_DIR)
        return
    PGSQL_BASE_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
    shutil.chown(PGSQL_BASE_DIR, user="postgres", group="postgres")
    PGSQL_BASE_DIR.chmod(0o700)


def restore_backup(backup_file: Path, dry_run: bool) -> None:
    if not backup_file.exists() and not dry_run:
        raise Fatal(f"Backup file does not exist: {backup_file}")
    LOGGER.info("Restoring %s", backup_file)
    LOGGER.info("+ runuser -u postgres -- psql < %s", backup_file)
    with local_postgres_peer_auth(dry_run):
        if not dry_run:
            with backup_file.open() as input_file:
                subprocess.run(
                    [*POSTGRES_USER_COMMAND, "psql"],
                    check=True,
                    cwd="/tmp",
                    stdin=input_file,
                )


def destructive_confirmation(args: argparse.Namespace, reason: str) -> None:
    if args.yes and args.i_understand_data_loss:
        return
    LOGGER.warning("WARNING: This operation destroys the current PostgreSQL data directory.")
    LOGGER.warning("Reason: %s", reason)
    LOGGER.warning("Target directory: %s", DATA_DIR)
    if not prompt("Have you created and verified a backup?", assume_yes=False):
        raise Fatal("Cancelled. Create a backup before continuing.")
    phrase = input('Type "DESTROY POSTGRES DATA" to continue: ').strip()
    if phrase != "DESTROY POSTGRES DATA":
        raise Fatal("Cancelled.")


def logical_upgrade_confirmation(args: argparse.Namespace, backup_file: Path) -> None:
    if args.yes:
        LOGGER.info(
            "Proceeding with logical upgrade using backup %s. The existing data "
            "directory will be moved aside before the target cluster is initialized.",
            backup_file,
        )
        return
    LOGGER.warning("WARNING: Logical upgrade requires a clean target data directory.")
    LOGGER.warning("Backup that will be restored: %s", backup_file)
    LOGGER.warning(
        "The current PostgreSQL data directory will be moved aside, not deleted: %s",
        DATA_DIR,
    )
    if not prompt("Proceed with the logical upgrade?"):
        raise Fatal("Cancelled.")


def maybe_backup_before_destructive(args: argparse.Namespace) -> Optional[Path]:
    backup_dir = Path(args.backup_dir)
    has_cluster = existing_data_dir_has_cluster()
    if not has_cluster:
        return None
    if args.skip_backup:
        if not prompt(
            "You used --skip-backup. Continue without a pg_dumpall backup?",
            assume_yes=args.yes and args.i_understand_data_loss,
        ):
            raise Fatal("Cancelled.")
        return None
    if prompt("Create a pg_dumpall backup before changing PostgreSQL?", assume_yes=args.yes):
        return backup_cluster(backup_dir, args.dry_run)
    if not prompt("Continue without creating a new backup?", assume_yes=False):
        raise Fatal("Cancelled.")
    return None


def install_or_reset(args: argparse.Namespace, version: Optional[int]) -> None:
    has_cluster = existing_data_dir_has_cluster()
    current = existing_cluster_version() if has_cluster else None
    if current:
        LOGGER.info("Detected existing PostgreSQL cluster version: %s", current)
    if (
        has_cluster
        and version is not None
        and current is not None
        and current != str(version)
        and not args.reset_data
    ):
        raise Fatal(
            f"Existing PostgreSQL data directory is version {current}, but target "
            f"module stream is {version}. Use --upgrade for a logical dump/restore "
            "or --reset-data if you intend to destroy and recreate the cluster."
        )

    if args.reset_data and has_cluster:
        maybe_backup_before_destructive(args)
        destructive_confirmation(args, "Fresh initialization requested with --reset-data.")
        stop_postgresql(args.dry_run)
        clear_data_dir(args.dry_run)

    reset_module_and_install(version, args.dry_run, args.yes)

    if args.reset_data:
        if args.dry_run or not existing_data_dir_has_cluster():
            initdb_and_start(args.dry_run)
            finalize_cpanel_postgresql(args.dry_run, args.yes)
    elif not existing_data_dir_has_cluster():
        initdb_and_start(args.dry_run)
        finalize_cpanel_postgresql(args.dry_run, args.yes)
    else:
        if not args.dry_run:
            require_data_dir_matches_installed_server()
        run(["systemctl", "enable", "--now", POSTGRES_SERVICE], dry_run=args.dry_run)
        run(["systemctl", "status", "--no-pager", POSTGRES_SERVICE], dry_run=args.dry_run, check=False)


def logical_upgrade(args: argparse.Namespace, version: Optional[int]) -> None:
    if not existing_data_dir_has_cluster():
        LOGGER.info("No existing PostgreSQL data directory found; running a normal install.")
        install_or_reset(args, version)
        return

    current = existing_cluster_version()
    LOGGER.info("Detected existing PostgreSQL cluster version: %s", current or "unknown")
    target = str(version) if version is not None else "repository default"
    LOGGER.info("Target PostgreSQL version: %s", target)
    if (
        current is not None
        and version is not None
        and int(current) > version
        and not args.allow_logical_downgrade
    ):
        raise Fatal(
            f"Logical downgrade from PostgreSQL {current} to {version} is not supported. "
            "Use --allow-logical-downgrade to attempt a best-effort pg_dumpall restore, "
            "or --reset-data with a verified backup if you need to recreate the cluster."
        )
    if current is not None and version is not None and int(current) > version:
        LOGGER.warning(
            f"WARNING: Attempting logical downgrade from PostgreSQL {current} to {version}. "
            "The restore can fail if the dump contains newer PostgreSQL features."
        )
    backup_file = maybe_backup_before_destructive(args)
    if backup_file is None:
        raise Fatal("Upgrade requires a pg_dumpall backup so the cluster can be restored.")

    logical_upgrade_confirmation(args, backup_file)
    moved_data_dir: Optional[Path] = None
    try:
        stop_postgresql(args.dry_run)
        moved_data_dir = move_data_dir_aside("pre-upgrade", args.dry_run)
        reset_module_and_install(version, args.dry_run, args.yes)
        ensure_cluster_running(args.dry_run)
        LOGGER.info("Restoring logical backup into the new PostgreSQL cluster.")
        restore_backup(backup_file, args.dry_run)
        run(["systemctl", "restart", POSTGRES_SERVICE], dry_run=args.dry_run)
        run(["systemctl", "status", "--no-pager", POSTGRES_SERVICE], dry_run=args.dry_run, check=False)
        finalize_cpanel_postgresql(args.dry_run, args.yes)
    except Exception:
        LOGGER.error("Upgrade did not complete.")
        LOGGER.error("Logical backup: %s", backup_file)
        if moved_data_dir is not None:
            LOGGER.error("Previous data directory: %s", moved_data_dir)
        LOGGER.error("Current data directory: %s", DATA_DIR)
        raise


def main() -> int:
    logging.basicConfig(level=logging.INFO, format="%(message)s")
    args = parse_args()
    try:
        require_root()
        require_cpanel()
        version = args.version if args.version is not None else prompt_for_version()
        validate_selected_version(version)

        if args.upgrade:
            logical_upgrade(args, version)
        else:
            install_or_reset(args, version)

        LOGGER.info("Done on %s.", socket.gethostname())
        return 0
    except KeyboardInterrupt:
        LOGGER.info("")
        LOGGER.info("Cancelled.")
        return 130
    except subprocess.CalledProcessError as exc:
        LOGGER.error(
            "Command failed with exit code %s: %s",
            exc.returncode,
            " ".join(exc.cmd),
        )
        if exc.stdout:
            LOGGER.error("%s", exc.stdout.rstrip())
        if exc.stderr:
            LOGGER.error("%s", exc.stderr.rstrip())
        return exc.returncode or 1
    except Fatal as exc:
        LOGGER.error("ERROR: %s", exc)
        return 1


if __name__ == "__main__":
    sys.exit(main())