Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ axum = { version = "0.8", features = ["macros"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["compression-gzip", "cors", "set-header"] }
hyper = { version = "1" }
urlencoding = { version = "2.1" }

# Database plugin registry
inventory = "0.3"
Expand Down
2 changes: 1 addition & 1 deletion crates/bin/src/cmd_init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ pub struct InitArgs {
#[arg(long)]
catalog_db: Option<String>,

/// PostgreSQL host
/// PostgreSQL host (hostname, IP address, or absolute Unix socket directory path)
#[arg(long)]
pg_host: Option<String>,

Expand Down
1 change: 1 addition & 0 deletions crates/storage-postgres/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@ bcrypt = { workspace = true }
aes-gcm = { workspace = true }
async-trait = { workspace = true }
zeroize = { workspace = true }
urlencoding = { workspace = true }

142 changes: 137 additions & 5 deletions crates/storage-postgres/src/bootstrapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,22 @@ impl PostgresBootstrapper {
}

/// Build the connection URL for the application user and a named database.
///
/// URL-encodes all components to handle special characters:
/// - Unix socket paths (e.g., `/var/run/postgresql` → `%2Fvar%2Frun%2Fpostgresql`)
/// - Passwords with special chars (e.g., `pass@word` → `pass%40word`)
/// - Database names with special chars
///
/// PostgreSQL's libpq automatically decodes percent-encoded values per RFC 3986.
fn app_connection_url(&self, database: &str) -> String {
let host_encoded = urlencoding::encode(&self.config.host);
let user_encoded = urlencoding::encode(&self.config.app_user);
let pass_encoded = urlencoding::encode(&self.config.app_password);
let db_encoded = urlencoding::encode(database);

format!(
"postgresql://{}:{}@{}:{}/{}",
self.config.app_user,
self.config.app_password,
self.config.host,
self.config.port,
database,
user_encoded, pass_encoded, host_encoded, self.config.port, db_encoded,
)
}

Expand Down Expand Up @@ -604,3 +612,127 @@ fn check_conflict<T: PartialEq + std::fmt::Display>(
fn extract_arg(args: &[String], flag: &str) -> Option<String> {
args.windows(2).find(|w| w[0] == flag).map(|w| w[1].clone())
}

#[cfg(test)]
mod tests {
use super::*;
use extenddb_storage::bootstrapper::BootstrapConfig;

#[test]
fn test_connection_url_tcp_host() {
let config = BootstrapConfig {
host: "localhost".to_string(),
port: 5432,
admin_user: "postgres".to_string(),
admin_password: None,
app_user: "extenddb".to_string(),
app_password: "testpass".to_string(),
catalog_db: "extenddb_catalog".to_string(),
data_db: "extenddb".to_string(),
};
let bootstrapper = PostgresBootstrapper::new(config);
let url = bootstrapper.catalog_connection_url();

assert_eq!(
url,
"postgresql://extenddb:testpass@localhost:5432/extenddb_catalog"
);
}

#[test]
fn test_connection_url_unix_socket() {
let config = BootstrapConfig {
host: "/var/run/postgresql".to_string(),
port: 5432,
admin_user: "postgres".to_string(),
admin_password: None,
app_user: "extenddb".to_string(),
app_password: "testpass".to_string(),
catalog_db: "extenddb_catalog".to_string(),
data_db: "extenddb".to_string(),
};
let bootstrapper = PostgresBootstrapper::new(config);
let url = bootstrapper.catalog_connection_url();

assert_eq!(
url,
"postgresql://extenddb:testpass@%2Fvar%2Frun%2Fpostgresql:5432/extenddb_catalog"
);
}

#[test]
fn test_connection_url_password_with_special_chars() {
let config = BootstrapConfig {
host: "localhost".to_string(),
port: 5432,
admin_user: "postgres".to_string(),
admin_password: None,
app_user: "extenddb".to_string(),
app_password: "pass@word:with/special".to_string(),
catalog_db: "extenddb_catalog".to_string(),
data_db: "extenddb".to_string(),
};
let bootstrapper = PostgresBootstrapper::new(config);
let url = bootstrapper.catalog_connection_url();

assert_eq!(
url,
"postgresql://extenddb:pass%40word%3Awith%2Fspecial@localhost:5432/extenddb_catalog"
);
}

#[tokio::test]
async fn test_connection_url_round_trip_tcp() {
let config = BootstrapConfig {
host: "localhost".to_string(),
port: 5432,
admin_user: "postgres".to_string(),
admin_password: None,
app_user: "extenddb".to_string(),
app_password: "testpass".to_string(),
catalog_db: "extenddb_catalog".to_string(),
data_db: "extenddb".to_string(),
};
let bootstrapper = PostgresBootstrapper::new(config);
let url = bootstrapper.catalog_connection_url();

// Should parse without error
let opts = url
.parse::<sqlx::postgres::PgConnectOptions>()
.expect("Generated URL should parse");

// Verify parsed values match original config
assert_eq!(opts.get_host(), "localhost");
assert_eq!(opts.get_port(), 5432);
assert_eq!(opts.get_username(), "extenddb");
assert_eq!(opts.get_database().unwrap(), "extenddb_catalog");
}

#[tokio::test]
async fn test_connection_url_round_trip_unix_socket() {
let config = BootstrapConfig {
host: "/var/run/postgresql".to_string(),
port: 5432,
admin_user: "postgres".to_string(),
admin_password: None,
app_user: "extenddb".to_string(),
app_password: "testpass".to_string(),
catalog_db: "extenddb_catalog".to_string(),
data_db: "extenddb".to_string(),
};
let bootstrapper = PostgresBootstrapper::new(config);
let url = bootstrapper.catalog_connection_url();

// Should parse without error - this is the key test
let opts = url
.parse::<sqlx::postgres::PgConnectOptions>()
.expect("Generated URL should parse");

// Note: sqlx may show "localhost" for get_host() even with a Unix socket path,
// but the actual connection uses the percent-encoded socket path correctly.
// The important thing is that the URL parses and the connection will work.
assert_eq!(opts.get_port(), 5432);
assert_eq!(opts.get_username(), "extenddb");
assert_eq!(opts.get_database().unwrap(), "extenddb_catalog");
}
}
3 changes: 3 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ For remote PostgreSQL or Aurora, supply the admin password with `--pg-pass`:
When `--pg-pass` is omitted entirely, `extenddb init` connects without a password, relying on
PostgreSQL peer/ident authentication (works only on localhost via Unix socket).

**Note:** When using Unix socket connections, specify the socket directory with `--pg-host` using an
absolute path (e.g., `--pg-host /var/run/postgresql`). Relative paths are not supported.

### Custom bind address

To bind the server to a specific address (e.g., for remote access), pass `--bind-addr` during init. The address is included as a SAN in the self-signed certificate and written to the generated config file:
Expand Down
20 changes: 20 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,26 @@ pg_ctl -D ~/pgdata status # check if running
pg_ctl -D ~/pgdata -l ~/pgdata/server.log start # start it
```

### `Failed to connect to postgres: Connection error: error with configuration: empty host`

**Cause:** The connection string in `extenddb.toml` has an invalid format, typically from using a Unix socket path with `--pg-host` during `extenddb init` in version tagged 0.1.

**Fix:** Edit `extenddb.toml` and manually percent-encode the Unix socket path in the connection string:
```toml
# Before (invalid):
connection_string = "postgresql://extenddb:***@/var/run/postgresql:5432/extenddb_catalog"

# After (valid):
connection_string = "postgresql://extenddb:***@%2Fvar%2Frun%2Fpostgresql:5432/extenddb_catalog"
```

Also update the catalog database:
```bash
psql extenddb_catalog -c "UPDATE settings SET value='postgresql://extenddb:***@%2Fvar%2Frun%2Fpostgresql:5432/extenddb' WHERE key='data_database_connection_string';"
```

**Prevention:** This issue is fixed in versions after 0.1, which automatically encode Unix socket paths correctly.

### `password authentication failed for user "extenddb"`

**Cause:** The PostgreSQL `extenddb` user doesn't exist or the password doesn't match.
Expand Down
70 changes: 70 additions & 0 deletions tests/test_cli_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,76 @@ def test_stop_when_not_running(self, cli_env):
# (exact behavior depends on implementation)
assert result.returncode != 0 or "not running" in result.stdout.lower()

def test_init_with_unix_socket(self, cli_env):
"""extenddb init with Unix socket path generates valid connection string and daemon starts."""
import platform
import psycopg2

# Only run on Linux or macOS
if platform.system() not in ("Linux", "Darwin"):
pytest.skip(f"Unix socket test only runs on Linux/macOS, not {platform.system()}")

# Try to find PostgreSQL's Unix socket by connecting without host
# (psycopg2 defaults to Unix socket on Linux/macOS)
socket_path = None
try:
# Connect using Unix socket to discover the socket directory
conn = psycopg2.connect(
dbname="postgres",
user=PG_USER,
password=PG_PASS,
)
# Query the socket directory from PostgreSQL
with conn.cursor() as cur:
cur.execute("SHOW unix_socket_directories")
socket_dirs = cur.fetchone()[0]
# Take the first directory if multiple are listed
socket_path = socket_dirs.split(",")[0].strip()
conn.close()

# Verify the socket directory exists and is accessible
if not os.path.isdir(socket_path):
socket_path = None
except Exception as e:
# If Unix socket connection fails, PostgreSQL might not be configured for it
# This is expected and we skip the test rather than fail
pytest.skip(f"PostgreSQL Unix socket not available: {e}")

if not socket_path:
pytest.skip("PostgreSQL Unix socket directory not found")

# Override pg_host with Unix socket path
cli_env["pg_host"] = socket_path

# Init with Unix socket
result = _run_extenddb(
"init", *_init_args(cli_env),
config=cli_env["config_path"],
env_override={"EXTENDDB_ADMIN_PASSWORD": "TestPass1!"},
)
assert result.returncode == 0, f"Init failed: {result.stderr}"

# Verify config contains percent-encoded socket path
with open(cli_env["config_path"]) as f:
config_content = f.read()

# Socket path should be percent-encoded in connection string
encoded_path = socket_path.replace("/", "%2F")
assert encoded_path in config_content, (
f"Connection string should contain percent-encoded socket path {encoded_path}"
)

# Verify daemon can start with the generated config
_patch_config_port(cli_env["config_path"], cli_env["port"])
result = _run_extenddb("serve", config=cli_env["config_path"])
assert result.returncode == 0, f"Serve failed: {result.stderr}"

# Wait for server to be healthy
assert _wait_for_server(cli_env["port"]), "Server did not become healthy with Unix socket connection"

# Stop
_run_extenddb("stop", config=cli_env["config_path"])


class TestCliMultiInstance:
"""Test multi-instance isolation — two extenddb instances on different ports/databases."""
Expand Down
Loading