diff --git a/README.md b/README.md index d903ba8..d637c7e 100644 --- a/README.md +++ b/README.md @@ -406,6 +406,42 @@ pg0 start -c shared_buffers=512MB -c work_mem=128MB pg0 start -c shared_buffers=1GB -c maintenance_work_mem=2GB ``` +### Runtime directories + +The following environment variables allow deployments to keep executables in +an approved location and writable instance data in a separate directory: + +| Variable | Purpose | Default | +|----------|---------|---------| +| `PG0_HOME` | Instance metadata and default data directories | `~/.pg0` | +| `PG0_INSTALLATION_DIR` | Root for extracted PostgreSQL and extension files; contains a version subdirectory such as `18.1.0` | `$PG0_HOME/installation` | +| `PG0_EXECUTABLE` | Python SDK only: full path to the pg0 executable, taking precedence over the wheel's bundled binary | Automatic binary discovery | + +All overrides must be non-empty absolute paths. An invalid override fails with +an error instead of falling back to the user profile. `--data-dir` continues to +override the data directory for an individual instance. Keep `PG0_HOME` set to +the same value for subsequent `info`, `stop`, `drop`, and other commands. + +For example, in PowerShell: + +```powershell +$env:PG0_HOME = Join-Path $env:LOCALAPPDATA 'pg0-state' +$env:PG0_INSTALLATION_DIR = 'C:\ProgramData\pg0\installation' +$env:PG0_EXECUTABLE = 'C:\Program Files\pg0\pg0.exe' +& $env:PG0_EXECUTABLE start --name example +``` + +Provision the executable at `PG0_EXECUTABLE` separately. The first start needs +write access to `PG0_INSTALLATION_DIR` to extract PostgreSQL and pgvector. +On Windows, a complete installation can subsequently be used with read and +execute access while `PG0_HOME` remains writable. Installing or upgrading +extensions still requires write access to the installation. These settings +select locations; the applicable execution policy must allow those locations. +No symbolic links are needed on Windows. + +`PG0_EXECUTABLE` is a runtime setting. The existing `PG0_BINARY_PATH` setting is +used only when building a Python wheel. + ## How It Works PostgreSQL and pgvector are **bundled directly** into the pg0 binary - no downloads required, works completely offline! On first start, pg0 extracts PostgreSQL and pgvector to `~/.pg0/installation/` and initializes the database. diff --git a/sdk/python/README.md b/sdk/python/README.md index c6a6b69..46d4a6a 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -79,6 +79,20 @@ print(info.username) # postgres print(info.database) # postgres ``` +## Runtime paths + +Set `PG0_EXECUTABLE` to the absolute path of an existing pg0 executable to use +it instead of the binary bundled in the wheel. An invalid override raises +`Pg0NotFoundError`; it never silently falls back to the bundled binary. + +The CLI also accepts `PG0_HOME` for writable instance state and +`PG0_INSTALLATION_DIR` for extracted PostgreSQL and pgvector files. Both must +be absolute paths and are inherited by SDK subprocesses. The installation root +contains a version subdirectory, for example `18.1.0/bin/postgres.exe`. +`--data-dir` / `Pg0(data_dir=...)` continues to override an instance's data path. +See [runtime directories](https://github.com/vectorize-io/pg0#runtime-directories) +for provisioning and permission requirements. + ## Supported Platforms Pre-built wheels are available for: diff --git a/sdk/python/pg0/__init__.py b/sdk/python/pg0/__init__.py index e8493d6..06aa3a0 100644 --- a/sdk/python/pg0/__init__.py +++ b/sdk/python/pg0/__init__.py @@ -101,6 +101,19 @@ def _get_install_dir() -> Path: def _find_pg0() -> str: """Find the pg0 binary or raise an error if not found.""" + # An explicit executable takes precedence over the wheel's bundled binary. + # Do not silently fall back to a location blocked by an execution policy. + configured = os.environ.get("PG0_EXECUTABLE") + if configured is not None: + binary = Path(configured) + if not binary.is_absolute() or not binary.is_file(): + raise Pg0NotFoundError( + "PG0_EXECUTABLE must be an absolute path to an existing pg0 executable" + ) + if sys.platform != "win32" and not os.access(binary, os.X_OK): + raise Pg0NotFoundError("PG0_EXECUTABLE is not executable") + return str(binary) + # Check for bundled binary first (from platform-specific wheel) bundled = _get_bundled_binary() if bundled: diff --git a/sdk/python/tests/test_runtime_paths.py b/sdk/python/tests/test_runtime_paths.py new file mode 100644 index 0000000..ab9dc2d --- /dev/null +++ b/sdk/python/tests/test_runtime_paths.py @@ -0,0 +1,98 @@ +"""Runtime executable selection and separate native/state directories.""" + +import json +import subprocess +import shutil +import sys +from pathlib import Path + +import pytest +import pg0 + + +def test_explicit_executable_precedes_bundled_binary(tmp_path, monkeypatch): + executable = tmp_path / "approved directory" / ("pg0.exe" if sys.platform == "win32" else "pg0") + executable.parent.mkdir() + executable.write_bytes(b"test executable") + executable.chmod(0o755) + monkeypatch.setenv("PG0_EXECUTABLE", str(executable)) + monkeypatch.setattr(pg0, "_get_bundled_binary", lambda: pytest.fail("unexpected fallback")) + assert pg0._find_pg0() == str(executable) + + +@pytest.mark.parametrize("value", ["", "relative/pg0", "missing"]) +def test_invalid_executable_does_not_fall_back(tmp_path, monkeypatch, value): + if value == "missing": + value = str(tmp_path / "missing") + monkeypatch.setenv("PG0_EXECUTABLE", value) + monkeypatch.setattr(pg0, "_get_bundled_binary", lambda: pytest.fail("unexpected fallback")) + with pytest.raises(pg0.Pg0NotFoundError, match="PG0_EXECUTABLE"): + pg0._find_pg0() + + +def test_directory_is_not_an_executable(tmp_path, monkeypatch): + monkeypatch.setenv("PG0_EXECUTABLE", str(tmp_path)) + with pytest.raises(pg0.Pg0NotFoundError, match="PG0_EXECUTABLE"): + pg0._find_pg0() + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX executable permission") +def test_non_executable_file_is_rejected(tmp_path, monkeypatch): + executable = tmp_path / "pg0" + executable.touch(mode=0o600) + monkeypatch.setenv("PG0_EXECUTABLE", str(executable)) + with pytest.raises(pg0.Pg0NotFoundError, match="not executable"): + pg0._find_pg0() + + +def test_unset_override_keeps_bundled_discovery(tmp_path, monkeypatch): + monkeypatch.delenv("PG0_EXECUTABLE", raising=False) + bundled = tmp_path / "bundled-pg0" + monkeypatch.setattr(pg0, "_get_bundled_binary", lambda: bundled) + assert pg0._find_pg0() == str(bundled) + + +def test_native_installation_and_state_can_be_separated(tmp_path, monkeypatch): + """Exercise the built CLI through SDK discovery, SQL, stop, and restart.""" + if sys.platform == "win32": + # Elevated Windows sessions can make pytest's mode=0700 directory + # owned by Administrators. initdb removes that group from its token. + sid = subprocess.check_output( + ["powershell.exe", "-NoProfile", "-Command", + "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value"], + text=True, + ).strip() + subprocess.run( + ["icacls", str(tmp_path), "/grant:r", f"*{sid}:(OI)(CI)(F)"], + check=True, capture_output=True, + ) + original = Path(pg0._find_pg0()) + approved = tmp_path / "approved executables" + approved.mkdir() + executable = approved / original.name + shutil.copy2(original, executable) + state = tmp_path / "writable state" + installation = approved / "installation" + monkeypatch.setenv("PG0_EXECUTABLE", str(executable)) + monkeypatch.setenv("PG0_HOME", str(state)) + monkeypatch.setenv("PG0_INSTALLATION_DIR", str(installation)) + instance = pg0.Pg0(name="runtime-paths") + try: + instance.start() + metadata = json.loads( + (state / "instances/runtime-paths/instance.json").read_text() + ) + assert Path(metadata["installation_dir"]) == installation + assert Path(instance.info().data_dir) == state / "instances/runtime-paths/data" + instance.execute("CREATE EXTENSION IF NOT EXISTS vector") + result = instance.execute("SELECT '[1,2]'::vector <-> '[1,2]'::vector") + assert "0" in result + instance.execute("CREATE TABLE path_test (value integer)") + instance.execute("INSERT INTO path_test VALUES (42)") + instance.stop() + instance.start() + assert "42" in instance.execute("SELECT value FROM path_test") + assert any(installation.glob("*/bin/postgres*")) + assert not (state / "installation").exists() + finally: + instance.drop(force=True) diff --git a/src/main.rs b/src/main.rs index 617f36d..0396d63 100644 --- a/src/main.rs +++ b/src/main.rs @@ -215,11 +215,45 @@ struct InfoOutput { } fn get_base_dir() -> Result { + if let Some(path) = configured_directory("PG0_HOME", std::env::var_os("PG0_HOME"))? { + return Ok(path); + } dirs::home_dir() .map(|h| h.join(".pg0")) .ok_or(CliError::NoDataDir) } +/// Explicit paths must be absolute so a later command can find the same files +/// even when invoked from a different working directory. Preserve OS strings. +fn configured_directory( + variable: &str, + value: Option, +) -> Result, CliError> { + match value { + None => Ok(None), + Some(value) => { + let path = PathBuf::from(value); + if !path.is_absolute() { + return Err(CliError::Other(format!( + "{} must be a non-empty absolute directory path", + variable + ))); + } + Ok(Some(path)) + } + } +} + +fn get_installation_dir() -> Result { + match configured_directory( + "PG0_INSTALLATION_DIR", + std::env::var_os("PG0_INSTALLATION_DIR"), + )? { + Some(path) => Ok(path), + None => Ok(get_base_dir()?.join("installation")), + } +} + fn get_instances_dir() -> Result { Ok(get_base_dir()?.join("instances")) } @@ -814,7 +848,6 @@ fn start( port }; - let base_dir = get_base_dir()?; let instance_dir = get_instance_dir(&name)?; // Use provided data_dir or default to instance-specific directory @@ -823,7 +856,7 @@ fn start( None => instance_dir.join("data"), }; - let installation_dir = base_dir.join("installation"); + let installation_dir = get_installation_dir()?; fs::create_dir_all(&data_dir)?; fs::create_dir_all(&installation_dir)?; @@ -1695,6 +1728,35 @@ fn init_logging(verbose: bool) { .init(); } +#[cfg(test)] +mod directory_tests { + use super::*; + + #[test] + fn unset_directory_keeps_the_default() { + assert!(configured_directory("PG0_HOME", None).unwrap().is_none()); + } + + #[test] + fn explicit_directory_preserves_spaces() { + let path = std::env::temp_dir().join("pg0 installation with spaces"); + assert_eq!( + configured_directory("PG0_INSTALLATION_DIR", Some(path.clone().into_os_string())) + .unwrap(), + Some(path) + ); + } + + #[test] + fn invalid_directory_does_not_fall_back() { + for value in ["", "relative/path"] { + let error = configured_directory("PG0_HOME", Some(value.into())).unwrap_err(); + assert!(error.to_string().contains("PG0_HOME")); + assert!(error.to_string().contains("absolute")); + } + } +} + #[cfg(all(test, unix))] mod tests { use super::*;