Files
meguri/src/demo_runtime.rs
T
ab a47c4a0398
Checks / test (push) Failing after 12m20s
Init
2026-09-24 14:35:05 +03:00

260 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
"#;