refactor: complete v0.8 core architecture
This commit is contained in:
1 parent
f138a645f8
commit
d7e0ac7679
98 files changed
+8413
-3044
No files matched your search
@@ -0,0 +1,398 @@
|
||||
//! Explicit Phase 3 Identity & Directory Migration Engine.
|
||||
//!
|
||||
//! Lifecycle:
|
||||
//! 1. Preflight (inspect DB and filesystem, identify unmigrated users)
|
||||
//! 2. Backup (create pre-migration tarball)
|
||||
//! 3. Identity Migration (assign immutable TenantId + UUID in users.db)
|
||||
//! 4. Filesystem Migration (atomically move users/<id>/ to users/<TenantId>/)
|
||||
//! 5. Validation (verify all users, directories, and databases)
|
||||
//! 6. Completion Marker (record audit event and system setting)
|
||||
//!
|
||||
//! Legacy Admin (users/1) is preserved as a Core/legacy concern and NOT converted
|
||||
//! to a normal TenantId directory.
|
||||
|
||||
use rusqlite::Connection;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::db::topology::{is_v08_user_id, Topology};
|
||||
use crate::db::Db;
|
||||
use crate::identity::TenantId;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct IdentityMigrationReport {
|
||||
pub total_users: usize,
|
||||
pub users_assigned_tenant_id: usize,
|
||||
pub users_assigned_uuid: usize,
|
||||
pub directories_moved: usize,
|
||||
pub directories_already_migrated: usize,
|
||||
pub legacy_admin_preserved: bool,
|
||||
pub validation_passed: bool,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreflightPlan {
|
||||
pub total_users: usize,
|
||||
pub normal_users: usize,
|
||||
pub users_needing_tenant_id: Vec<(i64, String)>,
|
||||
pub users_needing_uuid: Vec<(i64, String)>,
|
||||
pub directories_to_move: Vec<(i64, PathBuf, TenantId)>,
|
||||
pub directories_already_migrated: usize,
|
||||
}
|
||||
|
||||
/// Run the full explicit identity migration lifecycle.
|
||||
pub async fn run_identity_migration(
|
||||
config: &Config,
|
||||
dry_run: bool,
|
||||
skip_backup: bool,
|
||||
) -> Result<IdentityMigrationReport, Box<dyn std::error::Error>> {
|
||||
let topology = Topology::new(&config.data_dir);
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
// 1. Initialize Db for Core connections
|
||||
let db = Db::init(config)?;
|
||||
|
||||
// 2. Preflight
|
||||
let plan = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
inspect_preflight(&users_conn, &topology)?
|
||||
};
|
||||
|
||||
info!(
|
||||
"Preflight: total_users={}, normal_users={}, needing_tenant_id={}, needing_uuid={}, dirs_to_move={}",
|
||||
plan.total_users,
|
||||
plan.normal_users,
|
||||
plan.users_needing_tenant_id.len(),
|
||||
plan.users_needing_uuid.len(),
|
||||
plan.directories_to_move.len()
|
||||
);
|
||||
|
||||
if dry_run {
|
||||
info!("Dry run mode enabled. No identity changes or directory moves will be performed.");
|
||||
return Ok(IdentityMigrationReport {
|
||||
total_users: plan.total_users,
|
||||
users_assigned_tenant_id: plan.users_needing_tenant_id.len(),
|
||||
users_assigned_uuid: plan.users_needing_uuid.len(),
|
||||
directories_moved: plan.directories_to_move.len(),
|
||||
directories_already_migrated: plan.directories_already_migrated,
|
||||
legacy_admin_preserved: true,
|
||||
validation_passed: true,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Backup before migration (if there is work to do and backup is not skipped)
|
||||
let needs_migration = !plan.users_needing_tenant_id.is_empty()
|
||||
|| !plan.users_needing_uuid.is_empty()
|
||||
|| !plan.directories_to_move.is_empty();
|
||||
|
||||
if needs_migration && !skip_backup {
|
||||
info!("Creating pre-migration backup...");
|
||||
match crate::jobs::backup::perform_backup(&db, config).await {
|
||||
Ok(path) => info!("Pre-migration backup created at {:?}", path),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Pre-migration backup failed: {}. Continuing with caution.",
|
||||
e
|
||||
);
|
||||
warnings.push(format!("Pre-migration backup warning: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Identity Migration (users.db backfill)
|
||||
let (assigned_tids, assigned_uuids) = {
|
||||
let mut users_conn = db.users.lock().unwrap();
|
||||
migrate_user_table_identities(&mut users_conn)?
|
||||
};
|
||||
|
||||
// 5. Filesystem Migration (users/<id>/ -> users/<TenantId>/)
|
||||
let moved_dirs = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
migrate_tenant_directories(&users_conn, &topology, &mut warnings)?
|
||||
};
|
||||
|
||||
// 6. Validation
|
||||
let validation_passed = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
validate_identity_migration(&users_conn, &topology, &mut warnings)?
|
||||
};
|
||||
|
||||
// 7. Completion Marker in system.db
|
||||
if validation_passed {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let details = format!(
|
||||
"Identity migration completed: {} tenant_ids assigned, {} uuids assigned, {} directories moved",
|
||||
assigned_tids, assigned_uuids, moved_dirs
|
||||
);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
"system",
|
||||
"IDENTITY_MIGRATION_COMPLETED",
|
||||
"identity",
|
||||
"users.db",
|
||||
Some(&details),
|
||||
);
|
||||
let _ = system_conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('v08_identity_migration_completed', ?1);",
|
||||
[&now],
|
||||
);
|
||||
}
|
||||
|
||||
Ok(IdentityMigrationReport {
|
||||
total_users: plan.total_users,
|
||||
users_assigned_tenant_id: assigned_tids,
|
||||
users_assigned_uuid: assigned_uuids,
|
||||
directories_moved: moved_dirs,
|
||||
directories_already_migrated: plan.directories_already_migrated,
|
||||
legacy_admin_preserved: true,
|
||||
validation_passed,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// Inspect existing database and filesystem to build a preflight plan.
|
||||
pub fn inspect_preflight(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
) -> Result<PreflightPlan, Box<dyn std::error::Error>> {
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
let total_users = users.len();
|
||||
|
||||
let mut normal_users = 0;
|
||||
let mut users_needing_tenant_id = Vec::new();
|
||||
let mut users_needing_uuid = Vec::new();
|
||||
let mut directories_to_move = Vec::new();
|
||||
let mut directories_already_migrated = 0;
|
||||
|
||||
for user in &users {
|
||||
// Skip legacy admin / system accounts
|
||||
if user.account_type == "admin"
|
||||
|| user.account_type == "system"
|
||||
|| user.username == "legacy_admin"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normal_users += 1;
|
||||
|
||||
if user.tenant_id.is_none() {
|
||||
users_needing_tenant_id.push((user.id, user.username.clone()));
|
||||
}
|
||||
if user.uuid.is_none() {
|
||||
users_needing_uuid.push((user.id, user.username.clone()));
|
||||
}
|
||||
|
||||
if let Some(tid) = user.tenant_id {
|
||||
let legacy_dir = topology.user_dir_i64(user.id)?;
|
||||
let target_dir = topology.tenant_dir(tid);
|
||||
|
||||
if legacy_dir.exists() && legacy_dir != target_dir {
|
||||
directories_to_move.push((user.id, legacy_dir, tid));
|
||||
} else if target_dir.exists() {
|
||||
directories_already_migrated += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PreflightPlan {
|
||||
total_users,
|
||||
normal_users,
|
||||
users_needing_tenant_id,
|
||||
users_needing_uuid,
|
||||
directories_to_move,
|
||||
directories_already_migrated,
|
||||
})
|
||||
}
|
||||
|
||||
/// Assign immutable TenantId and UUID for all normal users missing them in users.db.
|
||||
/// Restart-safe: never regenerates or modifies existing identities.
|
||||
pub fn migrate_user_table_identities(
|
||||
users_conn: &mut Connection,
|
||||
) -> Result<(usize, usize), Box<dyn std::error::Error>> {
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
let mut assigned_tids = 0;
|
||||
let mut assigned_uuids = 0;
|
||||
|
||||
let tx = users_conn.transaction()?;
|
||||
|
||||
for user in users {
|
||||
// Skip legacy admin / system accounts
|
||||
if user.account_type == "admin"
|
||||
|| user.account_type == "system"
|
||||
|| user.username == "legacy_admin"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut needs_update = false;
|
||||
let mut tid_str = user.tenant_id.map(|t| t.as_str().to_string());
|
||||
let mut uuid_str = user.uuid;
|
||||
|
||||
if tid_str.is_none() {
|
||||
// Allocate unique TenantId
|
||||
let tid = crate::identity::TenantId::generate();
|
||||
tid_str = Some(tid.as_str().to_string());
|
||||
assigned_tids += 1;
|
||||
needs_update = true;
|
||||
}
|
||||
|
||||
if uuid_str.is_none() {
|
||||
// Allocate unique UUID
|
||||
let u = uuid::Uuid::new_v4().to_string();
|
||||
uuid_str = Some(u);
|
||||
assigned_uuids += 1;
|
||||
needs_update = true;
|
||||
}
|
||||
|
||||
if needs_update {
|
||||
tx.execute(
|
||||
"UPDATE users SET tenant_id = ?1, uuid = ?2 WHERE id = ?3;",
|
||||
rusqlite::params![tid_str, uuid_str, user.id],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok((assigned_tids, assigned_uuids))
|
||||
}
|
||||
|
||||
/// Move tenant directories from users/<id>/ to users/<TenantId>/ for normal users.
|
||||
/// Legacy users/1 is preserved.
|
||||
pub fn migrate_tenant_directories(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
let mut moved = 0;
|
||||
|
||||
for user in users {
|
||||
if user.account_type == "admin"
|
||||
|| user.account_type == "system"
|
||||
|| user.username == "legacy_admin"
|
||||
{
|
||||
// Preserve Core / system accounts intact
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(tid) = user.tenant_id else {
|
||||
warnings.push(format!(
|
||||
"User '{}' (ID {}) has no TenantId; skipping directory move",
|
||||
user.username, user.id
|
||||
));
|
||||
continue;
|
||||
};
|
||||
|
||||
let legacy_dir = match topology.user_dir_i64(user.id) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let target_dir = topology.tenant_dir(tid);
|
||||
|
||||
if legacy_dir.exists() && legacy_dir != target_dir {
|
||||
if !target_dir.exists() {
|
||||
// Atomic rename on same filesystem
|
||||
fs::rename(&legacy_dir, &target_dir)?;
|
||||
let _ = fs::create_dir_all(target_dir.join("extensions"));
|
||||
info!(
|
||||
"Migrated tenant directory for user '{}': {:?} -> {:?}",
|
||||
user.username, legacy_dir, target_dir
|
||||
);
|
||||
moved += 1;
|
||||
} else {
|
||||
// Target already exists (interrupted / partial run)
|
||||
warn!(
|
||||
"Target directory {:?} already exists for user '{}'. Verifying contents.",
|
||||
target_dir, user.username
|
||||
);
|
||||
// Ensure extensions dir exists
|
||||
let _ = fs::create_dir_all(target_dir.join("extensions"));
|
||||
|
||||
// If legacy directory is now empty or duplicate, clean it up safely
|
||||
if let Ok(entries) = fs::read_dir(&legacy_dir) {
|
||||
if entries.count() == 0 {
|
||||
let _ = fs::remove_dir(&legacy_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// Validate that every normal user has a valid TenantId, UUID, and matching directory.
|
||||
pub fn validate_identity_migration(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
let mut valid = true;
|
||||
|
||||
for user in &users {
|
||||
if user.account_type == "admin"
|
||||
|| user.account_type == "system"
|
||||
|| user.username == "legacy_admin"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate TenantId
|
||||
match user.tenant_id {
|
||||
Some(tid) => {
|
||||
if !is_v08_user_id(tid.as_str()) {
|
||||
warnings.push(format!(
|
||||
"Invalid TenantId format '{}' for user '{}'",
|
||||
tid.as_str(),
|
||||
user.username
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
let target_dir = topology.tenant_dir(tid);
|
||||
if !target_dir.exists() {
|
||||
// Check if content db was initialized or if directory was not yet created
|
||||
warnings.push(format!(
|
||||
"Tenant directory {:?} does not exist for user '{}'",
|
||||
target_dir, user.username
|
||||
));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
warnings.push(format!(
|
||||
"Normal user '{}' (ID {}) is missing TenantId",
|
||||
user.username, user.id
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate UUID
|
||||
match &user.uuid {
|
||||
Some(u) => {
|
||||
if uuid::Uuid::parse_str(u).is_err() {
|
||||
warnings.push(format!(
|
||||
"Invalid UUID format '{}' for user '{}'",
|
||||
u, user.username
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
warnings.push(format!(
|
||||
"Normal user '{}' (ID {}) is missing UUID",
|
||||
user.username, user.id
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(valid)
|
||||
}
|
||||
Reference in new issue
Block a user