← Files RailwayARCHIVED FILE

scripts/pg-extensions.py

13 KB · Sep 30, 2026 · 23:18 UTC

↓ Download file

#!/usr/bin/env python3
"""
Manage PostgreSQL extensions for Railway database services.

This script replicates the frontend's Extensions.tsx logic:
- List available and installed extensions
- Install extensions with automatic dependency handling
- Uninstall extensions with dependency checking

Usage:
    pg-extensions.py --service <name> list
    pg-extensions.py --service <name> install <extension> [--version <ver>]
    pg-extensions.py --service <name> uninstall <extension>
    pg-extensions.py --service <name> info <extension>

Requires: railway CLI linked to the correct project/environment

IMPORTANT: Install and uninstall require interactive terminal confirmation.
They cannot be run with piped input - the user must confirm directly.
"""

import argparse
import sys
import json
from typing import Tuple, List, Optional, Dict, Any
from dataclasses import dataclass

from dal import run_psql_query, info, error, confirm_with_user


@dataclass
class Extension:
    """PostgreSQL extension info."""
    name: str
    default_version: str
    installed_version: Optional[str]
    comment: str


def list_extensions(service: str, json_output: bool = False) -> List[Extension]:
    """List all available and installed extensions."""
    # Query available extensions
    available_query = "SELECT name, default_version, comment FROM pg_available_extensions ORDER BY name"
    code, output = run_psql_query(service, available_query)
    if code != 0:
        error(f"Failed to query available extensions: {output}")

    # Parse available extensions
    available = {}
    for line in output.strip().split("\n"):
        if not line:
            continue
        parts = line.split("|")
        if len(parts) >= 2:
            name = parts[0].strip()
            version = parts[1].strip()
            comment = parts[2].strip() if len(parts) > 2 else ""
            available[name] = {"version": version, "comment": comment}

    # Query installed extensions
    installed_query = "SELECT extname, extversion FROM pg_extension"
    code, output = run_psql_query(service, installed_query)
    if code != 0:
        error(f"Failed to query installed extensions: {output}")

    # Parse installed extensions
    installed = {}
    for line in output.strip().split("\n"):
        if not line:
            continue
        parts = line.split("|")
        if len(parts) >= 2:
            name = parts[0].strip()
            version = parts[1].strip()
            installed[name] = version

    # Build extension list
    extensions = []
    for name, info in available.items():
        ext = Extension(
            name=name,
            default_version=info["version"],
            installed_version=installed.get(name),
            comment=info["comment"]
        )
        extensions.append(ext)

    if json_output:
        print(json.dumps([{
            "name": e.name,
            "defaultVersion": e.default_version,
            "installedVersion": e.installed_version,
            "comment": e.comment
        } for e in extensions], indent=2))
    else:
        # Print formatted output
        installed_exts = [e for e in extensions if e.installed_version]
        available_exts = [e for e in extensions if not e.installed_version]

        print(f"\n{len(installed_exts)} Extension(s) installed:")
        print("-" * 60)
        if installed_exts:
            for e in sorted(installed_exts, key=lambda x: x.name):
                print(f"  {e.name} (v{e.installed_version})")
        else:
            print("  (none)")

        print(f"\n{len(available_exts)} Extension(s) available:")
        print("-" * 60)
        for e in sorted(available_exts, key=lambda x: x.name)[:30]:
            desc = f" - {e.comment[:50]}..." if e.comment and len(e.comment) > 50 else f" - {e.comment}" if e.comment else ""
            print(f"  {e.name} (v{e.default_version}){desc}")
        if len(available_exts) > 30:
            print(f"  ... and {len(available_exts) - 30} more")

    return extensions


def get_extension_dependencies(service: str, extension: str) -> List[str]:
    """Get dependencies for an extension."""
    query = f"""
        SELECT DISTINCT unnest(pev.requires) as dependency
        FROM pg_available_extension_versions pev
        WHERE pev.name = '{extension}' AND pev.requires IS NOT NULL
        ORDER BY dependency
    """
    code, output = run_psql_query(service, query)
    if code != 0:
        return []

    deps = []
    for line in output.strip().split("\n"):
        if line.strip():
            deps.append(line.strip())
    return deps


def get_extension_dependents(service: str, extension: str) -> List[str]:
    """Get extensions that depend on this extension."""
    query = f"""
        SELECT e.extname AS dependent_extension
        FROM pg_depend d
        JOIN pg_extension e ON d.objid = e.oid
        JOIN pg_extension ref_e ON d.refobjid = ref_e.oid
        WHERE d.classid = 'pg_extension'::regclass
            AND d.refclassid = 'pg_extension'::regclass
            AND ref_e.extname = '{extension}'
        ORDER BY dependent_extension
    """
    code, output = run_psql_query(service, query)
    if code != 0:
        return []

    dependents = []
    for line in output.strip().split("\n"):
        if line.strip():
            dependents.append(line.strip())
    return dependents


def is_extension_installed(service: str, extension: str) -> Tuple[bool, Optional[str]]:
    """Check if extension is installed, return (installed, version)."""
    query = f"SELECT extversion FROM pg_extension WHERE extname = '{extension}'"
    code, output = run_psql_query(service, query)
    if code == 0 and output.strip():
        return True, output.strip()
    return False, None


def is_extension_available(service: str, extension: str) -> bool:
    """Check if extension is available in the image."""
    query = f"SELECT 1 FROM pg_available_extensions WHERE name = '{extension}'"
    code, output = run_psql_query(service, query)
    return code == 0 and output.strip() == "1"


def install_extension(service: str, extension: str, version: Optional[str] = None) -> bool:
    """Install an extension with CASCADE (auto-install dependencies)."""
    # Check if available
    if not is_extension_available(service, extension):
        error(f"Extension '{extension}' is not available in this database image")

    # Check if already installed
    installed, curr_ver = is_extension_installed(service, extension)
    if installed:
        info(f"Extension '{extension}' is already installed (v{curr_ver})")
        return True

    # Check dependencies and confirm - REQUIRES interactive terminal
    deps = get_extension_dependencies(service, extension)
    if deps:
        print(f"\nInstalling '{extension}' will also install these dependencies: {', '.join(deps)}")
    else:
        print(f"\nAbout to install extension '{extension}'")

    if not confirm_with_user("Continue? [y/N]"):
        print("Cancelled.")
        return False

    # Build install query
    query = f'CREATE EXTENSION IF NOT EXISTS "{extension}"'
    if version:
        query += f" VERSION '{version}'"
    query += " CASCADE"  # Auto-install dependencies

    info(f"Installing extension '{extension}'...")
    code, output = run_psql_query(service, query)
    if code != 0:
        error(f"Failed to install extension: {output}")

    # Verify installation
    installed, ver = is_extension_installed(service, extension)
    if installed:
        info(f"Successfully installed '{extension}' (v{ver})")
        if deps:
            info(f"Dependencies installed: {', '.join(deps)}")
        return True
    else:
        error("Extension installation failed - not found after CREATE EXTENSION")
    return False


def uninstall_extension(service: str, extension: str) -> bool:
    """Uninstall an extension."""
    # Check if installed
    installed, ver = is_extension_installed(service, extension)
    if not installed:
        info(f"Extension '{extension}' is not installed")
        return True

    # Check for dependents
    dependents = get_extension_dependents(service, extension)
    if dependents:
        error(f"Cannot uninstall '{extension}' - these extensions depend on it: {', '.join(dependents)}")

    # Confirm - REQUIRES interactive terminal
    print(f"\nAbout to uninstall '{extension}' (v{ver})")
    if not confirm_with_user("Continue? [y/N]"):
        print("Cancelled.")
        return False

    # Uninstall
    query = f'DROP EXTENSION IF EXISTS "{extension}"'
    info(f"Uninstalling extension '{extension}'...")
    code, output = run_psql_query(service, query)
    if code != 0:
        error(f"Failed to uninstall extension: {output}")

    # Verify
    installed, _ = is_extension_installed(service, extension)
    if not installed:
        info(f"Successfully uninstalled '{extension}'")
        return True
    else:
        error("Extension uninstall failed - still found after DROP EXTENSION")
    return False


def extension_info(service: str, extension: str, json_output: bool = False):
    """Show detailed info about an extension."""
    # Check availability
    available = is_extension_available(service, extension)
    if not available:
        error(f"Extension '{extension}' is not available in this database image")

    # Get info
    query = f"SELECT name, default_version, comment FROM pg_available_extensions WHERE name = '{extension}'"
    code, output = run_psql_query(service, query)
    if code != 0:
        error(f"Failed to get extension info: {output}")

    parts = output.strip().split("|")
    name = parts[0].strip() if parts else extension
    default_version = parts[1].strip() if len(parts) > 1 else "unknown"
    comment = parts[2].strip() if len(parts) > 2 else ""

    installed, installed_version = is_extension_installed(service, extension)
    deps = get_extension_dependencies(service, extension)
    dependents = get_extension_dependents(service, extension) if installed else []

    if json_output:
        print(json.dumps({
            "name": name,
            "defaultVersion": default_version,
            "installedVersion": installed_version,
            "comment": comment,
            "dependencies": deps,
            "dependents": dependents
        }, indent=2))
    else:
        print(f"\nExtension: {name}")
        print("-" * 40)
        print(f"  Default Version: {default_version}")
        print(f"  Installed: {'Yes (v' + installed_version + ')' if installed else 'No'}")
        if comment:
            print(f"  Description: {comment}")
        if deps:
            print(f"  Dependencies: {', '.join(deps)}")
        if dependents:
            print(f"  Required by: {', '.join(dependents)}")
        print()


def main():
    parser = argparse.ArgumentParser(
        description="Manage PostgreSQL extensions for Railway services.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  List all extensions (safe, no confirmation needed):
    pg-extensions.py --service my-postgres list

  Install an extension (requires interactive confirmation):
    pg-extensions.py --service my-postgres install postgis
    pg-extensions.py --service my-postgres install pgvector --version 0.5.0

  Uninstall an extension (requires interactive confirmation):
    pg-extensions.py --service my-postgres uninstall postgis

  Get extension info (safe, no confirmation needed):
    pg-extensions.py --service my-postgres info pg_stat_statements

IMPORTANT: Install and uninstall require interactive terminal confirmation.
They cannot be automated or run with piped input.
        """
    )

    parser.add_argument("--service", required=True, help="Service name (requires linked project)")
    parser.add_argument("--json", action="store_true", help="Output as JSON")

    subparsers = parser.add_subparsers(dest="command", help="Command to run")

    # list command (safe - no confirmation)
    list_parser = subparsers.add_parser("list", help="List available and installed extensions")

    # install command (requires interactive confirmation)
    install_parser = subparsers.add_parser("install", help="Install an extension (requires confirmation)")
    install_parser.add_argument("extension", help="Extension name")
    install_parser.add_argument("--version", "-v", help="Specific version to install")

    # uninstall command (requires interactive confirmation)
    uninstall_parser = subparsers.add_parser("uninstall", help="Uninstall an extension (requires confirmation)")
    uninstall_parser.add_argument("extension", help="Extension name")

    # info command
    info_parser = subparsers.add_parser("info", help="Show extension info")
    info_parser.add_argument("extension", help="Extension name")

    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        return 1

    service = args.service

    if args.command == "list":
        list_extensions(service, json_output=args.json)
    elif args.command == "install":
        install_extension(service, args.extension, version=args.version)
    elif args.command == "uninstall":
        uninstall_extension(service, args.extension)
    elif args.command == "info":
        extension_info(service, args.extension, json_output=args.json)

    return 0


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

SHA-256: 443b34c6fe7418090e426a8830062106f4f89309a754c684d7b9e720e443fc9d