Files
meguri/src/demo_runtime.rs
T

259 lines
14 KiB
Rust
Raw Normal View History

2026-09-24 14:35:05 +03:00
//! 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);
"#;