diff --git a/crates/crank-registry/src/migrations.rs b/crates/crank-registry/src/migrations.rs index b622829..9973ba2 100644 --- a/crates/crank-registry/src/migrations.rs +++ b/crates/crank-registry/src/migrations.rs @@ -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; diff --git a/crates/crank-registry/src/migrations/authority.rs b/crates/crank-registry/src/migrations/authority.rs index 873d428..a9fdf7f 100644 --- a/crates/crank-registry/src/migrations/authority.rs +++ b/crates/crank-registry/src/migrations/authority.rs @@ -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 Result Result { + 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", diff --git a/crates/crank-registry/src/migrations/schema_guard.rs b/crates/crank-registry/src/migrations/schema_guard.rs index 5d7b69e..2dd3164 100644 --- a/crates/crank-registry/src/migrations/schema_guard.rs +++ b/crates/crank-registry/src/migrations/schema_guard.rs @@ -325,6 +325,15 @@ pub(super) async fn validate_schema_fingerprint( .iter() .filter_map(|row| row.try_get::("column_name").ok()) .collect::>(); + let required = required + .iter() + .copied() + .filter(|column| { + !(current_version == 1 + && *table == "__crank_ext_migrations" + && *column == "checksum") + }) + .collect::>(); 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::("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::("present") + .map_err(|_| MigrationError::storage("preflight.schema"))?; + if !present { + return Err(schema_error(current_version)); + } } } if current_version >= 3 { diff --git a/crates/crank-registry/src/migrations/schema_guard_legacy_v1.rs b/crates/crank-registry/src/migrations/schema_guard_legacy_v1.rs new file mode 100644 index 0000000..2e0d8b7 --- /dev/null +++ b/crates/crank-registry/src/migrations/schema_guard_legacy_v1.rs @@ -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 { + let relations = relations + .iter() + .map(|value| (*value).to_owned()) + .collect::>(); + 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", + ) +} diff --git a/crates/crank-registry/tests/integration/migrations.rs b/crates/crank-registry/tests/integration/migrations.rs index 866bfa0..00a38eb 100644 --- a/crates/crank-registry/tests/integration/migrations.rs +++ b/crates/crank-registry/tests/integration/migrations.rs @@ -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, } ); diff --git a/crates/crank-registry/tests/integration/migrations/legacy_adoption.rs b/crates/crank-registry/tests/integration/migrations/legacy_adoption.rs new file mode 100644 index 0000000..d1bc972 --- /dev/null +++ b/crates/crank-registry/tests/integration/migrations/legacy_adoption.rs @@ -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"); +} diff --git a/scripts/deploy-community.sh b/scripts/deploy-community.sh index 3bf31ac..dd477f0 100755 --- a/scripts/deploy-community.sh +++ b/scripts/deploy-community.sh @@ -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