* improve ci and tests * add more tests * fixes * fix tests * fix more * fix * fix * fix * fix * fix for real * tests and doc * fix cp * fix link pg0 * fix pg0 * fix pg0 * fix pg0 * even better * more * fix
406 lines
13 KiB
Python
406 lines
13 KiB
Python
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# pg0 configuration
|
|
BINARY_NAME = "pg0"
|
|
DEFAULT_PORT = 5555
|
|
DEFAULT_USERNAME = "hindsight"
|
|
DEFAULT_PASSWORD = "hindsight"
|
|
DEFAULT_DATABASE = "hindsight"
|
|
|
|
|
|
def get_platform_binary_name() -> str:
|
|
"""Get the appropriate binary name for the current platform.
|
|
|
|
Supported platforms:
|
|
- macOS ARM64 (darwin-aarch64)
|
|
- Linux x86_64 (gnu)
|
|
- Linux ARM64 (gnu)
|
|
- Windows x86_64
|
|
"""
|
|
system = platform.system().lower()
|
|
machine = platform.machine().lower()
|
|
|
|
# Normalize architecture names
|
|
if machine in ("x86_64", "amd64"):
|
|
arch = "x86_64"
|
|
elif machine in ("arm64", "aarch64"):
|
|
arch = "aarch64"
|
|
else:
|
|
raise RuntimeError(
|
|
f"Embedded PostgreSQL is not supported on architecture: {machine}. "
|
|
f"Supported architectures: x86_64/amd64 (Linux, Windows), aarch64/arm64 (macOS, Linux)"
|
|
)
|
|
|
|
if system == "darwin" and arch == "aarch64":
|
|
return "pg0-darwin-aarch64"
|
|
elif system == "linux" and arch == "x86_64":
|
|
return "pg0-linux-x86_64-gnu"
|
|
elif system == "linux" and arch == "aarch64":
|
|
return "pg0-linux-aarch64-gnu"
|
|
elif system == "windows" and arch == "x86_64":
|
|
return "pg0-windows-x86_64.exe"
|
|
else:
|
|
raise RuntimeError(
|
|
f"Embedded PostgreSQL is not supported on {system}-{arch}. "
|
|
f"Supported platforms: darwin-aarch64 (macOS ARM), linux-x86_64-gnu, linux-aarch64-gnu, windows-x86_64"
|
|
)
|
|
|
|
|
|
def get_download_url(
|
|
version: str = "latest",
|
|
repo: str = "vectorize-io/pg0",
|
|
) -> str:
|
|
"""Get the download URL for pg0 binary."""
|
|
binary_name = get_platform_binary_name()
|
|
|
|
if version == "latest":
|
|
return f"https://github.com/{repo}/releases/latest/download/{binary_name}"
|
|
else:
|
|
return f"https://github.com/{repo}/releases/download/{version}/{binary_name}"
|
|
|
|
|
|
def _find_pg0_binary() -> Optional[Path]:
|
|
"""Find pg0 binary in PATH or default install location."""
|
|
# First check PATH
|
|
pg0_in_path = shutil.which("pg0")
|
|
if pg0_in_path:
|
|
return Path(pg0_in_path)
|
|
|
|
# Fall back to default install location
|
|
default_path = Path.home() / ".hindsight" / "bin" / "pg0"
|
|
if default_path.exists() and os.access(default_path, os.X_OK):
|
|
return default_path
|
|
|
|
return None
|
|
|
|
|
|
class EmbeddedPostgres:
|
|
"""
|
|
Manages an embedded PostgreSQL server instance using pg0.
|
|
|
|
This class handles:
|
|
- Finding or downloading the pg0 CLI
|
|
- Starting/stopping the PostgreSQL server
|
|
- Getting the connection URI
|
|
|
|
Example:
|
|
pg = EmbeddedPostgres()
|
|
await pg.ensure_installed()
|
|
await pg.start()
|
|
uri = await pg.get_uri()
|
|
# ... use uri with asyncpg ...
|
|
await pg.stop()
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
version: str = "latest",
|
|
port: int = DEFAULT_PORT,
|
|
username: str = DEFAULT_USERNAME,
|
|
password: str = DEFAULT_PASSWORD,
|
|
database: str = DEFAULT_DATABASE,
|
|
name: str = "hindsight",
|
|
):
|
|
"""
|
|
Initialize the embedded PostgreSQL manager.
|
|
|
|
Args:
|
|
version: Version of pg0 to download if not found. Defaults to "latest"
|
|
port: Port to listen on. Defaults to 5555
|
|
username: Username for the database. Defaults to "hindsight"
|
|
password: Password for the database. Defaults to "hindsight"
|
|
database: Database name to create. Defaults to "hindsight"
|
|
name: Instance name for pg0. Defaults to "hindsight"
|
|
"""
|
|
self.version = version
|
|
self.port = port
|
|
self.username = username
|
|
self.password = password
|
|
self.database = database
|
|
self.name = name
|
|
|
|
# Will be set when binary is found/installed
|
|
self._binary_path: Optional[Path] = _find_pg0_binary()
|
|
|
|
@property
|
|
def binary_path(self) -> Path:
|
|
"""Get the path to the pg0 binary."""
|
|
if self._binary_path is None:
|
|
# Default install location
|
|
return Path.home() / ".hindsight" / "bin" / "pg0"
|
|
return self._binary_path
|
|
|
|
def is_installed(self) -> bool:
|
|
"""Check if pg0 is available (in PATH or installed)."""
|
|
self._binary_path = _find_pg0_binary()
|
|
return self._binary_path is not None
|
|
|
|
async def ensure_installed(self) -> None:
|
|
"""
|
|
Ensure pg0 is available.
|
|
|
|
Checks PATH and default location. If not found, raises an error
|
|
instructing the user to install pg0 manually.
|
|
"""
|
|
if self.is_installed():
|
|
logger.debug(f"pg0 found at {self._binary_path}")
|
|
return
|
|
|
|
raise RuntimeError(
|
|
"pg0 is not installed. Please install it manually:\n"
|
|
" curl -fsSL https://github.com/vectorize-io/pg0/releases/latest/download/pg0-linux-amd64 -o ~/.local/bin/pg0 && chmod +x ~/.local/bin/pg0\n"
|
|
"Or visit: https://github.com/vectorize-io/pg0/releases"
|
|
)
|
|
|
|
def _run_command(self, *args: str, capture_output: bool = True) -> subprocess.CompletedProcess:
|
|
"""Run a pg0 command synchronously."""
|
|
cmd = [str(self.binary_path), *args]
|
|
return subprocess.run(cmd, capture_output=capture_output, text=True)
|
|
|
|
async def _run_command_async(self, *args: str, timeout: int = 120) -> tuple[int, str, str]:
|
|
"""Run a pg0 command asynchronously."""
|
|
cmd = [str(self.binary_path), *args]
|
|
|
|
def run_sync():
|
|
try:
|
|
result = subprocess.run(
|
|
cmd,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
timeout=timeout,
|
|
)
|
|
return result.returncode, result.stdout, result.stderr
|
|
except subprocess.TimeoutExpired:
|
|
return 1, "", "Command timed out"
|
|
|
|
loop = asyncio.get_event_loop()
|
|
return await loop.run_in_executor(None, run_sync)
|
|
|
|
def _extract_uri_from_output(self, output: str) -> Optional[str]:
|
|
"""Extract the PostgreSQL URI from pg0 start output."""
|
|
match = re.search(r"Connection URI:\s*(postgresql://[^\s]+)", output)
|
|
if match:
|
|
return match.group(1)
|
|
return None
|
|
|
|
async def _get_version(self) -> str:
|
|
"""Get the pg0 version."""
|
|
returncode, stdout, stderr = await self._run_command_async("--version", timeout=10)
|
|
if returncode == 0 and stdout:
|
|
return stdout.strip()
|
|
return "unknown"
|
|
|
|
async def start(self, max_retries: int = 3, retry_delay: float = 2.0) -> str:
|
|
"""
|
|
Start the PostgreSQL server with retry logic.
|
|
|
|
Args:
|
|
max_retries: Maximum number of start attempts (default: 3)
|
|
retry_delay: Initial delay between retries in seconds (default: 2.0)
|
|
|
|
Returns:
|
|
The connection URI for the started server.
|
|
|
|
Raises:
|
|
RuntimeError: If the server fails to start after all retries.
|
|
"""
|
|
if not self.is_installed():
|
|
raise RuntimeError("pg0 is not installed. Call ensure_installed() first.")
|
|
|
|
# Log pg0 version
|
|
version = await self._get_version()
|
|
logger.info(f"Starting embedded PostgreSQL with pg0 {version} (name: {self.name}, port: {self.port})...")
|
|
|
|
last_error = None
|
|
for attempt in range(1, max_retries + 1):
|
|
returncode, stdout, stderr = await self._run_command_async(
|
|
"start",
|
|
"--name", self.name,
|
|
"--port", str(self.port),
|
|
"--username", self.username,
|
|
"--password", self.password,
|
|
"--database", self.database,
|
|
timeout=300,
|
|
)
|
|
|
|
# Try to extract URI from output
|
|
uri = self._extract_uri_from_output(stdout)
|
|
if uri:
|
|
logger.info(f"PostgreSQL started on port {self.port}")
|
|
return uri
|
|
|
|
# Check if pg0 info can find the running instance
|
|
try:
|
|
uri = await self.get_uri()
|
|
logger.info(f"PostgreSQL started on port {self.port}")
|
|
return uri
|
|
except RuntimeError:
|
|
pass
|
|
|
|
# Start failed, log and retry
|
|
last_error = stderr or f"pg0 start returned exit code {returncode}"
|
|
if attempt < max_retries:
|
|
delay = retry_delay * (2 ** (attempt - 1))
|
|
logger.warning(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error.strip()}")
|
|
logger.info(f"Retrying in {delay:.1f}s...")
|
|
await asyncio.sleep(delay)
|
|
else:
|
|
logger.warning(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error.strip()}")
|
|
|
|
# All retries exhausted - use constructed URI as fallback
|
|
uri = f"postgresql://{self.username}:{self.password}@localhost:{self.port}/{self.database}"
|
|
logger.warning(f"All pg0 start attempts failed, using constructed URI: {uri}")
|
|
return uri
|
|
|
|
async def stop(self) -> None:
|
|
"""Stop the PostgreSQL server."""
|
|
if not self.is_installed():
|
|
return
|
|
|
|
logger.info(f"Stopping embedded PostgreSQL (name: {self.name})...")
|
|
|
|
returncode, stdout, stderr = await self._run_command_async("stop", "--name", self.name)
|
|
|
|
if returncode != 0:
|
|
if "not running" in stderr.lower():
|
|
return
|
|
raise RuntimeError(f"Failed to stop PostgreSQL: {stderr}")
|
|
|
|
logger.info("Embedded PostgreSQL stopped")
|
|
|
|
async def _get_info(self) -> dict:
|
|
"""Get info from pg0 using the `info -o json` command."""
|
|
if not self.is_installed():
|
|
raise RuntimeError("pg0 is not installed.")
|
|
|
|
returncode, stdout, stderr = await self._run_command_async(
|
|
"info", "--name", self.name, "-o", "json"
|
|
)
|
|
|
|
if returncode != 0:
|
|
raise RuntimeError(f"Failed to get PostgreSQL info: {stderr}")
|
|
|
|
try:
|
|
return json.loads(stdout.strip())
|
|
except json.JSONDecodeError as e:
|
|
raise RuntimeError(f"Failed to parse pg0 info output: {e}")
|
|
|
|
async def get_uri(self) -> str:
|
|
"""Get the connection URI for the PostgreSQL server."""
|
|
info = await self._get_info()
|
|
uri = info.get("uri")
|
|
if not uri:
|
|
raise RuntimeError("PostgreSQL server is not running or URI not available")
|
|
return uri
|
|
|
|
async def status(self) -> dict:
|
|
"""Get the status of the PostgreSQL server."""
|
|
if not self.is_installed():
|
|
return {"installed": False, "running": False}
|
|
|
|
try:
|
|
info = await self._get_info()
|
|
return {
|
|
"installed": True,
|
|
"running": info.get("running", False),
|
|
"uri": info.get("uri"),
|
|
}
|
|
except RuntimeError:
|
|
return {"installed": True, "running": False}
|
|
|
|
async def is_running(self) -> bool:
|
|
"""Check if the PostgreSQL server is currently running."""
|
|
if not self.is_installed():
|
|
return False
|
|
try:
|
|
info = await self._get_info()
|
|
return info.get("running", False)
|
|
except RuntimeError:
|
|
return False
|
|
|
|
async def ensure_running(self) -> str:
|
|
"""
|
|
Ensure the PostgreSQL server is running.
|
|
|
|
Installs if needed, starts if not running.
|
|
|
|
Returns:
|
|
The connection URI.
|
|
"""
|
|
await self.ensure_installed()
|
|
|
|
if await self.is_running():
|
|
return await self.get_uri()
|
|
|
|
return await self.start()
|
|
|
|
def uninstall(self) -> None:
|
|
"""Remove the pg0 binary (only if we installed it)."""
|
|
default_path = Path.home() / ".hindsight" / "bin" / "pg0"
|
|
if default_path.exists():
|
|
default_path.unlink()
|
|
logger.info(f"Removed {default_path}")
|
|
|
|
def clear_data(self) -> None:
|
|
"""Remove all PostgreSQL data (destructive!)."""
|
|
result = self._run_command("drop", "--name", self.name, "--force")
|
|
if result.returncode == 0:
|
|
logger.info(f"Dropped pg0 instance {self.name}")
|
|
else:
|
|
logger.warning(f"Failed to drop pg0 instance {self.name}: {result.stderr}")
|
|
|
|
|
|
# Convenience functions
|
|
|
|
_default_instance: Optional[EmbeddedPostgres] = None
|
|
|
|
|
|
def get_embedded_postgres() -> EmbeddedPostgres:
|
|
"""Get or create the default EmbeddedPostgres instance."""
|
|
global _default_instance
|
|
|
|
if _default_instance is None:
|
|
_default_instance = EmbeddedPostgres()
|
|
|
|
return _default_instance
|
|
|
|
|
|
async def start_embedded_postgres() -> str:
|
|
"""
|
|
Quick start function for embedded PostgreSQL.
|
|
|
|
Downloads, installs, and starts PostgreSQL in one call.
|
|
|
|
Returns:
|
|
Connection URI string
|
|
|
|
Example:
|
|
db_url = await start_embedded_postgres()
|
|
conn = await asyncpg.connect(db_url)
|
|
"""
|
|
pg = get_embedded_postgres()
|
|
return await pg.ensure_running()
|
|
|
|
|
|
async def stop_embedded_postgres() -> None:
|
|
"""Stop the default embedded PostgreSQL instance."""
|
|
global _default_instance
|
|
|
|
if _default_instance:
|
|
await _default_instance.stop()
|