260 lines
14 KiB
Rust
260 lines
14 KiB
Rust
//! Demo-only retention and a conservative database provenance boundary before migrations.
|
||
use crate::{
|
||
error::{Error, Result, ensure},
|
||
store::Store,
|
||
};
|
||
use chrono::{DateTime, Utc};
|
||
use cot::db::{Database, DatabaseError};
|
||
use serde::Serialize;
|
||
use std::sync::OnceLock;
|
||
use uuid::Uuid;
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct Retention {
|
||
pub workspaces: i64,
|
||
pub accounts: i64,
|
||
pub requests: i64,
|
||
pub events: i64,
|
||
pub purge_grace: i64,
|
||
}
|
||
impl Default for Retention {
|
||
fn default() -> Self {
|
||
Self {
|
||
workspaces: 86400,
|
||
accounts: 172800,
|
||
requests: 7200,
|
||
events: 21600,
|
||
purge_grace: 600,
|
||
}
|
||
}
|
||
}
|
||
impl Retention {
|
||
pub fn from_env() -> Result<Self> {
|
||
fn read(name: &str, default: i64) -> Result<i64> {
|
||
let value = std::env::var(name).map_or(Ok(default), |v| {
|
||
v.parse().map_err(|_| {
|
||
Error::Validation(format!("{name} must be an integer number of seconds"))
|
||
})
|
||
})?;
|
||
ensure(
|
||
(60..=2592000).contains(&value),
|
||
format!("{name} must be 60–2592000 seconds"),
|
||
)?;
|
||
Ok(value)
|
||
}
|
||
let d = Self::default();
|
||
Ok(Self {
|
||
workspaces: read("MEGURI_DEMO_WORKSPACE_TTL_SECONDS", d.workspaces)?,
|
||
accounts: read("MEGURI_DEMO_ACCOUNT_TTL_SECONDS", d.accounts)?,
|
||
requests: read("MEGURI_DEMO_REQUEST_TTL_SECONDS", d.requests)?,
|
||
events: read("MEGURI_DEMO_EVENT_TTL_SECONDS", d.events)?,
|
||
purge_grace: read("MEGURI_DEMO_PURGE_GRACE_SECONDS", d.purge_grace)?,
|
||
})
|
||
}
|
||
}
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct Notice {
|
||
pub isolated: bool,
|
||
pub retention: Retention,
|
||
}
|
||
static NOTICE: OnceLock<Notice> = OnceLock::new();
|
||
pub fn notice() -> Option<&'static Notice> {
|
||
NOTICE.get()
|
||
}
|
||
pub fn active() -> bool {
|
||
NOTICE.get().is_some()
|
||
}
|
||
pub fn install(notice: Notice) {
|
||
let _ = NOTICE.set(notice);
|
||
}
|
||
|
||
fn identifier(name: &str) -> String {
|
||
format!("\"{}\"", name.replace('"', "\"\""))
|
||
}
|
||
fn code(error: &DatabaseError) -> Option<String> {
|
||
if let DatabaseError::DatabaseEngineError(e) = error {
|
||
e.as_database_error()
|
||
.and_then(|e| e.code().map(|c| c.into_owned()))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
pub fn application_url(url: &str, demo: bool) -> Result<String> {
|
||
let mut parsed =
|
||
reqwest::Url::parse(url).map_err(|_| Error::Validation("Invalid PostgreSQL URL".into()))?;
|
||
ensure(parsed.scheme() == "postgresql", "Use a postgresql:// URL")?;
|
||
let pairs: Vec<_> = parsed
|
||
.query_pairs()
|
||
.filter(|(k, _)| k != "application_name")
|
||
.map(|(k, v)| (k.into_owned(), v.into_owned()))
|
||
.collect();
|
||
parsed.set_query(None);
|
||
parsed.query_pairs_mut().extend_pairs(pairs).append_pair(
|
||
"application_name",
|
||
&format!(
|
||
"meguri-{}-{}",
|
||
if demo { "demo" } else { "normal" },
|
||
Uuid::new_v4().simple()
|
||
),
|
||
);
|
||
Ok(parsed.to_string())
|
||
}
|
||
const CREATE_GUARD: &str = "CREATE TABLE IF NOT EXISTS public.meguri_runtime_guard(id boolean PRIMARY KEY CHECK(id),signature text NOT NULL,last_mode text NOT NULL CHECK(last_mode IN('normal','demo')),started_at timestamptz NOT NULL)";
|
||
const PROOF: &str = "DO $$ BEGIN
|
||
IF to_regclass('public.meguri_runtime_guard') IS NULL THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF;
|
||
IF NOT EXISTS(SELECT 1 FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo') THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF;
|
||
END $$;";
|
||
/// A failed proof never authorizes a write. No migration or marker update happens here.
|
||
pub async fn is_demo_database(db: &Database) -> bool {
|
||
db.raw(PROOF).await.is_ok()
|
||
}
|
||
|
||
/// Claim only a database with no user relations. A pre-existing application DB is never claimed.
|
||
pub async fn claim_empty(db: &Database) -> Result<()> {
|
||
db.raw(&format!("DO $$ BEGIN
|
||
PERFORM pg_advisory_xact_lock(726482039);
|
||
IF EXISTS(SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname NOT LIKE 'pg_%' AND n.nspname<>'information_schema' AND c.relkind IN('r','p','v','m','f','S')) THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF;
|
||
{CREATE_GUARD};
|
||
INSERT INTO public.meguri_runtime_guard VALUES(true,'meguri.runtime.v1','demo',clock_timestamp());
|
||
END $$;")).await?;
|
||
Ok(())
|
||
}
|
||
pub async fn mark_normal(db: &Database) -> Result<()> {
|
||
db.raw(&format!("DO $$ BEGIN PERFORM pg_advisory_xact_lock(726482039); {CREATE_GUARD};
|
||
INSERT INTO public.meguri_runtime_guard VALUES(true,'meguri.runtime.v1','normal',clock_timestamp()) ON CONFLICT(id) DO UPDATE SET last_mode='normal',started_at=clock_timestamp(); END $$;")).await?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Uses the configured DB only with positive demo provenance. Otherwise creates/reuses a
|
||
/// separately named DB on the same server. Protected candidates and migration tables survive.
|
||
pub async fn select_database(configured: &str, alternate: Option<&str>) -> Result<(String, bool)> {
|
||
let configured = application_url(configured, true)?;
|
||
let source = Database::new(&configured).await?;
|
||
if is_demo_database(&source).await {
|
||
return Ok((configured, false));
|
||
}
|
||
let base = reqwest::Url::parse(alternate.unwrap_or(&configured))
|
||
.map_err(|_| Error::Validation("Invalid MEGURI_DEMO_DATABASE_URL".into()))?;
|
||
let fingerprint = crate::generation::stable_id(&serde_json::json!([
|
||
base.host_str(),
|
||
base.port(),
|
||
base.path()
|
||
]));
|
||
for attempt in 0..8 {
|
||
let mut target = base.clone();
|
||
let name = if alternate.is_some() && attempt == 0 {
|
||
base.path().trim_start_matches('/').to_owned()
|
||
} else {
|
||
format!(
|
||
"meguri_demo_{}_{}",
|
||
&fingerprint.simple().to_string()[..12],
|
||
attempt
|
||
)
|
||
};
|
||
ensure(!name.is_empty(), "A demo database name is required")?;
|
||
target.set_path(&format!("/{name}"));
|
||
let target = application_url(target.as_str(), true)?;
|
||
let database = match Database::new(&target).await {
|
||
Ok(db) => db,
|
||
Err(e) if code(&e).as_deref() == Some("3D000") => {
|
||
// Database creation is the only cluster-level write; no DROP DATABASE/schema/CASCADE.
|
||
let mut admin = base.clone();
|
||
admin.set_path("/postgres");
|
||
let admin = Database::new(application_url(admin.as_str(), true)?).await?;
|
||
if let Err(e) = admin
|
||
.raw(&format!("CREATE DATABASE {}", identifier(&name)))
|
||
.await
|
||
&& code(&e).as_deref() != Some("42P04")
|
||
{
|
||
return Err(Error::Validation("Cannot create an isolated demo database. Ask the administrator to provision an empty database and set MEGURI_DEMO_DATABASE_URL. The configured database was not changed.".into()));
|
||
}
|
||
Database::new(&target).await?
|
||
}
|
||
Err(e) => return Err(e.into()),
|
||
};
|
||
if is_demo_database(&database).await {
|
||
return Ok((target, true));
|
||
}
|
||
if claim_empty(&database).await.is_ok() {
|
||
return Ok((target, true));
|
||
}
|
||
}
|
||
Err(Error::Validation("No safe demo database is available. Protected databases were not modified; configure a separate empty MEGURI_DEMO_DATABASE_URL.".into()))
|
||
}
|
||
|
||
/// Truncate only the enumerated application state, after rechecking provenance under lock.
|
||
/// No CASCADE: an unexpected external dependency aborts the entire reset.
|
||
pub async fn reset(db: &Database) -> Result<()> {
|
||
db.raw("DO $$ DECLARE tables text; BEGIN
|
||
PERFORM pg_advisory_xact_lock(726482039);
|
||
IF to_regclass('public.meguri_runtime_guard') IS NULL THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF;
|
||
LOCK TABLE public.meguri_runtime_guard IN EXCLUSIVE MODE;
|
||
IF NOT EXISTS(SELECT 1 FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo') THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF;
|
||
IF EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND backend_type='client backend' AND pid<>pg_backend_pid() AND application_name<>current_setting('application_name')) THEN RAISE EXCEPTION 'Demo database has another active client; stop the other process before restarting'; END IF;
|
||
SELECT string_agg(format('public.%I',name),',') INTO tables FROM unnest(ARRAY['meguri__workspaces','meguri__workspace_members','meguri__workspace_history','meguri__sessions','meguri__external_identities','meguri__team_members','meguri__tenant_members','meguri__team_history','meguri__audit_events','meguri__sync_jobs','meguri__provider_objects','meguri__teams','meguri__tenants','meguri__users','meguri__demo_pins']) name WHERE to_regclass('public.'||name) IS NOT NULL;
|
||
IF tables IS NOT NULL THEN EXECUTE 'TRUNCATE '||tables||' RESTART IDENTITY'; END IF;
|
||
UPDATE public.meguri_runtime_guard SET started_at=clock_timestamp() WHERE id;
|
||
END $$;").await?;
|
||
Ok(())
|
||
}
|
||
|
||
pub async fn cleanup(db: &Store, now: DateTime<Utc>, ttl: &Retention) -> Result<()> {
|
||
// The server checks provenance on every sweep too. A changed mode stops cleanup.
|
||
ensure(
|
||
is_demo_database(&db.db).await,
|
||
"Cleanup refused: database is not marked as demo",
|
||
)?;
|
||
db.db
|
||
.raw_with(
|
||
CLEANUP,
|
||
&[&now, &ttl.workspaces, &ttl.accounts, &ttl.requests],
|
||
)
|
||
.await?;
|
||
db.db
|
||
.raw_with(PURGE, &[&now, &ttl.purge_grace, &ttl.events])
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
pub async fn run(db: Store, ttl: Retention) {
|
||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
|
||
loop {
|
||
interval.tick().await;
|
||
if let Err(error) = async {
|
||
cleanup(&db, Utc::now(), &ttl).await?;
|
||
crate::demo::seed(&db).await
|
||
}
|
||
.await
|
||
{
|
||
tracing::error!(%error,"demo maintenance failed");
|
||
}
|
||
}
|
||
}
|
||
const CLEANUP: &str = r#"
|
||
WITH guard AS MATERIALIZED (SELECT id FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo' FOR UPDATE),
|
||
expired AS (UPDATE meguri__workspaces SET demo_deleted_at=$1,revision=revision+1 WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at IS NULL AND created_at<=$1-make_interval(secs=>$2::bigint::double precision) RETURNING id),
|
||
expired_users AS (UPDATE meguri__users SET demo_deleted_at=$1 WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at IS NULL AND created_at<=$1-make_interval(secs=>$3::bigint::double precision) AND NOT EXISTS(SELECT 1 FROM meguri__demo_pins p WHERE p.kind='user' AND p.id=meguri__users.id) RETURNING id),
|
||
sessions AS (DELETE FROM meguri__sessions WHERE EXISTS(SELECT 1 FROM guard) AND (user_id IN(SELECT id FROM expired_users) OR expires_at<=$1)),
|
||
jobs AS (DELETE FROM meguri__sync_jobs WHERE workspace_id IN(SELECT id FROM expired)),
|
||
requests AS (
|
||
UPDATE meguri__workspaces SET document=jsonb_set(document::jsonb,'{requests}',COALESCE((SELECT jsonb_agg(r) FROM jsonb_array_elements(document::jsonb->'requests') r WHERE (r->>'created_at')::timestamptz>$1-make_interval(secs=>$4::bigint::double precision)),'[]'::jsonb))::text,revision=revision+1
|
||
WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at IS NULL AND id NOT IN(SELECT id FROM expired) AND EXISTS(SELECT 1 FROM jsonb_array_elements(document::jsonb->'requests') r WHERE (r->>'created_at')::timestamptz<=$1-make_interval(secs=>$4::bigint::double precision)) RETURNING *
|
||
), history AS (INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at) SELECT id||':'||revision::text,id,revision,document,$1 FROM requests),
|
||
request_jobs AS (INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,revision,'pending',0,$1 FROM requests ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision,state='pending',next_attempt=$1)
|
||
INSERT INTO meguri__audit_events(workspace_id,action,details,created_at) SELECT id,'demo.requests_expired','{}',$1 FROM requests;
|
||
"#;
|
||
const PURGE: &str = r#"
|
||
WITH guard AS MATERIALIZED (SELECT id FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo' FOR UPDATE),
|
||
workspaces AS MATERIALIZED (SELECT id FROM meguri__workspaces WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at<=$1-make_interval(secs=>$2::bigint::double precision)),
|
||
members AS (DELETE FROM meguri__workspace_members WHERE workspace_id IN(SELECT id FROM workspaces)),
|
||
history AS (DELETE FROM meguri__workspace_history h WHERE workspace_id IN(SELECT id FROM workspaces) OR (EXISTS(SELECT 1 FROM guard) AND effective_at<$1-make_interval(secs=>$3::bigint::double precision) AND EXISTS(SELECT 1 FROM meguri__workspace_history newer WHERE newer.workspace_id=h.workspace_id AND newer.revision>h.revision AND newer.effective_at<$1-make_interval(secs=>$3::bigint::double precision)))),
|
||
audit AS (DELETE FROM meguri__audit_events WHERE workspace_id IN(SELECT id FROM workspaces) OR (EXISTS(SELECT 1 FROM guard) AND created_at<$1-make_interval(secs=>$3::bigint::double precision))),
|
||
jobs AS (DELETE FROM meguri__sync_jobs WHERE workspace_id IN(SELECT id FROM workspaces)),
|
||
objects AS (DELETE FROM meguri__provider_objects WHERE workspace_id IN(SELECT id FROM workspaces)),
|
||
pins AS (DELETE FROM meguri__demo_pins WHERE kind='workspace' AND id IN(SELECT id FROM workspaces)),
|
||
removed AS (DELETE FROM meguri__workspaces WHERE id IN(SELECT id FROM workspaces)),
|
||
users AS MATERIALIZED (SELECT id FROM meguri__users u WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at<=$1-make_interval(secs=>$2::bigint::double precision) AND NOT EXISTS(SELECT 1 FROM meguri__workspace_members m WHERE m.user_id=u.id AND m.workspace_id NOT IN(SELECT id FROM workspaces))),
|
||
sessions AS (DELETE FROM meguri__sessions WHERE user_id IN(SELECT id FROM users)),
|
||
identities AS (DELETE FROM meguri__external_identities WHERE user_id IN(SELECT id FROM users))
|
||
DELETE FROM meguri__users WHERE id IN(SELECT id FROM users);
|
||
"#;
|