diff --git a/infra/.env.example b/infra/.env.example index d09e8d9..2001127 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -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 diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 32e1ad3..2773344 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -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: diff --git a/server/Cargo.toml b/server/Cargo.toml index f69c46d..dfe1497 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -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"] } diff --git a/server/src/db.rs b/server/src/db.rs index cd99c36..a2eebe1 100644 --- a/server/src/db.rs +++ b/server/src/db.rs @@ -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(()) } diff --git a/server/src/main.rs b/server/src/main.rs index f88a6e2..4235a8e 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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, /// Queue of deploy IDs waiting to be processed. pub build_queue: Arc>>, 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)) diff --git a/server/src/models.rs b/server/src/models.rs index d77d42d..a2444a6 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -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, +} diff --git a/server/src/routes/databases.rs b/server/src/routes/databases.rs new file mode 100644 index 0000000..37db709 --- /dev/null +++ b/server/src/routes/databases.rs @@ -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) -> 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, + Path(app_id): Path, +) -> Result, 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, + Path(app_id): Path, +) -> Result<(StatusCode, Json), 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, + Path(app_id): Path, +) -> Result { + 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) +} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index e4978f3..aa4b997 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -1,5 +1,6 @@ pub mod api_keys; pub mod apps; +pub mod databases; pub mod deploys; pub mod envvars; pub mod git; diff --git a/server/src/routes/ui.rs b/server/src/routes/ui.rs index 6e5cdb7..875e13a 100644 --- a/server/src/routes/ui.rs +++ b/server/src/routes/ui.rs @@ -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#"

Database

+

Postgres is not configured on this server.

"# + .to_string(), + (true, None) => r#"

Database

+

No database provisioned.

+
"# + .to_string(), + (true, Some(db)) => { + let url = format!("postgres://{}:{}@postgres:5432/hiy", db.pg_user, db.pg_password); + format!(r#"

Database

+ + + + +
Schema{schema}
User{user}
Connection string{url}
+

+ DATABASE_URL is set as an env var and will be available on the next deploy. +

+
"#, + 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() } diff --git a/server/templates/app_detail.html b/server/templates/app_detail.html index bd8473b..f071f89 100644 --- a/server/templates/app_detail.html +++ b/server/templates/app_detail.html @@ -33,6 +33,8 @@ +{{db_card}} +

Environment Variables

@@ -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'});