fix(migrations): adopt published ledgerless baseline
CI / Rust Checks (push) Successful in 13m55s
CI / UI Checks (push) Successful in 5s
CI / Community Image Smoke (push) Successful in 6m14s
CI / Frontend E2E (push) Successful in 7m11s
CI / Deploy (push) Failing after 44s

This commit is contained in:
2026-09-01 23:01:33 +03:00
parent 527efb510f
commit 2759fde81f
7 changed files with 572 additions and 21 deletions
+1
View File
@@ -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,23 +436,25 @@ pub(super) async fn validate_schema_fingerprint(
return Err(schema_error(current_version)); return Err(schema_error(current_version));
} }
} }
let required_indexes = [ if relation_exists(connection, "mcp_transport_sessions").await? {
"mcp_transport_sessions_workspace_agent_idx", let required_indexes = [
"mcp_transport_sessions_expires_at_idx", "mcp_transport_sessions_workspace_agent_idx",
]; "mcp_transport_sessions_expires_at_idx",
for index in required_indexes { ];
let present = query( for index in required_indexes {
"select exists (select 1 from pg_catalog.pg_indexes let present = query(
where schemaname = current_schema() and indexname = $1) as present", "select exists (select 1 from pg_catalog.pg_indexes
) where schemaname = current_schema() and indexname = $1) as present",
.bind(index) )
.fetch_one(&mut *connection) .bind(index)
.await .fetch_one(&mut *connection)
.map_err(|_| MigrationError::storage("preflight.schema"))? .await
.try_get::<bool, _>("present") .map_err(|_| MigrationError::storage("preflight.schema"))?
.map_err(|_| MigrationError::storage("preflight.schema"))?; .try_get::<bool, _>("present")
if !present { .map_err(|_| MigrationError::storage("preflight.schema"))?;
return Err(schema_error(current_version)); if !present {
return Err(schema_error(current_version));
}
} }
} }
if current_version >= 3 { 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; 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");
}
+71
View File
@@ -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