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:
parent
c113b098e1
commit
f4aa6972e1
10 changed files with 301 additions and 3 deletions
|
|
@ -7,3 +7,7 @@ DOMAIN_SUFFIX=yourdomain.com
|
|||
# Dashboard login credentials (required in production).
|
||||
HIY_ADMIN_USER=admin
|
||||
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
|
||||
|
|
|
|||
|
|
@ -48,15 +48,31 @@ services:
|
|||
# would fail: no user-namespace support in an unprivileged container).
|
||||
CONTAINER_HOST: tcp://podman-proxy:2375
|
||||
RUST_LOG: hiy_server=debug,tower_http=info
|
||||
POSTGRES_URL: postgres://hiy_admin:${POSTGRES_PASSWORD}@postgres:5432/hiy
|
||||
depends_on:
|
||||
caddy:
|
||||
condition: service_started
|
||||
podman-proxy:
|
||||
condition: service_started
|
||||
postgres:
|
||||
condition: service_started
|
||||
networks:
|
||||
- hiy-net
|
||||
- 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 ─────────────────────────────────────────────────────────
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
|
|
@ -88,3 +104,4 @@ volumes:
|
|||
hiy-data:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
hiy-pg-data:
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ path = "src/main.rs"
|
|||
[dependencies]
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
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_json = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
|
|
|||
|
|
@ -101,5 +101,16 @@ pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> {
|
|||
.execute(pool)
|
||||
.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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ pub use db::DbPool;
|
|||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
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.
|
||||
pub build_queue: Arc<Mutex<VecDeque<String>>>,
|
||||
pub data_dir: String,
|
||||
|
|
@ -119,8 +121,20 @@ async fn main() -> anyhow::Result<()> {
|
|||
let git_shell_path = std::env::var("HIY_GIT_SHELL")
|
||||
.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 {
|
||||
db,
|
||||
pg,
|
||||
build_queue,
|
||||
data_dir,
|
||||
sessions: auth::new_sessions(),
|
||||
|
|
@ -160,6 +174,10 @@ async fn main() -> anyhow::Result<()> {
|
|||
// Env vars API
|
||||
.route("/api/apps/:id/env", get(routes::envvars::list).post(routes::envvars::set))
|
||||
.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)
|
||||
.route("/api/users", get(routes::users::list).post(routes::users::create))
|
||||
.route("/api/users/:id", put(routes::users::update).delete(routes::users::delete))
|
||||
|
|
|
|||
|
|
@ -103,3 +103,11 @@ pub struct ApiKey {
|
|||
pub struct CreateApiKey {
|
||||
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,
|
||||
}
|
||||
|
|
|
|||
190
server/src/routes/databases.rs
Normal file
190
server/src/routes/databases.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod api_keys;
|
||||
pub mod apps;
|
||||
pub mod databases;
|
||||
pub mod deploys;
|
||||
pub mod envvars;
|
||||
pub mod git;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use futures::future::join_all;
|
|||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
models::{App, Deploy, EnvVar},
|
||||
models::{App, Database, Deploy, EnvVar},
|
||||
AppState,
|
||||
};
|
||||
|
||||
|
|
@ -261,6 +261,41 @@ pub async fn app_detail(
|
|||
|
||||
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
|
||||
.replace("{{name}}", &app.name)
|
||||
.replace("{{repo}}", &app.repo_url)
|
||||
|
|
@ -271,7 +306,8 @@ pub async fn app_detail(
|
|||
.replace("{{secret}}", &app.webhook_secret)
|
||||
.replace("{{deploy_rows}}", &deploy_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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{{db_card}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Environment Variables</h2>
|
||||
<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'});
|
||||
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() {
|
||||
if (!confirm('Stop ' + APP_ID + '?')) return;
|
||||
const r = await fetch('/api/apps/' + APP_ID + '/stop', {method:'POST'});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue