feat: shared Postgres with per-app schemas

One Postgres 16 instance runs in the infra stack (docker-compose).
Each app can be given its own isolated schema with a dedicated,
scoped Postgres user via the new Database card on the app detail page.

What was added:

infra/
  docker-compose.yml  — postgres:16-alpine service + hiy-pg-data
                        volume; POSTGRES_URL injected into server
  .env.example        — POSTGRES_PASSWORD entry

server/
  Cargo.toml          — sqlx postgres feature
  src/db.rs           — databases table (SQLite) migration
  src/models.rs       — Database model
  src/main.rs         — PgPool (lazy) added to AppState;
                        /api/apps/:id/database routes registered
  src/routes/mod.rs   — databases module
  src/routes/databases.rs — GET / POST / DELETE handlers:
      provision  — creates schema + scoped PG user, sets search_path,
                   injects DATABASE_URL env var
      deprovision — DROP OWNED BY + DROP ROLE + DROP SCHEMA CASCADE,
                   removes SQLite record
  src/routes/ui.rs    — app_detail queries databases table, renders
                        db_card based on provisioning state
  templates/app_detail.html — {{db_card}} placeholder +
                              provisionDb / deprovisionDb JS

Apps connect via:
  postgres://hiy-<app>:<pw>@postgres:5432/hiy
search_path is set on the role so no URL option is needed.

https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
This commit is contained in:
Claude 2026-03-24 13:16:39 +00:00
parent c113b098e1
commit f4aa6972e1
No known key found for this signature in database
10 changed files with 301 additions and 3 deletions

View file

@ -7,3 +7,7 @@ DOMAIN_SUFFIX=yourdomain.com
# Dashboard login credentials (required in production). # Dashboard login credentials (required in production).
HIY_ADMIN_USER=admin HIY_ADMIN_USER=admin
HIY_ADMIN_PASS=changeme HIY_ADMIN_PASS=changeme
# Postgres admin password — used by the shared cluster.
# App schemas get their own scoped users; this password never leaves the server.
POSTGRES_PASSWORD=changeme

View file

@ -48,15 +48,31 @@ services:
# would fail: no user-namespace support in an unprivileged container). # would fail: no user-namespace support in an unprivileged container).
CONTAINER_HOST: tcp://podman-proxy:2375 CONTAINER_HOST: tcp://podman-proxy:2375
RUST_LOG: hiy_server=debug,tower_http=info RUST_LOG: hiy_server=debug,tower_http=info
POSTGRES_URL: postgres://hiy_admin:${POSTGRES_PASSWORD}@postgres:5432/hiy
depends_on: depends_on:
caddy: caddy:
condition: service_started condition: service_started
podman-proxy: podman-proxy:
condition: service_started condition: service_started
postgres:
condition: service_started
networks: networks:
- hiy-net - hiy-net
- default - default
# ── Shared Postgres ───────────────────────────────────────────────────────
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: hiy
POSTGRES_USER: hiy_admin
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- hiy-pg-data:/var/lib/postgresql/data
networks:
- hiy-net
# ── Reverse proxy ───────────────────────────────────────────────────────── # ── Reverse proxy ─────────────────────────────────────────────────────────
caddy: caddy:
image: caddy:2-alpine image: caddy:2-alpine
@ -88,3 +104,4 @@ volumes:
hiy-data: hiy-data:
caddy-data: caddy-data:
caddy-config: caddy-config:
hiy-pg-data:

View file

@ -10,7 +10,7 @@ path = "src/main.rs"
[dependencies] [dependencies]
axum = { version = "0.7", features = ["macros"] } axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls", "migrate", "chrono"] } sqlx = { version = "0.7", features = ["sqlite", "postgres", "runtime-tokio-rustls", "migrate", "chrono"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }

View file

@ -101,5 +101,16 @@ pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> {
.execute(pool) .execute(pool)
.await?; .await?;
sqlx::query(
r#"CREATE TABLE IF NOT EXISTS databases (
app_id TEXT PRIMARY KEY REFERENCES apps(id) ON DELETE CASCADE,
pg_user TEXT NOT NULL,
pg_password TEXT NOT NULL,
created_at TEXT NOT NULL
)"#,
)
.execute(pool)
.await?;
Ok(()) Ok(())
} }

View file

@ -19,6 +19,8 @@ pub use db::DbPool;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub db: DbPool, pub db: DbPool,
/// Admin Postgres pool for schema/user provisioning. None if POSTGRES_URL is unset.
pub pg: Option<sqlx::PgPool>,
/// Queue of deploy IDs waiting to be processed. /// Queue of deploy IDs waiting to be processed.
pub build_queue: Arc<Mutex<VecDeque<String>>>, pub build_queue: Arc<Mutex<VecDeque<String>>>,
pub data_dir: String, pub data_dir: String,
@ -119,8 +121,20 @@ async fn main() -> anyhow::Result<()> {
let git_shell_path = std::env::var("HIY_GIT_SHELL") let git_shell_path = std::env::var("HIY_GIT_SHELL")
.unwrap_or_else(|_| "/usr/local/bin/hiy-git-shell".into()); .unwrap_or_else(|_| "/usr/local/bin/hiy-git-shell".into());
let pg = match std::env::var("POSTGRES_URL") {
Ok(url) => match sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect_lazy(&url)
{
Ok(pool) => { tracing::info!("Postgres pool initialised (lazy)"); Some(pool) }
Err(e) => { tracing::warn!("Could not create Postgres pool: {}", e); None }
},
Err(_) => { tracing::info!("POSTGRES_URL not set — database provisioning disabled"); None }
};
let state = AppState { let state = AppState {
db, db,
pg,
build_queue, build_queue,
data_dir, data_dir,
sessions: auth::new_sessions(), sessions: auth::new_sessions(),
@ -160,6 +174,10 @@ async fn main() -> anyhow::Result<()> {
// Env vars API // Env vars API
.route("/api/apps/:id/env", get(routes::envvars::list).post(routes::envvars::set)) .route("/api/apps/:id/env", get(routes::envvars::list).post(routes::envvars::set))
.route("/api/apps/:id/env/:key", delete(routes::envvars::remove)) .route("/api/apps/:id/env/:key", delete(routes::envvars::remove))
// Database provisioning API
.route("/api/apps/:id/database", get(routes::databases::get_db)
.post(routes::databases::provision)
.delete(routes::databases::deprovision))
// Users API (admin only — already behind admin middleware) // Users API (admin only — already behind admin middleware)
.route("/api/users", get(routes::users::list).post(routes::users::create)) .route("/api/users", get(routes::users::list).post(routes::users::create))
.route("/api/users/:id", put(routes::users::update).delete(routes::users::delete)) .route("/api/users/:id", put(routes::users::update).delete(routes::users::delete))

View file

@ -103,3 +103,11 @@ pub struct ApiKey {
pub struct CreateApiKey { pub struct CreateApiKey {
pub label: String, pub label: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Database {
pub app_id: String,
pub pg_user: String,
pub pg_password: String,
pub created_at: String,
}

View file

@ -0,0 +1,190 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde_json::json;
use crate::{models::Database, AppState};
type ApiError = (StatusCode, String);
fn err(status: StatusCode, msg: impl Into<String>) -> ApiError {
(status, msg.into())
}
/// App IDs are slugs: lowercase alphanumeric + hyphens.
/// Validate before interpolating into SQL identifiers.
fn validate_app_id(id: &str) -> bool {
!id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}
fn conn_str(pg_user: &str, pg_password: &str) -> String {
format!("postgres://{}:{}@postgres:5432/hiy", pg_user, pg_password)
}
// ── GET /api/apps/:id/database ────────────────────────────────────────────────
pub async fn get_db(
State(s): State<AppState>,
Path(app_id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> {
let db = sqlx::query_as::<_, Database>(
"SELECT * FROM databases WHERE app_id = ?",
)
.bind(&app_id)
.fetch_optional(&s.db)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
match db {
None => Err(err(StatusCode::NOT_FOUND, "No database provisioned")),
Some(d) => Ok(Json(json!({
"app_id": d.app_id,
"schema": d.app_id,
"pg_user": d.pg_user,
"conn_str": conn_str(&d.pg_user, &d.pg_password),
"created_at": d.created_at,
}))),
}
}
// ── POST /api/apps/:id/database ───────────────────────────────────────────────
pub async fn provision(
State(s): State<AppState>,
Path(app_id): Path<String>,
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
let pg = s.pg.as_ref()
.ok_or_else(|| err(StatusCode::SERVICE_UNAVAILABLE, "Postgres not configured on this server"))?;
if !validate_app_id(&app_id) {
return Err(err(StatusCode::BAD_REQUEST, "Invalid app id"));
}
// Verify app exists.
sqlx::query_scalar::<_, String>("SELECT id FROM apps WHERE id = ?")
.bind(&app_id)
.fetch_optional(&s.db)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| err(StatusCode::NOT_FOUND, "App not found"))?;
// Idempotency guard.
let already: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM databases WHERE app_id = ?")
.bind(&app_id)
.fetch_one(&s.db)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if already > 0 {
return Err(err(StatusCode::CONFLICT, "Database already provisioned for this app"));
}
// Random password: UUID v4 without hyphens → 32 hex chars, URL-safe.
let password = uuid::Uuid::new_v4().to_string().replace('-', "");
let pg_user = format!("hiy-{}", app_id);
let schema = app_id.as_str();
// All identifiers are double-quoted; passwords contain only [0-9a-f].
sqlx::query(&format!(r#"CREATE SCHEMA IF NOT EXISTS "{}""#, schema))
.execute(pg).await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
sqlx::query(&format!(r#"CREATE USER "{}" WITH PASSWORD '{}'"#, pg_user, password))
.execute(pg).await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
sqlx::query(&format!(
r#"GRANT USAGE, CREATE ON SCHEMA "{}" TO "{}""#, schema, pg_user,
))
.execute(pg).await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Default search_path so the app never has to specify it in the URL.
sqlx::query(&format!(
r#"ALTER ROLE "{}" SET search_path TO "{}""#, pg_user, schema,
))
.execute(pg).await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Persist credentials.
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO databases (app_id, pg_user, pg_password, created_at) VALUES (?, ?, ?, ?)",
)
.bind(&app_id)
.bind(&pg_user)
.bind(&password)
.bind(&now)
.execute(&s.db)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Inject DATABASE_URL as an app env var (picked up on next deploy).
let url = conn_str(&pg_user, &password);
sqlx::query(
"INSERT INTO env_vars (app_id, key, value) VALUES (?, 'DATABASE_URL', ?)
ON CONFLICT (app_id, key) DO UPDATE SET value = excluded.value",
)
.bind(&app_id)
.bind(&url)
.execute(&s.db)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
tracing::info!("Provisioned database schema \"{}\" for app {}", schema, app_id);
Ok((StatusCode::CREATED, Json(json!({
"app_id": app_id,
"schema": schema,
"pg_user": pg_user,
"conn_str": url,
}))))
}
// ── DELETE /api/apps/:id/database ─────────────────────────────────────────────
pub async fn deprovision(
State(s): State<AppState>,
Path(app_id): Path<String>,
) -> Result<StatusCode, ApiError> {
let pg = s.pg.as_ref()
.ok_or_else(|| err(StatusCode::SERVICE_UNAVAILABLE, "Postgres not configured on this server"))?;
if !validate_app_id(&app_id) {
return Err(err(StatusCode::BAD_REQUEST, "Invalid app id"));
}
let db = sqlx::query_as::<_, Database>(
"SELECT * FROM databases WHERE app_id = ?",
)
.bind(&app_id)
.fetch_optional(&s.db)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| err(StatusCode::NOT_FOUND, "No database provisioned for this app"))?;
// Drop all objects owned by the app user, then the role, then the schema.
sqlx::query(&format!(r#"DROP OWNED BY "{}""#, db.pg_user))
.execute(pg).await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
sqlx::query(&format!(r#"DROP ROLE IF EXISTS "{}""#, db.pg_user))
.execute(pg).await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
sqlx::query(&format!(r#"DROP SCHEMA IF EXISTS "{}" CASCADE"#, app_id))
.execute(pg).await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Remove from SQLite (also removes DATABASE_URL env var if desired — left to the user).
sqlx::query("DELETE FROM databases WHERE app_id = ?")
.bind(&app_id)
.execute(&s.db)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
tracing::info!("Deprovisioned database for app {}", app_id);
Ok(StatusCode::NO_CONTENT)
}

View file

@ -1,5 +1,6 @@
pub mod api_keys; pub mod api_keys;
pub mod apps; pub mod apps;
pub mod databases;
pub mod deploys; pub mod deploys;
pub mod envvars; pub mod envvars;
pub mod git; pub mod git;

View file

@ -8,7 +8,7 @@ use futures::future::join_all;
use std::collections::HashMap; use std::collections::HashMap;
use crate::{ use crate::{
models::{App, Deploy, EnvVar}, models::{App, Database, Deploy, EnvVar},
AppState, AppState,
}; };
@ -261,6 +261,41 @@ pub async fn app_detail(
let host = std::env::var("DOMAIN_SUFFIX").unwrap_or_else(|_| "localhost".into()); let host = std::env::var("DOMAIN_SUFFIX").unwrap_or_else(|_| "localhost".into());
let db_record = sqlx::query_as::<_, Database>(
"SELECT * FROM databases WHERE app_id = ?",
)
.bind(&app_id)
.fetch_optional(&s.db)
.await
.unwrap_or(None);
let db_card = match (s.pg.is_some(), db_record) {
(false, _) => r#"<div class="card"><h2>Database</h2>
<p class="muted">Postgres is not configured on this server.</p></div>"#
.to_string(),
(true, None) => r#"<div class="card"><h2>Database</h2>
<p class="muted" style="margin-bottom:16px">No database provisioned.</p>
<button class="primary" onclick="provisionDb()">Provision Database</button></div>"#
.to_string(),
(true, Some(db)) => {
let url = format!("postgres://{}:{}@postgres:5432/hiy", db.pg_user, db.pg_password);
format!(r#"<div class="card"><h2>Database</h2>
<table style="margin-bottom:16px">
<tr><td style="width:160px">Schema</td><td><code>{schema}</code></td></tr>
<tr><td>User</td><td><code>{user}</code></td></tr>
<tr><td>Connection string</td><td><code style="word-break:break-all">{url}</code></td></tr>
</table>
<p class="muted" style="margin-bottom:12px;font-size:0.82rem">
DATABASE_URL is set as an env var and will be available on the next deploy.
</p>
<button class="danger" onclick="deprovisionDb()">Deprovision</button></div>"#,
schema = app_id,
user = db.pg_user,
url = url,
)
}
};
let body = APP_DETAIL_TMPL let body = APP_DETAIL_TMPL
.replace("{{name}}", &app.name) .replace("{{name}}", &app.name)
.replace("{{repo}}", &app.repo_url) .replace("{{repo}}", &app.repo_url)
@ -271,7 +306,8 @@ pub async fn app_detail(
.replace("{{secret}}", &app.webhook_secret) .replace("{{secret}}", &app.webhook_secret)
.replace("{{deploy_rows}}", &deploy_rows) .replace("{{deploy_rows}}", &deploy_rows)
.replace("{{env_rows}}", &env_rows) .replace("{{env_rows}}", &env_rows)
.replace("{{c_badge}}", &container_badge(&container_state)); .replace("{{c_badge}}", &container_badge(&container_state))
.replace("{{db_card}}", &db_card);
Html(page(&app.name, &body)).into_response() Html(page(&app.name, &body)).into_response()
} }

View file

@ -33,6 +33,8 @@
</div> </div>
</div> </div>
{{db_card}}
<div class="card"> <div class="card">
<h2>Environment Variables</h2> <h2>Environment Variables</h2>
<div class="row" style="margin-bottom:16px"> <div class="row" style="margin-bottom:16px">
@ -132,6 +134,17 @@ async function removeEnv(key) {
await fetch('/api/apps/' + APP_ID + '/env/' + key, {method:'DELETE'}); await fetch('/api/apps/' + APP_ID + '/env/' + key, {method:'DELETE'});
window.location.reload(); window.location.reload();
} }
async function provisionDb() {
const r = await fetch('/api/apps/' + APP_ID + '/database', {method: 'POST'});
if (r.ok) window.location.reload();
else alert('Error: ' + await r.text());
}
async function deprovisionDb() {
if (!confirm('Drop the database and all its data for ' + APP_ID + '?\nThis cannot be undone.')) return;
const r = await fetch('/api/apps/' + APP_ID + '/database', {method: 'DELETE'});
if (r.ok) window.location.reload();
else alert('Error: ' + await r.text());
}
async function stopApp() { async function stopApp() {
if (!confirm('Stop ' + APP_ID + '?')) return; if (!confirm('Stop ' + APP_ID + '?')) return;
const r = await fetch('/api/apps/' + APP_ID + '/stop', {method:'POST'}); const r = await fetch('/api/apps/' + APP_ID + '/stop', {method:'POST'});