889 lines
26 KiB
Rust
889 lines
26 KiB
Rust
use std::sync::Arc;
|
|
|
|
use anyhow::{anyhow, bail, Context, Result};
|
|
use chrono::{Duration as ChronoDuration, Utc};
|
|
use clap::{Parser, Subcommand, ValueEnum};
|
|
use diesel::{dsl::exists, pg::PgConnection, prelude::*, select};
|
|
use diesel_migrations::MigrationHarness;
|
|
use rand::{rngs::OsRng, TryRngCore};
|
|
use reqwest::{Client, Method, StatusCode};
|
|
use sha2::{Digest, Sha256};
|
|
use tokio::task;
|
|
use uuid::Uuid;
|
|
|
|
use papercrate::{
|
|
auth::capability_sets::{ensure_capability_set, owner_capabilities},
|
|
config::{redact_database_url, AppConfig},
|
|
db::{self, PgPool},
|
|
documents::search::ensure_quickwit_index,
|
|
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT, JOB_DELETE_TENANT},
|
|
migrations::MIGRATIONS,
|
|
models::{
|
|
DocumentAsset, MagicToken, MagicTokenKind, NewUser, NewUserMembership, Tenant,
|
|
TenantStatus, User,
|
|
},
|
|
s3,
|
|
schema::{document_assets, documents, magic_tokens, tenants, user_memberships, users},
|
|
storage::{ObjectStorage, S3Storage, TenantStorage},
|
|
tenants::{apply_tenant_guc, clear_tenant_context, TenantService},
|
|
utils::{text::normalize_identifier, tracing::init_tracing},
|
|
workers::tenants::{build_delete_proof_message, sign_delete_proof, DeleteAction},
|
|
};
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "papercrate-admin",
|
|
version,
|
|
about = "Papercrate administration utility"
|
|
)]
|
|
struct Cli {
|
|
#[command(subcommand)]
|
|
command: Command,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Command {
|
|
CreateUser {
|
|
username: String,
|
|
},
|
|
ListUsers,
|
|
DeleteUser {
|
|
username: String,
|
|
},
|
|
CreateTenant {
|
|
name: String,
|
|
#[arg(long = "storage-root")]
|
|
storage_root: Option<String>,
|
|
#[arg(long = "quickwit-index")]
|
|
quickwit_index: Option<String>,
|
|
},
|
|
DeleteTenant {
|
|
tenant_id: Uuid,
|
|
#[arg(long = "tenant-name")]
|
|
tenant_name: String,
|
|
},
|
|
ResetTenant {
|
|
tenant_id: Uuid,
|
|
#[arg(long = "tenant-name")]
|
|
tenant_name: String,
|
|
#[arg(long = "final-status", value_enum, default_value_t = TenantFinalStatusArg::Active)]
|
|
final_status: TenantFinalStatusArg,
|
|
},
|
|
AddUserToTenant {
|
|
username: String,
|
|
tenant_id: Uuid,
|
|
},
|
|
RemoveUserFromTenant {
|
|
username: String,
|
|
tenant_id: Uuid,
|
|
},
|
|
ReanalyzeDocuments {
|
|
tenant_id: Uuid,
|
|
},
|
|
ListTenants,
|
|
DeleteAssets {
|
|
tenant_id: Uuid,
|
|
#[arg(long = "asset-type")]
|
|
asset_type: Option<String>,
|
|
#[arg(long = "all", help = "Confirm deleting every asset for the tenant")]
|
|
delete_all: bool,
|
|
},
|
|
QuickwitCreate {
|
|
tenant_id: Uuid,
|
|
},
|
|
QuickwitDelete {
|
|
tenant_id: Uuid,
|
|
},
|
|
EnqueueDeleteTenant {
|
|
tenant_id: Uuid,
|
|
#[arg(long = "tenant-name")]
|
|
tenant_name: String,
|
|
#[arg(long = "remove-tenant")]
|
|
remove_tenant: bool,
|
|
#[arg(long = "final-status", value_enum)]
|
|
final_status: Option<TenantFinalStatusArg>,
|
|
},
|
|
MagicToken {
|
|
username: String,
|
|
#[arg(long = "ttl-minutes", default_value_t = 10)]
|
|
ttl_minutes: i64,
|
|
#[arg(
|
|
long = "max-uses",
|
|
value_name = "MAX_USES",
|
|
help = "Maximum number of uses before the token is rejected (default: unlimited)"
|
|
)]
|
|
max_uses: Option<i32>,
|
|
#[arg(long = "kind", value_enum, default_value_t = MagicTokenKindArg::EmailLogin)]
|
|
kind: MagicTokenKindArg,
|
|
},
|
|
MigrateDatabase {
|
|
#[arg(
|
|
long = "database-url",
|
|
value_name = "URL",
|
|
help = "Override the migrations database URL (defaults to MIGRATIONS_DATABASE_URL or DATABASE_URL)"
|
|
)]
|
|
database_url: Option<String>,
|
|
},
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, ValueEnum)]
|
|
enum MagicTokenKindArg {
|
|
#[value(name = "email_login")]
|
|
EmailLogin,
|
|
#[value(name = "demo_login")]
|
|
DemoLogin,
|
|
}
|
|
|
|
impl From<MagicTokenKindArg> for MagicTokenKind {
|
|
fn from(value: MagicTokenKindArg) -> Self {
|
|
match value {
|
|
MagicTokenKindArg::EmailLogin => MagicTokenKind::EmailLogin,
|
|
MagicTokenKindArg::DemoLogin => MagicTokenKind::DemoLogin,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, ValueEnum)]
|
|
enum TenantFinalStatusArg {
|
|
Active,
|
|
Suspended,
|
|
}
|
|
|
|
impl TenantFinalStatusArg {
|
|
fn as_str(&self) -> &'static str {
|
|
match self {
|
|
TenantFinalStatusArg::Active => "active",
|
|
TenantFinalStatusArg::Suspended => "suspended",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
init_tracing("info");
|
|
let cli = Cli::parse();
|
|
let config = AppConfig::load_and_log("admin")?;
|
|
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
|
|
|
match cli.command {
|
|
Command::CreateUser { username } => create_user(&pool, &username)?,
|
|
Command::ListUsers => list_users(&pool)?,
|
|
Command::DeleteUser { username } => delete_user(&pool, &username)?,
|
|
Command::CreateTenant {
|
|
name,
|
|
storage_root,
|
|
quickwit_index,
|
|
} => create_tenant(&pool, &name, storage_root, quickwit_index)?,
|
|
Command::DeleteTenant {
|
|
tenant_id,
|
|
tenant_name,
|
|
} => delete_tenant(&config, &pool, tenant_id, &tenant_name)?,
|
|
Command::ResetTenant {
|
|
tenant_id,
|
|
tenant_name,
|
|
final_status,
|
|
} => reset_tenant(&config, &pool, tenant_id, &tenant_name, final_status)?,
|
|
Command::AddUserToTenant {
|
|
username,
|
|
tenant_id,
|
|
} => add_user_to_tenant(&pool, &username, tenant_id)?,
|
|
Command::RemoveUserFromTenant {
|
|
username,
|
|
tenant_id,
|
|
} => remove_user_from_tenant(&pool, &username, tenant_id)?,
|
|
Command::ReanalyzeDocuments { tenant_id } => reanalyze_documents(&pool, tenant_id)?,
|
|
Command::ListTenants => list_tenants(&pool)?,
|
|
Command::DeleteAssets {
|
|
tenant_id,
|
|
asset_type,
|
|
delete_all,
|
|
} => {
|
|
let asset_type = asset_type.as_deref();
|
|
if asset_type.is_none() && !delete_all {
|
|
bail!("refusing to delete all assets without --all confirmation");
|
|
}
|
|
|
|
delete_assets_for_tenant(&config, &pool, tenant_id, asset_type).await?
|
|
}
|
|
Command::QuickwitCreate { tenant_id } => {
|
|
quickwit_index(&config, &pool, tenant_id, Method::POST).await?
|
|
}
|
|
Command::QuickwitDelete { tenant_id } => {
|
|
quickwit_index(&config, &pool, tenant_id, Method::DELETE).await?
|
|
}
|
|
Command::EnqueueDeleteTenant {
|
|
tenant_id,
|
|
tenant_name,
|
|
remove_tenant,
|
|
final_status,
|
|
} => enqueue_delete_tenant_job(
|
|
&config,
|
|
&pool,
|
|
tenant_id,
|
|
&tenant_name,
|
|
remove_tenant,
|
|
final_status,
|
|
)?,
|
|
Command::MagicToken {
|
|
username,
|
|
ttl_minutes,
|
|
max_uses,
|
|
kind,
|
|
} => {
|
|
create_magic_token(&pool, &username, ttl_minutes, max_uses, kind.into())?;
|
|
}
|
|
Command::MigrateDatabase { database_url } => {
|
|
let url = database_url.unwrap_or_else(|| config.migrations_database_url().to_string());
|
|
migrate_database(url).await?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn migrate_database(database_url: String) -> Result<()> {
|
|
let redacted = redact_database_url(&database_url);
|
|
tracing::info!(database_url = %redacted, "running pending migrations");
|
|
|
|
let result = task::spawn_blocking(move || -> Result<()> {
|
|
let mut conn =
|
|
PgConnection::establish(&database_url).context("failed to connect to database")?;
|
|
conn.run_pending_migrations(MIGRATIONS)
|
|
.map_err(|err| anyhow!("failed to run migrations: {err}"))?;
|
|
Ok(())
|
|
})
|
|
.await
|
|
.context("migration task panicked")?;
|
|
|
|
result?;
|
|
tracing::info!(database_url = %redacted, "migrations completed");
|
|
Ok(())
|
|
}
|
|
|
|
fn create_user(pool: &PgPool, username: &str) -> Result<()> {
|
|
let username = normalize_identifier(
|
|
username,
|
|
100,
|
|
"username must not be empty",
|
|
"username must not exceed 100 characters",
|
|
Some("username may only contain printable characters"),
|
|
|ch| !ch.is_control(),
|
|
)
|
|
.map_err(|err| anyhow!("{:?}", err))?;
|
|
|
|
let mut conn = pool.get().context("failed to get database connection")?;
|
|
let exists: bool =
|
|
select(exists(users::table.filter(users::username.eq(&username)))).get_result(&mut conn)?;
|
|
if exists {
|
|
bail!("user '{}' already exists", username);
|
|
}
|
|
|
|
let new_user = NewUser {
|
|
id: Uuid::new_v4(),
|
|
username: username.clone(),
|
|
};
|
|
|
|
diesel::insert_into(users::table)
|
|
.values(&new_user)
|
|
.execute(&mut conn)?;
|
|
|
|
println!("created user '{}' (id: {})", username, new_user.id);
|
|
Ok(())
|
|
}
|
|
|
|
fn list_users(pool: &PgPool) -> Result<()> {
|
|
let mut conn = pool.get().context("failed to get database connection")?;
|
|
|
|
let users_list: Vec<User> = users::table.order(users::username.asc()).load(&mut conn)?;
|
|
if users_list.is_empty() {
|
|
println!("No users found.");
|
|
return Ok(());
|
|
}
|
|
|
|
for user in users_list {
|
|
let memberships: Vec<String> = user_memberships::table
|
|
.inner_join(tenants::table)
|
|
.filter(user_memberships::user_id.eq(user.id))
|
|
.select(tenants::name)
|
|
.order(tenants::name.asc())
|
|
.load(&mut conn)?;
|
|
|
|
if memberships.is_empty() {
|
|
println!("{} ({})", user.username, user.id);
|
|
} else {
|
|
println!(
|
|
"{} ({}) -> {}",
|
|
user.username,
|
|
user.id,
|
|
memberships.join(", ")
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn delete_user(pool: &PgPool, username: &str) -> Result<()> {
|
|
let mut conn = pool.get().context("failed to get database connection")?;
|
|
|
|
let user: User = users::table
|
|
.filter(users::username.eq(username))
|
|
.first(&mut conn)
|
|
.optional()?
|
|
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
|
|
|
let has_memberships: bool = select(exists(
|
|
user_memberships::table.filter(user_memberships::user_id.eq(user.id)),
|
|
))
|
|
.get_result(&mut conn)?;
|
|
if has_memberships {
|
|
bail!(
|
|
"user '{}' is still a member of one or more tenants; remove memberships first",
|
|
username
|
|
);
|
|
}
|
|
|
|
diesel::delete(users::table.filter(users::id.eq(user.id))).execute(&mut conn)?;
|
|
|
|
println!("deleted user '{}'", username);
|
|
Ok(())
|
|
}
|
|
|
|
fn create_tenant(
|
|
pool: &PgPool,
|
|
name: &str,
|
|
storage_root_arg: Option<String>,
|
|
quickwit_index_arg: Option<String>,
|
|
) -> Result<()> {
|
|
let service = TenantService::new(pool.clone());
|
|
let tenant = service
|
|
.create_tenant(
|
|
name,
|
|
storage_root_arg.as_deref(),
|
|
quickwit_index_arg.as_deref(),
|
|
TenantStatus::Creating,
|
|
&[],
|
|
None,
|
|
)
|
|
.map_err(|err| anyhow!(format!("{err:?}")))?;
|
|
|
|
let storage_root = tenant.storage_root.as_deref().unwrap_or("<none>");
|
|
let quickwit_index = tenant.quickwit_index.as_deref().unwrap_or("<none>");
|
|
|
|
println!(
|
|
"created tenant '{}' with id {}, storage_root '{}', quickwit_index '{}', status '{}'",
|
|
tenant.name,
|
|
tenant.id,
|
|
storage_root,
|
|
quickwit_index,
|
|
tenant.status.as_str()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn create_magic_token(
|
|
pool: &PgPool,
|
|
username: &str,
|
|
ttl_minutes: i64,
|
|
max_uses: Option<i32>,
|
|
kind: MagicTokenKind,
|
|
) -> Result<()> {
|
|
let mut conn = pool.get().context("failed to get database connection")?;
|
|
|
|
let user: User = users::table
|
|
.filter(users::username.eq(username))
|
|
.first(&mut conn)
|
|
.with_context(|| format!("user '{}' not found", username))?;
|
|
|
|
let raw_token = generate_random_token();
|
|
let token_hash = hash_token(&raw_token);
|
|
let expires_at = Utc::now() + ChronoDuration::minutes(ttl_minutes);
|
|
|
|
let new_token = MagicToken {
|
|
id: Uuid::new_v4(),
|
|
user_id: user.id,
|
|
kind,
|
|
token_hash,
|
|
metadata: serde_json::json!({}),
|
|
expires_at: expires_at.naive_utc(),
|
|
max_uses,
|
|
used_count: 0,
|
|
created_at: Utc::now().naive_utc(),
|
|
created_by: None,
|
|
last_used_at: None,
|
|
};
|
|
|
|
diesel::insert_into(magic_tokens::table)
|
|
.values(&new_token)
|
|
.execute(&mut conn)?;
|
|
|
|
println!(
|
|
"Magic token created for '{}' (kind: {}, expires_at: {}, max_uses: {})",
|
|
username,
|
|
kind.as_str(),
|
|
expires_at,
|
|
max_uses.map_or("∞".to_string(), |v| v.to_string())
|
|
);
|
|
println!("Token: {}", raw_token);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn generate_random_token() -> String {
|
|
let mut bytes = [0u8; 32];
|
|
OsRng
|
|
.try_fill_bytes(&mut bytes)
|
|
.expect("failed to read random bytes");
|
|
hex::encode(bytes)
|
|
}
|
|
|
|
fn hash_token(token: &str) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(token.as_bytes());
|
|
hex::encode(hasher.finalize())
|
|
}
|
|
|
|
fn delete_tenant(
|
|
config: &AppConfig,
|
|
pool: &PgPool,
|
|
tenant_id: Uuid,
|
|
tenant_name: &str,
|
|
) -> Result<()> {
|
|
enqueue_delete_tenant_job_internal(config, pool, tenant_id, tenant_name, true, None)?;
|
|
println!(
|
|
"delete job enqueued; tenant '{}' will be permanently removed",
|
|
tenant_name
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn reset_tenant(
|
|
config: &AppConfig,
|
|
pool: &PgPool,
|
|
tenant_id: Uuid,
|
|
tenant_name: &str,
|
|
final_status: TenantFinalStatusArg,
|
|
) -> Result<()> {
|
|
enqueue_delete_tenant_job_internal(
|
|
config,
|
|
pool,
|
|
tenant_id,
|
|
tenant_name,
|
|
false,
|
|
Some(final_status),
|
|
)?;
|
|
println!(
|
|
"reset job enqueued; tenant '{}' will be wiped and set to {}",
|
|
tenant_name,
|
|
final_status.as_str()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn enqueue_delete_tenant_job_internal(
|
|
config: &AppConfig,
|
|
pool: &PgPool,
|
|
tenant_id: Uuid,
|
|
expected_name: &str,
|
|
remove_tenant: bool,
|
|
final_status: Option<TenantFinalStatusArg>,
|
|
) -> Result<()> {
|
|
let mut conn = pool.get().context("failed to get database connection")?;
|
|
|
|
let tenant: Tenant = tenants::table
|
|
.find(tenant_id)
|
|
.first(&mut conn)
|
|
.optional()?
|
|
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
|
|
|
apply_tenant_guc(&mut conn, tenant.id)
|
|
.map_err(|err| anyhow!("failed to set tenant context for {}: {err:?}", tenant.name))?;
|
|
|
|
if tenant.name != expected_name {
|
|
bail!(
|
|
"tenant name mismatch: expected '{}', database has '{}'",
|
|
expected_name,
|
|
tenant.name
|
|
);
|
|
}
|
|
|
|
diesel::update(tenants::table.find(tenant.id))
|
|
.set(tenants::status.eq(TenantStatus::Deleting))
|
|
.execute(&mut conn)?;
|
|
|
|
let nonce = generate_random_token();
|
|
let issued_at = Utc::now();
|
|
let issued_at_str = issued_at.to_rfc3339();
|
|
let action = if remove_tenant {
|
|
DeleteAction::Delete
|
|
} else {
|
|
DeleteAction::Reset
|
|
};
|
|
let resolved_final_status = if remove_tenant {
|
|
None
|
|
} else {
|
|
Some(
|
|
final_status
|
|
.unwrap_or(TenantFinalStatusArg::Suspended)
|
|
.as_str(),
|
|
)
|
|
};
|
|
|
|
let message = build_delete_proof_message(
|
|
tenant.id,
|
|
expected_name,
|
|
action,
|
|
&nonce,
|
|
&issued_at_str,
|
|
resolved_final_status,
|
|
);
|
|
let signature = sign_delete_proof(&config.jwt_secret, &message).map_err(|err| anyhow!(err))?;
|
|
|
|
let mut payload = serde_json::json!({
|
|
"remove_tenant": remove_tenant,
|
|
"tenant_name": tenant.name.clone(),
|
|
"action": action.as_str(),
|
|
"nonce": nonce,
|
|
"issued_at": issued_at_str,
|
|
"signature": signature,
|
|
});
|
|
if let Some(status) = resolved_final_status {
|
|
payload["final_status"] = serde_json::json!(status);
|
|
}
|
|
|
|
enqueue_job(&mut conn, tenant.id, JOB_DELETE_TENANT, payload, None)?;
|
|
let status_label = if remove_tenant {
|
|
"deleted"
|
|
} else {
|
|
final_status.map(|s| s.as_str()).unwrap_or("suspended")
|
|
};
|
|
println!(
|
|
"delete-tenant job enqueued for '{}' (remove_tenant={}, final_status={})",
|
|
tenant.name, remove_tenant, status_label
|
|
);
|
|
|
|
clear_tenant_context(&mut conn).map_err(|err| {
|
|
anyhow!(
|
|
"failed to clear tenant context for {}: {err:?}",
|
|
tenant.name
|
|
)
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn enqueue_delete_tenant_job(
|
|
config: &AppConfig,
|
|
pool: &PgPool,
|
|
tenant_id: Uuid,
|
|
tenant_name: &str,
|
|
remove_tenant: bool,
|
|
final_status: Option<TenantFinalStatusArg>,
|
|
) -> Result<()> {
|
|
enqueue_delete_tenant_job_internal(
|
|
config,
|
|
pool,
|
|
tenant_id,
|
|
tenant_name,
|
|
remove_tenant,
|
|
final_status,
|
|
)
|
|
}
|
|
|
|
fn add_user_to_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result<()> {
|
|
let mut conn = pool.get().context("failed to get database connection")?;
|
|
|
|
let user: User = users::table
|
|
.filter(users::username.eq(username))
|
|
.first(&mut conn)
|
|
.optional()?
|
|
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
|
|
|
let tenant: Tenant = tenants::table
|
|
.find(tenant_id)
|
|
.first(&mut conn)
|
|
.optional()?
|
|
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
|
|
|
let owner_capability_set_id = ensure_capability_set(&mut conn, tenant.id, owner_capabilities())
|
|
.map_err(|err| anyhow!("failed to ensure owner capability set: {:?}", err))?
|
|
.id;
|
|
|
|
let membership = NewUserMembership {
|
|
id: Uuid::new_v4(),
|
|
user_id: user.id,
|
|
tenant_id: tenant.id,
|
|
capability_set_id: Some(owner_capability_set_id),
|
|
};
|
|
|
|
diesel::insert_into(user_memberships::table)
|
|
.values(&membership)
|
|
.on_conflict((user_memberships::user_id, user_memberships::tenant_id))
|
|
.do_nothing()
|
|
.execute(&mut conn)?;
|
|
|
|
println!("added user '{}' to tenant '{}'", username, tenant.name);
|
|
Ok(())
|
|
}
|
|
|
|
fn remove_user_from_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result<()> {
|
|
let mut conn = pool.get().context("failed to get database connection")?;
|
|
|
|
let user: User = users::table
|
|
.filter(users::username.eq(username))
|
|
.first(&mut conn)
|
|
.optional()?
|
|
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
|
|
|
let tenant: Tenant = tenants::table
|
|
.find(tenant_id)
|
|
.first(&mut conn)
|
|
.optional()?
|
|
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
|
|
|
let removed = diesel::delete(
|
|
user_memberships::table
|
|
.filter(user_memberships::user_id.eq(user.id))
|
|
.filter(user_memberships::tenant_id.eq(tenant.id)),
|
|
)
|
|
.execute(&mut conn)?;
|
|
|
|
if removed == 0 {
|
|
println!(
|
|
"user '{}' was not a member of tenant '{}'",
|
|
username, tenant.name
|
|
);
|
|
} else {
|
|
println!("removed user '{}' from tenant '{}'", username, tenant.name);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn reanalyze_documents(pool: &PgPool, tenant_id: Uuid) -> Result<()> {
|
|
let mut conn = pool.get().context("failed to get database connection")?;
|
|
|
|
let tenant: Tenant = tenants::table
|
|
.find(tenant_id)
|
|
.first(&mut conn)
|
|
.optional()?
|
|
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
|
|
|
let targets: Vec<(Uuid, Uuid)> = documents::table
|
|
.filter(documents::tenant_id.eq(tenant.id))
|
|
.filter(documents::deleted_at.is_null())
|
|
.select((documents::id, documents::current_version_id))
|
|
.load(&mut conn)?;
|
|
|
|
if targets.is_empty() {
|
|
println!("tenant '{}' has no active documents", tenant.name);
|
|
return Ok(());
|
|
}
|
|
|
|
let mut queued = 0usize;
|
|
for (document_id, version_id) in targets {
|
|
enqueue_job(
|
|
&mut conn,
|
|
tenant.id,
|
|
JOB_ANALYZE_DOCUMENT,
|
|
serde_json::json!({
|
|
"document_id": document_id,
|
|
"document_version_id": version_id,
|
|
"force": true,
|
|
}),
|
|
None,
|
|
)
|
|
.map_err(|err| anyhow!("failed to enqueue analyze job: {}", err))?;
|
|
queued += 1;
|
|
}
|
|
|
|
println!(
|
|
"queued {} documents for re-analysis in tenant '{}'",
|
|
queued, tenant.name
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn list_tenants(pool: &PgPool) -> Result<()> {
|
|
let mut conn = pool.get().context("failed to get database connection")?;
|
|
let tenants: Vec<Tenant> = tenants::table
|
|
.order(tenants::name.asc())
|
|
.load(&mut conn)
|
|
.context("failed to load tenants")?;
|
|
|
|
if tenants.is_empty() {
|
|
println!("No tenants found.");
|
|
return Ok(());
|
|
}
|
|
|
|
for tenant in tenants {
|
|
println!("{} {}", tenant.id, tenant.name);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn delete_assets_for_tenant(
|
|
config: &AppConfig,
|
|
pool: &PgPool,
|
|
tenant_id: Uuid,
|
|
asset_type: Option<&str>,
|
|
) -> Result<()> {
|
|
let bucket = s3::build_bucket(config)?;
|
|
let storage: Arc<dyn ObjectStorage> = Arc::new(S3Storage::new(bucket));
|
|
|
|
let mut conn = pool.get().context("failed to get database connection")?;
|
|
let tenant: Tenant = tenants::table
|
|
.find(tenant_id)
|
|
.first(&mut conn)
|
|
.optional()?
|
|
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
|
|
|
apply_tenant_guc(&mut conn, tenant.id)
|
|
.map_err(|err| anyhow!("failed to set tenant context for {}: {err:?}", tenant.name))?;
|
|
|
|
let result = async {
|
|
let tenant_storage = TenantStorage::new(Arc::clone(&storage), &tenant)
|
|
.with_context(|| format!("missing storage root for tenant {}", tenant.name))?;
|
|
|
|
let mut asset_query = document_assets::table
|
|
.filter(document_assets::tenant_id.eq(tenant.id))
|
|
.into_boxed();
|
|
|
|
if let Some(asset_type) = asset_type {
|
|
asset_query = asset_query.filter(document_assets::asset_type.eq(asset_type));
|
|
}
|
|
|
|
let assets: Vec<DocumentAsset> = asset_query
|
|
.load(&mut conn)
|
|
.with_context(|| format!("failed to load assets for tenant {}", tenant.name))?;
|
|
|
|
if assets.is_empty() {
|
|
match asset_type {
|
|
Some(asset_type) => {
|
|
println!("Tenant {}: no assets of type '{}'", tenant.name, asset_type)
|
|
}
|
|
None => println!("Tenant {}: no assets", tenant.name),
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
match asset_type {
|
|
Some(asset_type) => println!(
|
|
"Tenant {} ({}): deleting {} '{}' assets…",
|
|
tenant.name,
|
|
tenant.id,
|
|
assets.len(),
|
|
asset_type
|
|
),
|
|
None => println!(
|
|
"Tenant {} ({}): deleting {} assets…",
|
|
tenant.name,
|
|
tenant.id,
|
|
assets.len()
|
|
),
|
|
}
|
|
|
|
let asset_ids: Vec<Uuid> = assets.iter().map(|asset| asset.id).collect();
|
|
|
|
for asset in &assets {
|
|
if let Err(err) = tenant_storage.delete_object(&asset.s3_key).await {
|
|
eprintln!(
|
|
"Failed to delete object {} (tenant {}): {err}",
|
|
asset.s3_key, tenant.name
|
|
);
|
|
}
|
|
}
|
|
|
|
diesel::delete(
|
|
document_assets::table
|
|
.filter(document_assets::tenant_id.eq(tenant.id))
|
|
.filter(document_assets::id.eq_any(&asset_ids)),
|
|
)
|
|
.execute(&mut conn)
|
|
.with_context(|| format!("failed to remove asset records for tenant {}", tenant.name))?;
|
|
|
|
println!("Tenant {}: asset records deleted.", tenant.name);
|
|
Ok(())
|
|
}
|
|
.await;
|
|
|
|
clear_tenant_context(&mut conn).map_err(|err| {
|
|
anyhow!(
|
|
"failed to clear tenant context for {}: {err:?}",
|
|
tenant.name
|
|
)
|
|
})?;
|
|
|
|
result
|
|
}
|
|
|
|
async fn quickwit_index(
|
|
config: &AppConfig,
|
|
pool: &PgPool,
|
|
tenant_id: Uuid,
|
|
method: Method,
|
|
) -> Result<()> {
|
|
let endpoint = config
|
|
.quickwit_endpoint
|
|
.as_ref()
|
|
.ok_or_else(|| anyhow!("quickwit endpoint not configured"))?;
|
|
|
|
let mut conn = pool.get().context("failed to get database connection")?;
|
|
let tenant: Tenant = tenants::table
|
|
.find(tenant_id)
|
|
.first(&mut conn)
|
|
.optional()?
|
|
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
|
|
|
let client = Client::new();
|
|
let index_id = format!("documents-{}", tenant.id);
|
|
let base_endpoint = endpoint.trim_end_matches('/');
|
|
|
|
match method {
|
|
Method::POST => {
|
|
ensure_quickwit_index(&client, base_endpoint, &index_id)
|
|
.await
|
|
.context("failed to ensure quickwit index")?;
|
|
|
|
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
|
|
.set(tenants::quickwit_index.eq(Some(index_id.clone())))
|
|
.execute(&mut conn)
|
|
.context("failed to update tenant quickwit_index")?;
|
|
|
|
println!(
|
|
"Tenant '{}' quickwit index set to '{}'.",
|
|
tenant.name, index_id
|
|
);
|
|
}
|
|
Method::DELETE => {
|
|
let response = client
|
|
.delete(format!("{}/api/v1/indexes/{}", base_endpoint, index_id))
|
|
.send()
|
|
.await
|
|
.context("failed to send delete index request")?;
|
|
|
|
match response.status() {
|
|
status if status.is_success() || status == StatusCode::NOT_FOUND => {
|
|
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
|
|
.set(tenants::quickwit_index.eq::<Option<String>>(None))
|
|
.execute(&mut conn)
|
|
.context("failed to clear tenant quickwit_index")?;
|
|
|
|
println!("Tenant '{}' quickwit index cleared.", tenant.name);
|
|
}
|
|
status => {
|
|
let body = response.text().await.unwrap_or_default();
|
|
bail!(
|
|
"quickwit delete index failed with status {}: {}",
|
|
status,
|
|
body
|
|
);
|
|
}
|
|
}
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
|
|
Ok(())
|
|
}
|