fix(migrations): adopt published ledgerless baseline
This commit is contained in:
@@ -11,6 +11,7 @@ mod onboarding_product_events_v11;
|
|||||||
mod owned_relations;
|
mod owned_relations;
|
||||||
mod platform_key_name_reuse_v6;
|
mod platform_key_name_reuse_v6;
|
||||||
mod schema_guard;
|
mod schema_guard;
|
||||||
|
mod schema_guard_legacy_v1;
|
||||||
mod schema_guard_v10;
|
mod schema_guard_v10;
|
||||||
mod schema_guard_v11;
|
mod schema_guard_v11;
|
||||||
mod schema_guard_v12;
|
mod schema_guard_v12;
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ use super::platform_key_name_reuse_v6;
|
|||||||
use super::schema_guard::{
|
use super::schema_guard::{
|
||||||
OWNED_RELATIONS, relation_exists, validate_required_relations, validate_schema_fingerprint,
|
OWNED_RELATIONS, relation_exists, validate_required_relations, validate_schema_fingerprint,
|
||||||
};
|
};
|
||||||
|
use super::schema_guard_legacy_v1::{
|
||||||
|
validate_ledgerless_baseline_fingerprint, validate_ledgerless_optional_fingerprints,
|
||||||
|
};
|
||||||
use super::{BASELINE_CHECKSUM, BASELINE_VERSION, apply_baseline};
|
use super::{BASELINE_CHECKSUM, BASELINE_VERSION, apply_baseline};
|
||||||
use crate::ext::ExtensionMigration;
|
use crate::ext::ExtensionMigration;
|
||||||
use sqlx::{PgConnection, PgPool, Row, Transaction, query};
|
use sqlx::{PgConnection, PgPool, Row, Transaction, query};
|
||||||
@@ -389,6 +392,19 @@ impl MigrationAuthority {
|
|||||||
if from < 13 {
|
if from < 13 {
|
||||||
artifact_cleanup_indexes_v13::apply(&mut transaction, &Self::sequence()[12]).await?;
|
artifact_cleanup_indexes_v13::apply(&mut transaction, &Self::sequence()[12]).await?;
|
||||||
}
|
}
|
||||||
|
match inspect(&mut transaction).await? {
|
||||||
|
MigrationPreflight::Current {
|
||||||
|
version: CURRENT_VERSION,
|
||||||
|
} => {}
|
||||||
|
_ => {
|
||||||
|
return Err(MigrationError::new(
|
||||||
|
"apply_failed",
|
||||||
|
"apply.postflight",
|
||||||
|
Some(CURRENT_VERSION),
|
||||||
|
"restore_known_good_backup",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
transaction
|
transaction
|
||||||
.commit()
|
.commit()
|
||||||
.await
|
.await
|
||||||
@@ -408,6 +424,12 @@ async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, Mi
|
|||||||
owned_exists |= relation_exists(connection, relation).await?;
|
owned_exists |= relation_exists(connection, relation).await?;
|
||||||
}
|
}
|
||||||
if owned_exists {
|
if owned_exists {
|
||||||
|
if is_ledgerless_legacy_baseline(connection, canonical_exists).await? {
|
||||||
|
return Ok(MigrationPreflight::MigrationRequired {
|
||||||
|
current: 0,
|
||||||
|
target: CURRENT_VERSION,
|
||||||
|
});
|
||||||
|
}
|
||||||
return Err(MigrationError::new(
|
return Err(MigrationError::new(
|
||||||
"partial_sequence",
|
"partial_sequence",
|
||||||
"preflight.core_missing",
|
"preflight.core_missing",
|
||||||
@@ -541,6 +563,47 @@ async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, Mi
|
|||||||
Ok(MigrationPreflight::Current { version: current })
|
Ok(MigrationPreflight::Current { version: current })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async fn is_ledgerless_legacy_baseline(
|
||||||
|
connection: &mut PgConnection,
|
||||||
|
canonical_exists: bool,
|
||||||
|
) -> Result<bool, MigrationError> {
|
||||||
|
if canonical_exists {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
for relation in [
|
||||||
|
"__crank_migration_legacy_audit",
|
||||||
|
"master_key_identities",
|
||||||
|
"master_key_rotations",
|
||||||
|
"admin_bootstrap_contracts",
|
||||||
|
"admin_login_backoff",
|
||||||
|
"admin_security_audit_events",
|
||||||
|
"product_events",
|
||||||
|
"product_event_daily_rollups",
|
||||||
|
"onboarding_selections",
|
||||||
|
"artifact_blobs",
|
||||||
|
"artifact_sources",
|
||||||
|
] {
|
||||||
|
if relation_exists(connection, relation).await? {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for relation in owned_relations::BASELINE {
|
||||||
|
if !relation_exists(connection, relation).await? {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validate_required_relations(connection, owned_relations::BASELINE, 1).await?;
|
||||||
|
validate_ledgerless_baseline_fingerprint(connection).await?;
|
||||||
|
let mcp_ledger = relation_exists(connection, "__crank_mcp_migrations").await?;
|
||||||
|
if mcp_ledger {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let mcp_sessions = relation_exists(connection, "mcp_transport_sessions").await?;
|
||||||
|
let extension_ledger = relation_exists(connection, "__crank_ext_migrations").await?;
|
||||||
|
validate_ledgerless_optional_fingerprints(connection, mcp_sessions, extension_ledger).await?;
|
||||||
|
inspect_optional_legacy_for_ledgerless_baseline(connection).await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
async fn validate_core_ledger(connection: &mut PgConnection) -> Result<(), MigrationError> {
|
async fn validate_core_ledger(connection: &mut PgConnection) -> Result<(), MigrationError> {
|
||||||
let rows = query("select version, description, checksum from __crank_core_migrations order by version limit 2")
|
let rows = query("select version, description, checksum from __crank_core_migrations order by version limit 2")
|
||||||
.fetch_all(connection)
|
.fetch_all(connection)
|
||||||
@@ -577,9 +640,21 @@ async fn validate_core_ledger(connection: &mut PgConnection) -> Result<(), Migra
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn inspect_optional_legacy(connection: &mut PgConnection) -> Result<(), MigrationError> {
|
async fn inspect_optional_legacy(connection: &mut PgConnection) -> Result<(), MigrationError> {
|
||||||
|
inspect_optional_legacy_with_policy(connection, false).await
|
||||||
|
}
|
||||||
|
async fn inspect_optional_legacy_for_ledgerless_baseline(
|
||||||
|
connection: &mut PgConnection,
|
||||||
|
) -> Result<(), MigrationError> {
|
||||||
|
inspect_optional_legacy_with_policy(connection, true).await
|
||||||
|
}
|
||||||
|
async fn inspect_optional_legacy_with_policy(
|
||||||
|
connection: &mut PgConnection,
|
||||||
|
allow_sessions_without_ledger: bool,
|
||||||
|
) -> Result<(), MigrationError> {
|
||||||
let mcp_ledger = relation_exists(connection, "__crank_mcp_migrations").await?;
|
let mcp_ledger = relation_exists(connection, "__crank_mcp_migrations").await?;
|
||||||
let mcp_sessions = relation_exists(connection, "mcp_transport_sessions").await?;
|
let mcp_sessions = relation_exists(connection, "mcp_transport_sessions").await?;
|
||||||
if mcp_ledger != mcp_sessions {
|
if mcp_ledger != mcp_sessions && !(allow_sessions_without_ledger && !mcp_ledger && mcp_sessions)
|
||||||
|
{
|
||||||
return Err(MigrationError::new(
|
return Err(MigrationError::new(
|
||||||
"legacy_conflict",
|
"legacy_conflict",
|
||||||
"preflight.legacy_mcp",
|
"preflight.legacy_mcp",
|
||||||
|
|||||||
@@ -325,6 +325,15 @@ pub(super) async fn validate_schema_fingerprint(
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|row| row.try_get::<String, _>("column_name").ok())
|
.filter_map(|row| row.try_get::<String, _>("column_name").ok())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
let required = required
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|column| {
|
||||||
|
!(current_version == 1
|
||||||
|
&& *table == "__crank_ext_migrations"
|
||||||
|
&& *column == "checksum")
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
if actual.len() != required.len()
|
if actual.len() != required.len()
|
||||||
|| required
|
|| required
|
||||||
.iter()
|
.iter()
|
||||||
@@ -337,6 +346,9 @@ pub(super) async fn validate_schema_fingerprint(
|
|||||||
if !relation_exists(connection, table).await? {
|
if !relation_exists(connection, table).await? {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if current_version == 1 && *table == "__crank_ext_migrations" && *column == "checksum" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let row = query(
|
let row = query(
|
||||||
"select data_type, is_nullable from information_schema.columns
|
"select data_type, is_nullable from information_schema.columns
|
||||||
where table_schema = current_schema() and table_name = $1 and column_name = $2",
|
where table_schema = current_schema() and table_name = $1 and column_name = $2",
|
||||||
@@ -424,6 +436,7 @@ pub(super) async fn validate_schema_fingerprint(
|
|||||||
return Err(schema_error(current_version));
|
return Err(schema_error(current_version));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if relation_exists(connection, "mcp_transport_sessions").await? {
|
||||||
let required_indexes = [
|
let required_indexes = [
|
||||||
"mcp_transport_sessions_workspace_agent_idx",
|
"mcp_transport_sessions_workspace_agent_idx",
|
||||||
"mcp_transport_sessions_expires_at_idx",
|
"mcp_transport_sessions_expires_at_idx",
|
||||||
@@ -443,6 +456,7 @@ pub(super) async fn validate_schema_fingerprint(
|
|||||||
return Err(schema_error(current_version));
|
return Err(schema_error(current_version));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if current_version >= 3 {
|
if current_version >= 3 {
|
||||||
validate_index(
|
validate_index(
|
||||||
connection,
|
connection,
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use sqlx::PgConnection;
|
||||||
|
|
||||||
|
use super::authority::MigrationError;
|
||||||
|
|
||||||
|
// Exact PostgreSQL 16 catalog contract produced by the last published
|
||||||
|
// pre-ledger Community schema (commit 8318e4b).
|
||||||
|
const LEDGERLESS_BASELINE_FINGERPRINT_SHA256: &str =
|
||||||
|
"0dbd7357a80c0d772de03ea2833013932697187b32fbd7b54be594e224e5f82f";
|
||||||
|
const LEDGERLESS_MCP_FINGERPRINTS_SHA256: &[&str] = &[
|
||||||
|
// Initial published session table.
|
||||||
|
"53b58899dc388609cbd83fe8321a48878d610952a39d13b5fc8f05149b703683",
|
||||||
|
// Published session table after supports_elicitation was added.
|
||||||
|
"250e3e57f02283e9300bdff549305d49a6a875717ec4e4c332a2b9af3f883f09",
|
||||||
|
// Same published contract after an in-place upgrade from the initial layout.
|
||||||
|
"23c02db67cf83a3834b1ed7caa417eaafd7bcc69657d598ba831f06aeded4a62",
|
||||||
|
];
|
||||||
|
const LEDGERLESS_EXTENSION_FINGERPRINT_SHA256: &str =
|
||||||
|
"da06947028e3c18cc6ba93a58775722b425fd9989095f069316a146a2f45e921";
|
||||||
|
|
||||||
|
const BASELINE_RELATIONS: &[&str] = &[
|
||||||
|
"workspaces",
|
||||||
|
"users",
|
||||||
|
"memberships",
|
||||||
|
"user_sessions",
|
||||||
|
"invitation_tokens",
|
||||||
|
"platform_api_keys",
|
||||||
|
"operations",
|
||||||
|
"operation_versions",
|
||||||
|
"published_operations",
|
||||||
|
"operation_samples",
|
||||||
|
"descriptors",
|
||||||
|
"agents",
|
||||||
|
"agent_versions",
|
||||||
|
"published_agents",
|
||||||
|
"agent_operation_bindings",
|
||||||
|
"secrets",
|
||||||
|
"secret_versions",
|
||||||
|
"auth_profiles",
|
||||||
|
"workspace_upstreams",
|
||||||
|
"yaml_import_jobs",
|
||||||
|
"import_jobs",
|
||||||
|
"approval_requests",
|
||||||
|
"invocation_logs",
|
||||||
|
"usage_rollups",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub(super) async fn validate_ledgerless_baseline_fingerprint(
|
||||||
|
connection: &mut PgConnection,
|
||||||
|
) -> Result<(), MigrationError> {
|
||||||
|
let actual = catalog_fingerprint(connection, BASELINE_RELATIONS).await?;
|
||||||
|
if actual == LEDGERLESS_BASELINE_FINGERPRINT_SHA256 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(fingerprint_error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn validate_ledgerless_optional_fingerprints(
|
||||||
|
connection: &mut PgConnection,
|
||||||
|
has_mcp_sessions: bool,
|
||||||
|
has_extension_ledger: bool,
|
||||||
|
) -> Result<(), MigrationError> {
|
||||||
|
if has_mcp_sessions {
|
||||||
|
let actual = catalog_fingerprint(connection, &["mcp_transport_sessions"]).await?;
|
||||||
|
if !LEDGERLESS_MCP_FINGERPRINTS_SHA256.contains(&actual.as_str()) {
|
||||||
|
return Err(fingerprint_error());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has_extension_ledger {
|
||||||
|
let actual = catalog_fingerprint(connection, &["__crank_ext_migrations"]).await?;
|
||||||
|
if actual != LEDGERLESS_EXTENSION_FINGERPRINT_SHA256 {
|
||||||
|
return Err(fingerprint_error());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn catalog_fingerprint(
|
||||||
|
connection: &mut PgConnection,
|
||||||
|
relations: &[&str],
|
||||||
|
) -> Result<String, MigrationError> {
|
||||||
|
let relations = relations
|
||||||
|
.iter()
|
||||||
|
.map(|value| (*value).to_owned())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let unsafe_catalog_state = sqlx::query_scalar::<_, bool>(
|
||||||
|
"with selected(table_name) as (select unnest($1::text[]))
|
||||||
|
select
|
||||||
|
exists (
|
||||||
|
select 1 from pg_catalog.pg_class c
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
||||||
|
join selected s on s.table_name = c.relname
|
||||||
|
where n.nspname = current_schema()
|
||||||
|
and (c.relpersistence <> 'p' or c.relrowsecurity
|
||||||
|
or c.relforcerowsecurity or c.relreplident <> 'd')
|
||||||
|
)
|
||||||
|
or exists (
|
||||||
|
select 1 from pg_catalog.pg_index i
|
||||||
|
join pg_catalog.pg_class t on t.oid = i.indrelid
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||||
|
join selected s on s.table_name = t.relname
|
||||||
|
where n.nspname = current_schema()
|
||||||
|
and (not i.indisvalid or not i.indisready or not i.indislive)
|
||||||
|
)
|
||||||
|
or exists (
|
||||||
|
select 1 from pg_catalog.pg_policy p
|
||||||
|
join pg_catalog.pg_class t on t.oid = p.polrelid
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||||
|
join selected s on s.table_name = t.relname
|
||||||
|
where n.nspname = current_schema()
|
||||||
|
)
|
||||||
|
or exists (
|
||||||
|
select 1 from pg_catalog.pg_trigger tg
|
||||||
|
join pg_catalog.pg_class t on t.oid = tg.tgrelid
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||||
|
join selected s on s.table_name = t.relname
|
||||||
|
where n.nspname = current_schema() and tg.tgenabled <> 'O'
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(relations.clone())
|
||||||
|
.fetch_one(&mut *connection)
|
||||||
|
.await
|
||||||
|
.map_err(|_| MigrationError::storage("preflight.legacy_fingerprint"))?;
|
||||||
|
if unsafe_catalog_state {
|
||||||
|
return Err(fingerprint_error());
|
||||||
|
}
|
||||||
|
let fingerprint = sqlx::query_scalar::<_, String>(
|
||||||
|
"with baseline(table_name) as (select unnest($1::text[])), relation_rows as (
|
||||||
|
select jsonb_build_array('relation', c.relname, c.relkind::text) item
|
||||||
|
from pg_catalog.pg_class c
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
||||||
|
join baseline b on b.table_name = c.relname
|
||||||
|
where n.nspname = current_schema()
|
||||||
|
), column_rows as (
|
||||||
|
select jsonb_build_array(
|
||||||
|
'column', c.table_name, c.ordinal_position, c.column_name,
|
||||||
|
c.data_type, c.udt_name, c.is_nullable, coalesce(c.column_default, '')
|
||||||
|
) item
|
||||||
|
from information_schema.columns c
|
||||||
|
join baseline b using (table_name)
|
||||||
|
where c.table_schema = current_schema()
|
||||||
|
), constraint_rows as (
|
||||||
|
select jsonb_build_array(
|
||||||
|
'constraint', t.relname, c.conname, c.contype::text,
|
||||||
|
c.convalidated, pg_get_constraintdef(c.oid, true)
|
||||||
|
) item
|
||||||
|
from pg_catalog.pg_constraint c
|
||||||
|
join pg_catalog.pg_class t on t.oid = c.conrelid
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||||
|
join baseline b on b.table_name = t.relname
|
||||||
|
where n.nspname = current_schema()
|
||||||
|
), index_rows as (
|
||||||
|
select jsonb_build_array(
|
||||||
|
'index', t.relname, idx.relname,
|
||||||
|
replace(pg_get_indexdef(i.indexrelid), format('%I.', current_schema()), '')
|
||||||
|
) item
|
||||||
|
from pg_catalog.pg_index i
|
||||||
|
join pg_catalog.pg_class idx on idx.oid = i.indexrelid
|
||||||
|
join pg_catalog.pg_class t on t.oid = i.indrelid
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||||
|
join baseline b on b.table_name = t.relname
|
||||||
|
where n.nspname = current_schema()
|
||||||
|
), trigger_rows as (
|
||||||
|
select jsonb_build_array(
|
||||||
|
'trigger', t.relname, tg.tgname,
|
||||||
|
replace(pg_get_triggerdef(tg.oid, true), format('%I.', current_schema()), '')
|
||||||
|
) item
|
||||||
|
from pg_catalog.pg_trigger tg
|
||||||
|
join pg_catalog.pg_class t on t.oid = tg.tgrelid
|
||||||
|
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||||
|
join baseline b on b.table_name = t.relname
|
||||||
|
where n.nspname = current_schema() and not tg.tgisinternal
|
||||||
|
), all_rows as (
|
||||||
|
select item from relation_rows
|
||||||
|
union all select item from column_rows
|
||||||
|
union all select item from constraint_rows
|
||||||
|
union all select item from index_rows
|
||||||
|
union all select item from trigger_rows
|
||||||
|
)
|
||||||
|
select coalesce(jsonb_agg(item order by item::text), '[]'::jsonb)::text
|
||||||
|
from all_rows",
|
||||||
|
)
|
||||||
|
.bind(relations)
|
||||||
|
.fetch_one(connection)
|
||||||
|
.await
|
||||||
|
.map_err(|_| MigrationError::storage("preflight.legacy_fingerprint"))?;
|
||||||
|
Ok(format!("{:x}", Sha256::digest(fingerprint.as_bytes())))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fingerprint_error() -> MigrationError {
|
||||||
|
MigrationError::new(
|
||||||
|
"partial_sequence",
|
||||||
|
"preflight.legacy_fingerprint",
|
||||||
|
Some(1),
|
||||||
|
"restore_known_good_backup",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ use crank_registry::{MigrationAuthority, MigrationPreflight, PostgresRegistry};
|
|||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
|
||||||
mod artifact_metadata;
|
mod artifact_metadata;
|
||||||
|
mod legacy_adoption;
|
||||||
mod rollback;
|
mod rollback;
|
||||||
|
|
||||||
static EVENT_TRIGGER_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
static EVENT_TRIGGER_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||||
@@ -153,7 +154,7 @@ async fn changed_checksum_fails_closed_without_repair() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn legacy_core_baseline_is_consolidated_without_data_loss() {
|
async fn ledgerless_legacy_baseline_is_consolidated_without_data_loss() {
|
||||||
let database_url = crank_test_support::postgres_schema_url("test_legacy_core").await;
|
let database_url = crank_test_support::postgres_schema_url("test_legacy_core").await;
|
||||||
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||||
MigrationAuthority::apply(&pool).await.unwrap();
|
MigrationAuthority::apply(&pool).await.unwrap();
|
||||||
@@ -221,7 +222,8 @@ async fn legacy_core_baseline_is_consolidated_without_data_loss() {
|
|||||||
}
|
}
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"drop table __crank_migrations, __crank_migration_legacy_audit,
|
"drop table __crank_migrations, __crank_migration_legacy_audit,
|
||||||
__crank_mcp_migrations, mcp_transport_sessions, __crank_ext_migrations",
|
__crank_mcp_migrations, mcp_transport_sessions, __crank_ext_migrations,
|
||||||
|
__crank_core_migrations",
|
||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
@@ -229,7 +231,7 @@ async fn legacy_core_baseline_is_consolidated_without_data_loss() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||||
MigrationPreflight::MigrationRequired {
|
MigrationPreflight::MigrationRequired {
|
||||||
current: 1,
|
current: 0,
|
||||||
target: 13,
|
target: 13,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
async fn ledgerless_v1(pool: &sqlx::PgPool) {
|
||||||
|
MigrationAuthority::apply(pool).await.unwrap();
|
||||||
|
remove_v4_schema(pool).await;
|
||||||
|
remove_v3_schema(pool).await;
|
||||||
|
sqlx::raw_sql(
|
||||||
|
"drop table __crank_migrations, __crank_migration_legacy_audit;
|
||||||
|
drop table __crank_mcp_migrations;
|
||||||
|
drop index mcp_transport_sessions_expires_at_idx;
|
||||||
|
drop table __crank_ext_migrations;
|
||||||
|
create table __crank_ext_migrations (
|
||||||
|
extension_name text not null,
|
||||||
|
version integer not null,
|
||||||
|
applied_at timestamptz not null default now(),
|
||||||
|
primary key (extension_name, version)
|
||||||
|
);
|
||||||
|
drop table __crank_core_migrations;",
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ledgerless_baseline_with_optional_index_drift_is_rejected_without_writes() {
|
||||||
|
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_index").await;
|
||||||
|
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||||
|
ledgerless_v1(&pool).await;
|
||||||
|
sqlx::raw_sql(
|
||||||
|
"drop index mcp_transport_sessions_workspace_agent_idx;
|
||||||
|
create index mcp_transport_sessions_workspace_agent_idx
|
||||||
|
on mcp_transport_sessions(id);",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
|
||||||
|
assert_eq!(error.code(), "partial_sequence");
|
||||||
|
assert_eq!(error.stage(), "preflight.legacy_fingerprint");
|
||||||
|
let core_exists: bool = sqlx::query_scalar(
|
||||||
|
"select to_regclass(format('%I.%I', current_schema(), '__crank_core_migrations')) is not null",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!core_exists, "rejected adoption must remain read-only");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ledgerless_baseline_with_disabled_integrity_triggers_is_rejected_without_writes() {
|
||||||
|
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_triggers").await;
|
||||||
|
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||||
|
ledgerless_v1(&pool).await;
|
||||||
|
sqlx::query("alter table memberships disable trigger all")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
|
||||||
|
assert_eq!(error.code(), "partial_sequence");
|
||||||
|
assert_eq!(error.stage(), "preflight.legacy_fingerprint");
|
||||||
|
let core_exists: bool = sqlx::query_scalar(
|
||||||
|
"select to_regclass(format('%I.%I', current_schema(), '__crank_core_migrations')) is not null",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!core_exists, "rejected adoption must remain read-only");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn published_ledgerless_baseline_upgrades_without_data_loss() {
|
||||||
|
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_baseline").await;
|
||||||
|
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||||
|
ledgerless_v1(&pool).await;
|
||||||
|
sqlx::query(
|
||||||
|
"insert into operations
|
||||||
|
(id, workspace_id, name, display_name, protocol, status, created_at, updated_at)
|
||||||
|
values ('op_ledgerless', 'ws_default', 'ledgerless', 'Ledgerless', 'rest', 'draft', now(), now())",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||||
|
MigrationPreflight::MigrationRequired {
|
||||||
|
current: 0,
|
||||||
|
target: 13,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
MigrationAuthority::apply(&pool).await.unwrap();
|
||||||
|
|
||||||
|
let operation_count: i64 =
|
||||||
|
sqlx::query_scalar("select count(*) from operations where id = 'op_ledgerless'")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(operation_count, 1);
|
||||||
|
assert_eq!(
|
||||||
|
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||||
|
MigrationPreflight::Current { version: 13 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn published_in_place_mcp_upgrade_layout_is_accepted() {
|
||||||
|
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_mcp_upgrade").await;
|
||||||
|
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||||
|
ledgerless_v1(&pool).await;
|
||||||
|
sqlx::raw_sql(
|
||||||
|
"drop table mcp_transport_sessions;
|
||||||
|
create table mcp_transport_sessions (
|
||||||
|
id text primary key,
|
||||||
|
protocol_version text not null,
|
||||||
|
initialized boolean not null default false,
|
||||||
|
workspace_slug text not null,
|
||||||
|
agent_slug text not null,
|
||||||
|
created_at timestamptz not null,
|
||||||
|
updated_at timestamptz not null,
|
||||||
|
expires_at timestamptz null
|
||||||
|
);
|
||||||
|
create index mcp_transport_sessions_workspace_agent_idx
|
||||||
|
on mcp_transport_sessions(workspace_slug, agent_slug, updated_at desc);
|
||||||
|
alter table mcp_transport_sessions
|
||||||
|
add column supports_elicitation boolean not null default false;",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||||
|
MigrationPreflight::MigrationRequired {
|
||||||
|
current: 0,
|
||||||
|
target: 13,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
MigrationAuthority::apply(&pool).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||||
|
MigrationPreflight::Current { version: 13 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ledgerless_baseline_with_future_drift_is_rejected_without_writes() {
|
||||||
|
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_drift").await;
|
||||||
|
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||||
|
ledgerless_v1(&pool).await;
|
||||||
|
sqlx::query("alter table invocation_logs add column trace_id text")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
|
||||||
|
assert_eq!(error.code(), "partial_sequence");
|
||||||
|
assert_eq!(error.stage(), "preflight.legacy_fingerprint");
|
||||||
|
let core_exists: bool = sqlx::query_scalar(
|
||||||
|
"select to_regclass(format('%I.%I', current_schema(), '__crank_core_migrations')) is not null",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!core_exists, "rejected adoption must remain read-only");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ledgerless_baseline_with_constraint_drift_is_rejected_without_writes() {
|
||||||
|
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_constraint").await;
|
||||||
|
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||||
|
ledgerless_v1(&pool).await;
|
||||||
|
sqlx::query("alter table workspaces alter column status drop not null")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
|
||||||
|
assert_eq!(error.code(), "partial_sequence");
|
||||||
|
assert_eq!(error.stage(), "preflight.legacy_fingerprint");
|
||||||
|
let core_exists: bool = sqlx::query_scalar(
|
||||||
|
"select to_regclass(format('%I.%I', current_schema(), '__crank_core_migrations')) is not null",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!core_exists, "rejected adoption must remain read-only");
|
||||||
|
}
|
||||||
@@ -146,6 +146,76 @@ create_backup() {
|
|||||||
| sort -nr | awk 'NR > 5 { print $2 }' | xargs -r rm -rf
|
| sort -nr | awk 'NR > 5 { print $2 }' | xargs -r rm -rf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validate_backup_migration() {
|
||||||
|
admin_image="$(env_value CRANK_ADMIN_API_IMAGE)"
|
||||||
|
if [ -z "$admin_image" ]; then
|
||||||
|
echo "CRANK_ADMIN_API_IMAGE is required for shadow migration validation" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
shadow_id="crank-migration-check-$$"
|
||||||
|
shadow_network="${shadow_id}-network"
|
||||||
|
shadow_postgres="${shadow_id}-postgres"
|
||||||
|
shadow_password="crank-shadow-migration-password"
|
||||||
|
shadow_admin="crank_shadow_admin"
|
||||||
|
shadow_user="crank_shadow_owner"
|
||||||
|
shadow_cleanup() {
|
||||||
|
docker rm -fv "$shadow_postgres" >/dev/null 2>&1 || true
|
||||||
|
docker network rm "$shadow_network" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap shadow_cleanup EXIT
|
||||||
|
trap 'exit 130' HUP INT TERM
|
||||||
|
|
||||||
|
docker network create "$shadow_network" >/dev/null
|
||||||
|
docker run -d --name "$shadow_postgres" --network "$shadow_network" \
|
||||||
|
-e POSTGRES_USER="$shadow_admin" \
|
||||||
|
-e POSTGRES_PASSWORD="$shadow_password" \
|
||||||
|
-e POSTGRES_DB=crank \
|
||||||
|
postgres:16-alpine >/dev/null
|
||||||
|
|
||||||
|
shadow_ready=false
|
||||||
|
attempt=1
|
||||||
|
while [ "$attempt" -le 30 ]; do
|
||||||
|
if docker logs "$shadow_postgres" 2>&1 | grep -q 'PostgreSQL init process complete' \
|
||||||
|
&& docker exec "$shadow_postgres" pg_isready --username "$shadow_admin" --dbname crank >/dev/null 2>&1; then
|
||||||
|
shadow_ready=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
done
|
||||||
|
if [ "$shadow_ready" != true ]; then
|
||||||
|
echo "Shadow PostgreSQL did not become ready" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker exec "$shadow_postgres" psql --username "$shadow_admin" --dbname crank \
|
||||||
|
--set ON_ERROR_STOP=1 \
|
||||||
|
--command "create role ${shadow_user} login password '${shadow_password}' nosuperuser nocreatedb nocreaterole noreplication" \
|
||||||
|
--command "alter database crank owner to ${shadow_user}"
|
||||||
|
|
||||||
|
timeout 10m docker run --rm --network "$shadow_network" \
|
||||||
|
-e PGPASSWORD="$shadow_password" \
|
||||||
|
-v "$backup_dir:/backup:ro" \
|
||||||
|
postgres:16-alpine \
|
||||||
|
pg_restore --host "$shadow_postgres" --username "$shadow_user" --dbname crank \
|
||||||
|
--no-owner --no-privileges --single-transaction --exit-on-error \
|
||||||
|
/backup/postgres.dump
|
||||||
|
|
||||||
|
shadow_database_url="postgres://${shadow_user}:${shadow_password}@${shadow_postgres}:5432/crank"
|
||||||
|
timeout 10m docker run --rm --network "$shadow_network" \
|
||||||
|
-e CRANK_DATABASE_URL="$shadow_database_url" \
|
||||||
|
"$admin_image" crank-migrate apply
|
||||||
|
shadow_preflight="$(timeout 2m docker run --rm --network "$shadow_network" \
|
||||||
|
-e CRANK_DATABASE_URL="$shadow_database_url" \
|
||||||
|
"$admin_image" crank-migrate preflight)"
|
||||||
|
printf '%s\n' "$shadow_preflight" | grep -Fxq '{"status":"current","version":13}'
|
||||||
|
|
||||||
|
shadow_cleanup
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
echo "Shadow migration validation passed"
|
||||||
|
}
|
||||||
|
|
||||||
rollback() {
|
rollback() {
|
||||||
echo "New release failed readiness; restoring previous deployment" >&2
|
echo "New release failed readiness; restoring previous deployment" >&2
|
||||||
if [ ! -f .env.previous ] || [ ! -f docker-compose.previous.yml ]; then
|
if [ ! -f .env.previous ] || [ ! -f docker-compose.previous.yml ]; then
|
||||||
@@ -168,6 +238,7 @@ rollback() {
|
|||||||
compose config -q
|
compose config -q
|
||||||
create_backup
|
create_backup
|
||||||
compose pull
|
compose pull
|
||||||
|
validate_backup_migration
|
||||||
|
|
||||||
if ! compose_up || ! wait_for_stack ready; then
|
if ! compose_up || ! wait_for_stack ready; then
|
||||||
show_failure_diagnostics
|
show_failure_diagnostics
|
||||||
|
|||||||
Reference in New Issue
Block a user