fix(migrations): adopt published ledgerless baseline
This commit is contained in:
@@ -11,6 +11,7 @@ mod onboarding_product_events_v11;
|
||||
mod owned_relations;
|
||||
mod platform_key_name_reuse_v6;
|
||||
mod schema_guard;
|
||||
mod schema_guard_legacy_v1;
|
||||
mod schema_guard_v10;
|
||||
mod schema_guard_v11;
|
||||
mod schema_guard_v12;
|
||||
|
||||
@@ -11,6 +11,9 @@ use super::platform_key_name_reuse_v6;
|
||||
use super::schema_guard::{
|
||||
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 crate::ext::ExtensionMigration;
|
||||
use sqlx::{PgConnection, PgPool, Row, Transaction, query};
|
||||
@@ -389,6 +392,19 @@ impl MigrationAuthority {
|
||||
if from < 13 {
|
||||
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
|
||||
.commit()
|
||||
.await
|
||||
@@ -408,6 +424,12 @@ async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, Mi
|
||||
owned_exists |= relation_exists(connection, relation).await?;
|
||||
}
|
||||
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(
|
||||
"partial_sequence",
|
||||
"preflight.core_missing",
|
||||
@@ -541,6 +563,47 @@ async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, Mi
|
||||
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> {
|
||||
let rows = query("select version, description, checksum from __crank_core_migrations order by version limit 2")
|
||||
.fetch_all(connection)
|
||||
@@ -577,9 +640,21 @@ async fn validate_core_ledger(connection: &mut PgConnection) -> Result<(), Migra
|
||||
Ok(())
|
||||
}
|
||||
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_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(
|
||||
"legacy_conflict",
|
||||
"preflight.legacy_mcp",
|
||||
|
||||
@@ -325,6 +325,15 @@ pub(super) async fn validate_schema_fingerprint(
|
||||
.iter()
|
||||
.filter_map(|row| row.try_get::<String, _>("column_name").ok())
|
||||
.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()
|
||||
|| required
|
||||
.iter()
|
||||
@@ -337,6 +346,9 @@ pub(super) async fn validate_schema_fingerprint(
|
||||
if !relation_exists(connection, table).await? {
|
||||
continue;
|
||||
}
|
||||
if current_version == 1 && *table == "__crank_ext_migrations" && *column == "checksum" {
|
||||
continue;
|
||||
}
|
||||
let row = query(
|
||||
"select data_type, is_nullable from information_schema.columns
|
||||
where table_schema = current_schema() and table_name = $1 and column_name = $2",
|
||||
@@ -424,23 +436,25 @@ pub(super) async fn validate_schema_fingerprint(
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
}
|
||||
let required_indexes = [
|
||||
"mcp_transport_sessions_workspace_agent_idx",
|
||||
"mcp_transport_sessions_expires_at_idx",
|
||||
];
|
||||
for index in required_indexes {
|
||||
let present = query(
|
||||
"select exists (select 1 from pg_catalog.pg_indexes
|
||||
where schemaname = current_schema() and indexname = $1) as present",
|
||||
)
|
||||
.bind(index)
|
||||
.fetch_one(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.try_get::<bool, _>("present")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
if !present {
|
||||
return Err(schema_error(current_version));
|
||||
if relation_exists(connection, "mcp_transport_sessions").await? {
|
||||
let required_indexes = [
|
||||
"mcp_transport_sessions_workspace_agent_idx",
|
||||
"mcp_transport_sessions_expires_at_idx",
|
||||
];
|
||||
for index in required_indexes {
|
||||
let present = query(
|
||||
"select exists (select 1 from pg_catalog.pg_indexes
|
||||
where schemaname = current_schema() and indexname = $1) as present",
|
||||
)
|
||||
.bind(index)
|
||||
.fetch_one(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.try_get::<bool, _>("present")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
if !present {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
}
|
||||
}
|
||||
if current_version >= 3 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
mod artifact_metadata;
|
||||
mod legacy_adoption;
|
||||
mod rollback;
|
||||
|
||||
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]
|
||||
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 pool = sqlx::PgPool::connect(&database_url).await.unwrap();
|
||||
MigrationAuthority::apply(&pool).await.unwrap();
|
||||
@@ -221,7 +222,8 @@ async fn legacy_core_baseline_is_consolidated_without_data_loss() {
|
||||
}
|
||||
sqlx::query(
|
||||
"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)
|
||||
.await
|
||||
@@ -229,7 +231,7 @@ async fn legacy_core_baseline_is_consolidated_without_data_loss() {
|
||||
assert_eq!(
|
||||
MigrationAuthority::preflight(&pool).await.unwrap(),
|
||||
MigrationPreflight::MigrationRequired {
|
||||
current: 1,
|
||||
current: 0,
|
||||
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
|
||||
}
|
||||
|
||||
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() {
|
||||
echo "New release failed readiness; restoring previous deployment" >&2
|
||||
if [ ! -f .env.previous ] || [ ! -f docker-compose.previous.yml ]; then
|
||||
@@ -168,6 +238,7 @@ rollback() {
|
||||
compose config -q
|
||||
create_backup
|
||||
compose pull
|
||||
validate_backup_migration
|
||||
|
||||
if ! compose_up || ! wait_for_stack ready; then
|
||||
show_failure_diagnostics
|
||||
|
||||
Reference in New Issue
Block a user