Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
604 changes: 0 additions & 604 deletions docs/design/migration-integrity-verification.md

This file was deleted.

99 changes: 97 additions & 2 deletions src/cli/migrate/down.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use miette::{Context, IntoDiagnostic};
use serde::Serialize;

use crate::cli::{OutputFormat, ensure_backend_initialized, load_backend, print_json};
use crate::db::execution::MigrationTracker;
use crate::db::execution::{ExecutionError, MigrationTracker, compute_migration_hash};
use crate::db::migrate::{MigrationPlan, PostgresRenderer, RenderConfig};
use crate::db::state::{Migration, StateBackend};
use crate::db::{self};
Expand Down Expand Up @@ -42,6 +42,14 @@ pub struct Down {
#[arg(long)]
pub dry_run: bool,

/// Skip integrity verification (dangerous)
///
/// This skips both schema checksum verification and migration hash
/// verification. Use only in emergency situations when you understand
/// the risks of schema corruption.
#[arg(long)]
pub force: bool,

/// Output format
#[arg(long, default_value = "text")]
pub format: OutputFormat,
Expand Down Expand Up @@ -169,7 +177,19 @@ impl Down {
}
};

// Find the migration in the local backend
// Get the current migration record (for verification)
let current_record = tracker
.get_migration(&current_id)
.await
.into_diagnostic()?
.ok_or_else(|| {
miette::miette!(
"Migration {} is recorded as current but not found in tern.migrations table",
current_id
)
})?;

// Find the migration in the local backend (needed for both verification and execution)
let migration = find_migration_by_hex_id(&backend, &current_id)
.await
.into_diagnostic()
Expand All @@ -185,6 +205,81 @@ impl Down {
}
};

// Always perform verification, but handle results based on --force flag
let schema_verification = tracker
.verify_schema_checksum(&current_id, &current_record.schema_hash)
.await;

let local_hash = compute_migration_hash(&migration);
let hash_matches = local_hash == current_record.migration_hash;

// Determine if verification passed
let schema_ok = schema_verification.is_ok();
let all_ok = schema_ok && hash_matches;

if self.force && !matches!(self.format, OutputFormat::Json) {
if all_ok {
println!();
println!("Note: --force was unnecessary, all integrity checks passed.");
println!();
} else {
println!();
println!("WARNING: Integrity checks failed, but proceeding due to --force flag.");
println!();
if let Err(ExecutionError::SchemaDrift {
expected, actual, ..
}) = &schema_verification
{
println!(
" Schema drift detected for migration {}...:",
&current_id[..12.min(current_id.len())]
);
println!(" Expected checksum: {}", expected);
println!(" Actual checksum: {}", actual);
println!();
}
if !hash_matches {
println!(
" Migration file modified for {}...:",
&current_id[..12.min(current_id.len())]
);
println!(
" Expected hash: {}",
&current_record.migration_hash
[..16.min(current_record.migration_hash.len())]
);
println!(
" Actual hash: {}",
&local_hash[..16.min(local_hash.len())]
);
println!();
}
println!("Proceeding anyway. This can result in schema corruption or data loss.");
println!();
}
} else if !self.force {
// When not forcing, error on verification failure
if let Err(ExecutionError::SchemaDrift {
expected, actual, ..
}) = schema_verification
{
return Err(miette::miette!(
"Schema drift detected!\n\nThe live database schema does not match the expected state after migration {}.\n\n Expected checksum: {}\n Actual checksum: {}\n\nThis indicates the database was modified outside of Tern migrations.\n\nTo investigate: tern verify --database-url <URL>\nTo resolve:\n Option 1: Revert manual changes to match expected state\n Option 2: Run `tern compile` to capture changes as a new migration\n Option 3: Use `--force` to skip verification (dangerous)",
&current_id[..12.min(current_id.len())],
expected,
actual
));
}
if !hash_matches {
return Err(miette::miette!(
"Migration file has been modified since it was applied!\n\nMigration {} has different content than what was recorded in the database.\n\n Expected hash: {}\n Actual hash: {}\n\nThis migration was applied with different operations than what's on disk.\n\nTo resolve:\n Option 1: Restore the original migration file from version control\n Option 2: Use `--force` to skip verification (dangerous)\n\nWarning: Reverting with mismatched history can cause schema inconsistencies between environments.",
&current_id[..12.min(current_id.len())],
current_record.migration_hash,
local_hash
));
}
}

// Check if this is a baseline migration
if migration.is_baseline() {
return Err(miette::miette!(
Expand Down
57 changes: 55 additions & 2 deletions src/cli/migrate/up.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ pub struct Up {
#[arg(long)]
pub dry_run: bool,

/// Skip integrity verification (dangerous)
///
/// This skips both schema checksum verification and migration hash
/// verification. Use only in emergency situations when you understand
/// the risks of schema corruption.
#[arg(long)]
pub force: bool,

/// Output format
#[arg(long, default_value = "text")]
pub format: OutputFormat,
Expand Down Expand Up @@ -173,14 +181,59 @@ impl Up {
// Create executor
let executor = MigrationExecutor::new(&client, &backend, &self.schema);

// When --force is used, still perform verification but warn instead of error
if self.force && !matches!(self.format, OutputFormat::Json) {
let status = executor
.check_integrity()
.await
.into_diagnostic()
.wrap_err("Failed to check integrity")?;

if status.all_ok() {
println!();
println!("Note: --force was unnecessary, all integrity checks passed.");
println!();
} else {
println!();
println!("WARNING: Integrity checks failed, but proceeding due to --force flag.");
println!();
if let Some(ref mismatch) = status.schema_mismatch {
println!(
" Schema drift detected for migration {}...:",
&mismatch.migration_id[..12.min(mismatch.migration_id.len())]
);
println!(" Expected checksum: {}", mismatch.expected);
println!(" Actual checksum: {}", mismatch.actual);
println!();
}
if let Some(ref divergence) = status.history_diverged {
println!(
" Migration history diverged for {}...:",
&divergence.migration_id[..12.min(divergence.migration_id.len())]
);
println!(
" Expected hash: {}",
&divergence.expected_hash[..16.min(divergence.expected_hash.len())]
);
println!(
" Actual hash: {}",
&divergence.actual_hash[..16.min(divergence.actual_hash.len())]
);
println!();
}
println!("Proceeding anyway. This can result in schema corruption or data loss.");
println!();
}
}

if self.dry_run {
// Just show pending migrations
if !matches!(self.format, OutputFormat::Json) {
println!("Checking migration status...");
}

let pending = executor
.get_pending()
.get_pending(self.force)
.await
.into_diagnostic()
.wrap_err("Failed to get pending migrations")?;
Expand Down Expand Up @@ -221,7 +274,7 @@ impl Up {
}

let result = executor
.execute_pending()
.execute_pending(self.force)
.await
.into_diagnostic()
.wrap_err("Failed to execute migrations")?;
Expand Down
4 changes: 2 additions & 2 deletions src/cli/up/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ impl Up {
}

let pending = executor
.get_pending()
.get_pending(false)
.await
.into_diagnostic()
.wrap_err("Failed to get pending migrations")?;
Expand Down Expand Up @@ -221,7 +221,7 @@ impl Up {
}

let result = executor
.execute_pending()
.execute_pending(false)
.await
.into_diagnostic()
.wrap_err("Failed to execute migrations")?;
Expand Down
Loading