feat(registry): add workspace-scoped artifact metadata
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
use sqlx::{Postgres, Transaction, query};
|
||||
|
||||
use super::authority::{MigrationDescriptor, MigrationError};
|
||||
|
||||
pub(super) const SOURCE: &str = include_str!("artifact_metadata_v12.sql");
|
||||
pub(super) const SOURCE_SHA256: &str =
|
||||
"052058da53cd861b2a1af81243b34cc35204d665a9276f8ece563e061f8ebbfe";
|
||||
|
||||
pub(super) async fn apply(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
descriptor: &MigrationDescriptor,
|
||||
) -> Result<(), MigrationError> {
|
||||
sqlx::raw_sql(SOURCE)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.artifact_metadata",
|
||||
Some(12),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
query(
|
||||
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
|
||||
values ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(descriptor.version)
|
||||
.bind(descriptor.name)
|
||||
.bind(&descriptor.checksum)
|
||||
.bind(descriptor.phase)
|
||||
.bind(descriptor.compatibility)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.canonical_ledger",
|
||||
Some(12),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
create table artifact_blobs (
|
||||
digest text primary key,
|
||||
artifact_ref text not null unique,
|
||||
size_bytes bigint not null,
|
||||
storage_lifecycle text not null default 'available',
|
||||
claim_token text null,
|
||||
claim_expires_at timestamptz null,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null,
|
||||
constraint artifact_blobs_digest_check check (digest ~ '^[0-9a-f]{64}$'),
|
||||
constraint artifact_blobs_ref_check check (artifact_ref = 'sha256:' || digest),
|
||||
constraint artifact_blobs_size_check check (size_bytes between 1 and 262144),
|
||||
constraint artifact_blobs_lifecycle_check check (
|
||||
storage_lifecycle in ('available', 'unavailable')
|
||||
),
|
||||
constraint artifact_blobs_claim_token_check check (
|
||||
claim_token is null or octet_length(claim_token) between 1 and 128
|
||||
),
|
||||
constraint artifact_blobs_claim_shape_check check (
|
||||
(claim_token is null and claim_expires_at is null)
|
||||
or (claim_token is not null and claim_expires_at is not null)
|
||||
),
|
||||
constraint artifact_blobs_timestamps_check check (updated_at >= created_at)
|
||||
);
|
||||
|
||||
create table artifact_sources (
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
source_id text not null,
|
||||
blob_digest text not null references artifact_blobs(digest),
|
||||
mime_type text not null,
|
||||
sensitivity text not null,
|
||||
lifecycle text not null default 'active',
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null,
|
||||
detached_at timestamptz null,
|
||||
constraint artifact_sources_id_check check (
|
||||
source_id ~ '^src_[A-Za-z0-9_-]{1,128}$'
|
||||
),
|
||||
constraint artifact_sources_mime_type_check check (
|
||||
octet_length(mime_type) between 3 and 255
|
||||
and mime_type ~ '^[A-Za-z0-9!#$&^_.+-]+/[A-Za-z0-9!#$&^_.+-]+$'
|
||||
),
|
||||
constraint artifact_sources_sensitivity_check check (
|
||||
sensitivity in ('public', 'internal', 'secret')
|
||||
),
|
||||
constraint artifact_sources_lifecycle_check check (
|
||||
lifecycle in ('active', 'detached')
|
||||
),
|
||||
constraint artifact_sources_state_check check (
|
||||
(lifecycle = 'active' and detached_at is null)
|
||||
or (lifecycle = 'detached' and detached_at is not null)
|
||||
),
|
||||
constraint artifact_sources_timestamps_check check (
|
||||
updated_at >= created_at
|
||||
and (detached_at is null or (detached_at >= created_at and updated_at >= detached_at))
|
||||
),
|
||||
primary key (workspace_id, source_id)
|
||||
);
|
||||
|
||||
create index artifact_sources_workspace_created_idx
|
||||
on artifact_sources(workspace_id, created_at, source_id);
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::admin_auth_lifecycle_v8;
|
||||
use super::agent_catalog_lifecycle_v9;
|
||||
use super::approval_side_effects_v10;
|
||||
use super::artifact_metadata_v12;
|
||||
use super::execution_outcome_v5;
|
||||
use super::master_key_identity_v7;
|
||||
use super::onboarding_product_events_v11;
|
||||
@@ -11,12 +12,17 @@ use super::schema_guard::{
|
||||
};
|
||||
use super::{BASELINE_CHECKSUM, BASELINE_VERSION, apply_baseline};
|
||||
use crate::ext::ExtensionMigration;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{PgConnection, PgPool, Row, Transaction, query};
|
||||
use std::fmt;
|
||||
|
||||
mod contract;
|
||||
|
||||
#[cfg(test)]
|
||||
use contract::baseline_source_digest;
|
||||
use contract::validate_descriptors;
|
||||
const MIGRATION_LOCK_ID: i64 = 0x4352_414E_4B4D_4947;
|
||||
const CURRENT_VERSION: i64 = 11;
|
||||
const IMPLEMENTED_VERSIONS: &[i64] = &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||
const CURRENT_VERSION: i64 = 12;
|
||||
const IMPLEMENTED_VERSIONS: &[i64] = &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
||||
const BASELINE_SOURCE_SHA256: &str =
|
||||
"eb1656fc5b4b5be9ee390d237d1d58e4b2274ae5ba9b7ba06a2f3f860dfda675";
|
||||
const CONSOLIDATION_SOURCE: &str = include_str!("consolidation_v2.sql");
|
||||
@@ -242,6 +248,12 @@ impl MigrationAuthority {
|
||||
onboarding_product_events_v11::SOURCE_SHA256,
|
||||
10,
|
||||
),
|
||||
expand_descriptor(
|
||||
12,
|
||||
"artifact-metadata-v12",
|
||||
artifact_metadata_v12::SOURCE_SHA256,
|
||||
11,
|
||||
),
|
||||
]
|
||||
}
|
||||
pub fn validate_sequence() -> Result<(), MigrationError> {
|
||||
@@ -364,6 +376,9 @@ impl MigrationAuthority {
|
||||
if from < 11 {
|
||||
onboarding_product_events_v11::apply(&mut transaction, &Self::sequence()[10]).await?;
|
||||
}
|
||||
if from < 12 {
|
||||
artifact_metadata_v12::apply(&mut transaction, &Self::sequence()[11]).await?;
|
||||
}
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
@@ -374,134 +389,6 @@ impl MigrationAuthority {
|
||||
})
|
||||
}
|
||||
}
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn baseline_source_digest() -> String {
|
||||
let source = include_str!("baseline_v1.rs");
|
||||
let (_, baseline) = source
|
||||
.split_once("// baseline-v1:start\n")
|
||||
.expect("baseline start marker must exist");
|
||||
let (baseline, _) = baseline
|
||||
.split_once("// baseline-v1:end")
|
||||
.expect("baseline end marker must exist");
|
||||
sha256_hex(baseline.as_bytes())
|
||||
}
|
||||
fn validate_descriptors(descriptors: &[MigrationDescriptor]) -> Result<(), MigrationError> {
|
||||
if descriptors.is_empty() || descriptors.len() > 1_024 {
|
||||
return Err(MigrationError::new(
|
||||
"invalid_contract",
|
||||
"contract.sequence",
|
||||
None,
|
||||
"contact_operator",
|
||||
));
|
||||
}
|
||||
for (index, descriptor) in descriptors.iter().enumerate() {
|
||||
let expected_version = i64::try_from(index + 1).unwrap_or(i64::MAX);
|
||||
let valid_name = !descriptor.name.is_empty()
|
||||
&& descriptor.name.len() <= 128
|
||||
&& descriptor
|
||||
.name
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
&& !descriptors[..index]
|
||||
.iter()
|
||||
.any(|prior| prior.name == descriptor.name);
|
||||
let valid_source_digest = descriptor.source_digest.len() == 64
|
||||
&& descriptor
|
||||
.source_digest
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase());
|
||||
let valid_checksum = if descriptor.version == 1 {
|
||||
descriptor.checksum == BASELINE_CHECKSUM
|
||||
} else {
|
||||
descriptor.checksum == descriptor.source_digest
|
||||
};
|
||||
let valid_window = descriptor.readable_schema_min >= 1
|
||||
&& descriptor.readable_schema_min <= descriptor.readable_schema_max
|
||||
&& descriptor.readable_schema_max <= descriptor.version;
|
||||
let valid_phase = match descriptor.phase {
|
||||
"expand" => descriptor.backfill == BackfillPolicy::None,
|
||||
"migrate" => matches!(
|
||||
descriptor.backfill,
|
||||
BackfillPolicy::Bounded {
|
||||
max_batch_rows: 1..=10_000,
|
||||
max_batch_ms: 1..=60_000,
|
||||
resumable: true,
|
||||
}
|
||||
),
|
||||
"contract" => {
|
||||
descriptor.compatibility == "window-closed"
|
||||
&& descriptor.contract_evidence.is_some_and(|evidence| {
|
||||
!evidence.is_empty()
|
||||
&& evidence.len() <= 256
|
||||
&& !evidence.starts_with('/')
|
||||
&& !evidence.contains("..")
|
||||
})
|
||||
&& descriptor.backfill == BackfillPolicy::None
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let valid_compatibility = matches!(
|
||||
descriptor.compatibility,
|
||||
"legacy-baseline" | "n-minus-one-readable" | "window-open" | "window-closed"
|
||||
);
|
||||
let valid_owner = !descriptor.owner.is_empty()
|
||||
&& descriptor.owner.len() <= 128
|
||||
&& descriptor
|
||||
.owner
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
|
||||
if descriptor.version != expected_version
|
||||
|| !valid_name
|
||||
|| !valid_source_digest
|
||||
|| !valid_checksum
|
||||
|| !valid_phase
|
||||
|| !valid_compatibility
|
||||
|| !valid_owner
|
||||
|| !valid_window
|
||||
|| !descriptor.transactional
|
||||
{
|
||||
return Err(MigrationError::new(
|
||||
"invalid_contract",
|
||||
"contract.sequence",
|
||||
Some(descriptor.version),
|
||||
"contact_operator",
|
||||
));
|
||||
}
|
||||
}
|
||||
if descriptors.len() != usize::try_from(CURRENT_VERSION).unwrap_or_default()
|
||||
|| descriptors
|
||||
.iter()
|
||||
.map(|descriptor| descriptor.version)
|
||||
.ne(IMPLEMENTED_VERSIONS.iter().copied())
|
||||
|| sha256_hex(CONSOLIDATION_SOURCE.as_bytes()) != CONSOLIDATION_SOURCE_SHA256
|
||||
|| sha256_hex(REQUEST_TRACE_IDENTITY_SOURCE.as_bytes())
|
||||
!= REQUEST_TRACE_IDENTITY_SOURCE_SHA256
|
||||
|| sha256_hex(OPERATION_LIFECYCLE_SOURCE.as_bytes()) != OPERATION_LIFECYCLE_SOURCE_SHA256
|
||||
|| sha256_hex(execution_outcome_v5::SOURCE.as_bytes())
|
||||
!= execution_outcome_v5::SOURCE_SHA256
|
||||
|| sha256_hex(platform_key_name_reuse_v6::SOURCE.as_bytes())
|
||||
!= platform_key_name_reuse_v6::SOURCE_SHA256
|
||||
|| sha256_hex(master_key_identity_v7::SOURCE.as_bytes())
|
||||
!= master_key_identity_v7::SOURCE_SHA256
|
||||
|| sha256_hex(admin_auth_lifecycle_v8::SOURCE.as_bytes())
|
||||
!= admin_auth_lifecycle_v8::SOURCE_SHA256
|
||||
|| sha256_hex(agent_catalog_lifecycle_v9::SOURCE.as_bytes())
|
||||
!= agent_catalog_lifecycle_v9::SOURCE_SHA256
|
||||
|| sha256_hex(approval_side_effects_v10::SOURCE.as_bytes())
|
||||
!= approval_side_effects_v10::SOURCE_SHA256
|
||||
{
|
||||
return Err(MigrationError::new(
|
||||
"invalid_contract",
|
||||
"contract.implementation",
|
||||
Some(CURRENT_VERSION),
|
||||
"contact_operator",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, MigrationError> {
|
||||
let core_exists = relation_exists(connection, "__crank_core_migrations").await?;
|
||||
let canonical_exists = relation_exists(connection, "__crank_migrations").await?;
|
||||
@@ -633,6 +520,12 @@ async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, Mi
|
||||
CURRENT_VERSION,
|
||||
)
|
||||
.await?;
|
||||
validate_required_relations(
|
||||
connection,
|
||||
owned_relations::ARTIFACT_METADATA,
|
||||
CURRENT_VERSION,
|
||||
)
|
||||
.await?;
|
||||
validate_schema_fingerprint(connection, CURRENT_VERSION).await?;
|
||||
validate_legacy_audit(connection).await?;
|
||||
Ok(MigrationPreflight::Current { version: current })
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn baseline_source_digest() -> String {
|
||||
let source = include_str!("../baseline_v1.rs");
|
||||
let (_, baseline) = source
|
||||
.split_once("// baseline-v1:start\n")
|
||||
.expect("baseline start marker must exist");
|
||||
let (baseline, _) = baseline
|
||||
.split_once("// baseline-v1:end")
|
||||
.expect("baseline end marker must exist");
|
||||
sha256_hex(baseline.as_bytes())
|
||||
}
|
||||
|
||||
pub(super) fn validate_descriptors(
|
||||
descriptors: &[MigrationDescriptor],
|
||||
) -> Result<(), MigrationError> {
|
||||
if descriptors.is_empty() || descriptors.len() > 1_024 {
|
||||
return Err(MigrationError::new(
|
||||
"invalid_contract",
|
||||
"contract.sequence",
|
||||
None,
|
||||
"contact_operator",
|
||||
));
|
||||
}
|
||||
for (index, descriptor) in descriptors.iter().enumerate() {
|
||||
let expected_version = i64::try_from(index + 1).unwrap_or(i64::MAX);
|
||||
let valid_name = !descriptor.name.is_empty()
|
||||
&& descriptor.name.len() <= 128
|
||||
&& descriptor
|
||||
.name
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
&& !descriptors[..index]
|
||||
.iter()
|
||||
.any(|prior| prior.name == descriptor.name);
|
||||
let valid_source_digest = descriptor.source_digest.len() == 64
|
||||
&& descriptor
|
||||
.source_digest
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase());
|
||||
let valid_checksum = if descriptor.version == 1 {
|
||||
descriptor.checksum == BASELINE_CHECKSUM
|
||||
} else {
|
||||
descriptor.checksum == descriptor.source_digest
|
||||
};
|
||||
let valid_window = descriptor.readable_schema_min >= 1
|
||||
&& descriptor.readable_schema_min <= descriptor.readable_schema_max
|
||||
&& descriptor.readable_schema_max <= descriptor.version;
|
||||
let valid_phase = match descriptor.phase {
|
||||
"expand" => descriptor.backfill == BackfillPolicy::None,
|
||||
"migrate" => matches!(
|
||||
descriptor.backfill,
|
||||
BackfillPolicy::Bounded {
|
||||
max_batch_rows: 1..=10_000,
|
||||
max_batch_ms: 1..=60_000,
|
||||
resumable: true,
|
||||
}
|
||||
),
|
||||
"contract" => {
|
||||
descriptor.compatibility == "window-closed"
|
||||
&& descriptor.contract_evidence.is_some_and(|evidence| {
|
||||
!evidence.is_empty()
|
||||
&& evidence.len() <= 256
|
||||
&& !evidence.starts_with('/')
|
||||
&& !evidence.contains("..")
|
||||
})
|
||||
&& descriptor.backfill == BackfillPolicy::None
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let valid_compatibility = matches!(
|
||||
descriptor.compatibility,
|
||||
"legacy-baseline" | "n-minus-one-readable" | "window-open" | "window-closed"
|
||||
);
|
||||
let valid_owner = !descriptor.owner.is_empty()
|
||||
&& descriptor.owner.len() <= 128
|
||||
&& descriptor
|
||||
.owner
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
|
||||
if descriptor.version != expected_version
|
||||
|| !valid_name
|
||||
|| !valid_source_digest
|
||||
|| !valid_checksum
|
||||
|| !valid_phase
|
||||
|| !valid_compatibility
|
||||
|| !valid_owner
|
||||
|| !valid_window
|
||||
|| !descriptor.transactional
|
||||
{
|
||||
return Err(MigrationError::new(
|
||||
"invalid_contract",
|
||||
"contract.sequence",
|
||||
Some(descriptor.version),
|
||||
"contact_operator",
|
||||
));
|
||||
}
|
||||
}
|
||||
if descriptors.len() != usize::try_from(CURRENT_VERSION).unwrap_or_default()
|
||||
|| descriptors
|
||||
.iter()
|
||||
.map(|descriptor| descriptor.version)
|
||||
.ne(IMPLEMENTED_VERSIONS.iter().copied())
|
||||
|| sha256_hex(CONSOLIDATION_SOURCE.as_bytes()) != CONSOLIDATION_SOURCE_SHA256
|
||||
|| sha256_hex(REQUEST_TRACE_IDENTITY_SOURCE.as_bytes())
|
||||
!= REQUEST_TRACE_IDENTITY_SOURCE_SHA256
|
||||
|| sha256_hex(OPERATION_LIFECYCLE_SOURCE.as_bytes()) != OPERATION_LIFECYCLE_SOURCE_SHA256
|
||||
|| sha256_hex(execution_outcome_v5::SOURCE.as_bytes())
|
||||
!= execution_outcome_v5::SOURCE_SHA256
|
||||
|| sha256_hex(platform_key_name_reuse_v6::SOURCE.as_bytes())
|
||||
!= platform_key_name_reuse_v6::SOURCE_SHA256
|
||||
|| sha256_hex(master_key_identity_v7::SOURCE.as_bytes())
|
||||
!= master_key_identity_v7::SOURCE_SHA256
|
||||
|| sha256_hex(admin_auth_lifecycle_v8::SOURCE.as_bytes())
|
||||
!= admin_auth_lifecycle_v8::SOURCE_SHA256
|
||||
|| sha256_hex(agent_catalog_lifecycle_v9::SOURCE.as_bytes())
|
||||
!= agent_catalog_lifecycle_v9::SOURCE_SHA256
|
||||
|| sha256_hex(approval_side_effects_v10::SOURCE.as_bytes())
|
||||
!= approval_side_effects_v10::SOURCE_SHA256
|
||||
|| sha256_hex(onboarding_product_events_v11::SOURCE.as_bytes())
|
||||
!= onboarding_product_events_v11::SOURCE_SHA256
|
||||
|| sha256_hex(artifact_metadata_v12::SOURCE.as_bytes())
|
||||
!= artifact_metadata_v12::SOURCE_SHA256
|
||||
{
|
||||
return Err(MigrationError::new(
|
||||
"invalid_contract",
|
||||
"contract.implementation",
|
||||
Some(CURRENT_VERSION),
|
||||
"contact_operator",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -8,7 +8,7 @@ fn sequence_is_deterministic_and_append_only() {
|
||||
MigrationAuthority::validate_sequence().unwrap();
|
||||
assert_eq!(
|
||||
first.iter().map(|item| item.version).collect::<Vec<_>>(),
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
|
||||
);
|
||||
assert_eq!(first[0].checksum, "crank-community-baseline-v1");
|
||||
assert_eq!(first[0].source_digest, BASELINE_SOURCE_SHA256);
|
||||
|
||||
@@ -31,6 +31,8 @@ pub(super) const ONBOARDING_PRODUCT_EVENTS: &[&str] = &[
|
||||
"onboarding_selections",
|
||||
];
|
||||
|
||||
pub(super) const ARTIFACT_METADATA: &[&str] = &["artifact_blobs", "artifact_sources"];
|
||||
|
||||
pub(super) const CONSOLIDATION: &[&str] = &[
|
||||
"__crank_migrations",
|
||||
"__crank_migration_legacy_audit",
|
||||
|
||||
@@ -35,6 +35,8 @@ pub(super) const OWNED_RELATIONS: &[&str] = &[
|
||||
"approval_requests",
|
||||
"invocation_logs",
|
||||
"usage_rollups",
|
||||
"artifact_blobs",
|
||||
"artifact_sources",
|
||||
];
|
||||
|
||||
const REQUIRED_COLUMNS: &[(&str, &[&str])] = &[
|
||||
@@ -615,6 +617,13 @@ pub(super) async fn validate_schema_fingerprint(
|
||||
} else {
|
||||
super::schema_guard_v11::validate_v11_onboarding_product_events(connection).await?;
|
||||
}
|
||||
if current_version < 12 {
|
||||
if !super::schema_guard_v12::validate_v12_absent(connection).await? {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
} else {
|
||||
super::schema_guard_v12::validate_v12_artifact_metadata(connection).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
use sqlx::{PgConnection, Row, query};
|
||||
|
||||
use super::authority::MigrationError;
|
||||
use super::schema_guard::schema_error;
|
||||
|
||||
const OWNED_RELATIONS: &[(&str, &str)] = &[
|
||||
("artifact_blobs", "r"),
|
||||
("artifact_blobs_artifact_ref_key", "i"),
|
||||
("artifact_blobs_pkey", "i"),
|
||||
("artifact_sources", "r"),
|
||||
("artifact_sources_pkey", "i"),
|
||||
("artifact_sources_workspace_created_idx", "i"),
|
||||
];
|
||||
|
||||
const BLOB_COLUMNS: &[(&str, &str, bool, Option<&str>)] = &[
|
||||
("digest", "text", false, None),
|
||||
("artifact_ref", "text", false, None),
|
||||
("size_bytes", "bigint", false, None),
|
||||
(
|
||||
"storage_lifecycle",
|
||||
"text",
|
||||
false,
|
||||
Some("'available'::text"),
|
||||
),
|
||||
("claim_token", "text", true, None),
|
||||
("claim_expires_at", "timestamp with time zone", true, None),
|
||||
("created_at", "timestamp with time zone", false, None),
|
||||
("updated_at", "timestamp with time zone", false, None),
|
||||
];
|
||||
|
||||
const SOURCE_COLUMNS: &[(&str, &str, bool, Option<&str>)] = &[
|
||||
("workspace_id", "text", false, None),
|
||||
("source_id", "text", false, None),
|
||||
("blob_digest", "text", false, None),
|
||||
("mime_type", "text", false, None),
|
||||
("sensitivity", "text", false, None),
|
||||
("lifecycle", "text", false, Some("'active'::text")),
|
||||
("created_at", "timestamp with time zone", false, None),
|
||||
("updated_at", "timestamp with time zone", false, None),
|
||||
("detached_at", "timestamp with time zone", true, None),
|
||||
];
|
||||
|
||||
const BLOB_CONSTRAINTS: &[(&str, &str, &str)] = &[
|
||||
("artifact_blobs_pkey", "p", "PRIMARY KEY (digest)"),
|
||||
(
|
||||
"artifact_blobs_artifact_ref_key",
|
||||
"u",
|
||||
"UNIQUE (artifact_ref)",
|
||||
),
|
||||
(
|
||||
"artifact_blobs_digest_check",
|
||||
"c",
|
||||
"CHECK (digest ~ '^[0-9a-f]{64}$'::text)",
|
||||
),
|
||||
(
|
||||
"artifact_blobs_ref_check",
|
||||
"c",
|
||||
"CHECK (artifact_ref = ('sha256:'::text || digest))",
|
||||
),
|
||||
(
|
||||
"artifact_blobs_size_check",
|
||||
"c",
|
||||
"CHECK (size_bytes >= 1 AND size_bytes <= 262144)",
|
||||
),
|
||||
(
|
||||
"artifact_blobs_lifecycle_check",
|
||||
"c",
|
||||
"CHECK (storage_lifecycle = ANY (ARRAY['available'::text, 'unavailable'::text]))",
|
||||
),
|
||||
(
|
||||
"artifact_blobs_claim_token_check",
|
||||
"c",
|
||||
"CHECK (claim_token IS NULL OR octet_length(claim_token) >= 1 AND octet_length(claim_token) <= 128)",
|
||||
),
|
||||
(
|
||||
"artifact_blobs_claim_shape_check",
|
||||
"c",
|
||||
"CHECK (claim_token IS NULL AND claim_expires_at IS NULL OR claim_token IS NOT NULL AND claim_expires_at IS NOT NULL)",
|
||||
),
|
||||
(
|
||||
"artifact_blobs_timestamps_check",
|
||||
"c",
|
||||
"CHECK (updated_at >= created_at)",
|
||||
),
|
||||
];
|
||||
|
||||
const SOURCE_CONSTRAINTS: &[(&str, &str, &str)] = &[
|
||||
(
|
||||
"artifact_sources_pkey",
|
||||
"p",
|
||||
"PRIMARY KEY (workspace_id, source_id)",
|
||||
),
|
||||
(
|
||||
"artifact_sources_workspace_id_fkey",
|
||||
"f",
|
||||
"FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE",
|
||||
),
|
||||
(
|
||||
"artifact_sources_blob_digest_fkey",
|
||||
"f",
|
||||
"FOREIGN KEY (blob_digest) REFERENCES artifact_blobs(digest)",
|
||||
),
|
||||
(
|
||||
"artifact_sources_id_check",
|
||||
"c",
|
||||
"CHECK (source_id ~ '^src_[A-Za-z0-9_-]{1,128}$'::text)",
|
||||
),
|
||||
(
|
||||
"artifact_sources_mime_type_check",
|
||||
"c",
|
||||
"CHECK (octet_length(mime_type) >= 3 AND octet_length(mime_type) <= 255 AND mime_type ~ '^[A-Za-z0-9!#$&^_.+-]+/[A-Za-z0-9!#$&^_.+-]+$'::text)",
|
||||
),
|
||||
(
|
||||
"artifact_sources_sensitivity_check",
|
||||
"c",
|
||||
"CHECK (sensitivity = ANY (ARRAY['public'::text, 'internal'::text, 'secret'::text]))",
|
||||
),
|
||||
(
|
||||
"artifact_sources_lifecycle_check",
|
||||
"c",
|
||||
"CHECK (lifecycle = ANY (ARRAY['active'::text, 'detached'::text]))",
|
||||
),
|
||||
(
|
||||
"artifact_sources_state_check",
|
||||
"c",
|
||||
"CHECK (lifecycle = 'active'::text AND detached_at IS NULL OR lifecycle = 'detached'::text AND detached_at IS NOT NULL)",
|
||||
),
|
||||
(
|
||||
"artifact_sources_timestamps_check",
|
||||
"c",
|
||||
"CHECK (updated_at >= created_at AND (detached_at IS NULL OR detached_at >= created_at AND updated_at >= detached_at))",
|
||||
),
|
||||
];
|
||||
|
||||
pub(super) async fn validate_v12_absent(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<bool, MigrationError> {
|
||||
Ok(owned_relations(connection).await?.is_empty())
|
||||
}
|
||||
|
||||
pub(super) async fn validate_v12_artifact_metadata(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
if owned_relations(connection).await? != OWNED_RELATIONS {
|
||||
return Err(schema_error(12));
|
||||
}
|
||||
validate_table_properties(connection).await?;
|
||||
validate_columns(connection, "artifact_blobs", BLOB_COLUMNS).await?;
|
||||
validate_columns(connection, "artifact_sources", SOURCE_COLUMNS).await?;
|
||||
validate_constraints(connection, "artifact_blobs", BLOB_CONSTRAINTS).await?;
|
||||
validate_constraints(connection, "artifact_sources", SOURCE_CONSTRAINTS).await?;
|
||||
validate_indexes(connection).await?;
|
||||
validate_no_runtime_objects(connection).await
|
||||
}
|
||||
|
||||
async fn validate_table_properties(connection: &mut PgConnection) -> Result<(), MigrationError> {
|
||||
let rows = query(
|
||||
"select c.relname, c.relpersistence::text as persistence,
|
||||
c.relrowsecurity, c.relforcerowsecurity, c.reloptions
|
||||
from pg_catalog.pg_class c
|
||||
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
||||
where n.nspname = current_schema()
|
||||
and c.relname in ('artifact_blobs', 'artifact_sources')
|
||||
order by c.relname",
|
||||
)
|
||||
.fetch_all(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let valid = rows.len() == 2
|
||||
&& rows.iter().all(|row| {
|
||||
row.try_get::<String, _>("persistence").ok().as_deref() == Some("p")
|
||||
&& row.try_get::<bool, _>("relrowsecurity").ok() == Some(false)
|
||||
&& row.try_get::<bool, _>("relforcerowsecurity").ok() == Some(false)
|
||||
&& row
|
||||
.try_get::<Option<Vec<String>>, _>("reloptions")
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_none()
|
||||
});
|
||||
if valid { Ok(()) } else { Err(schema_error(12)) }
|
||||
}
|
||||
|
||||
async fn owned_relations(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<Vec<(&'static str, &'static str)>, MigrationError> {
|
||||
let rows = query(
|
||||
"select c.relname, c.relkind::text as relkind
|
||||
from pg_catalog.pg_class c
|
||||
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
||||
where n.nspname = current_schema()
|
||||
and c.relname like 'artifact\\_%' escape '\\'
|
||||
order by c.relname",
|
||||
)
|
||||
.fetch_all(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
let name = row
|
||||
.try_get::<String, _>("relname")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let kind = row
|
||||
.try_get::<String, _>("relkind")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let expected = OWNED_RELATIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|(expected_name, expected_kind)| {
|
||||
name == *expected_name && kind == *expected_kind
|
||||
})
|
||||
.ok_or_else(|| schema_error(12))?;
|
||||
Ok(expected)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn validate_columns(
|
||||
connection: &mut PgConnection,
|
||||
table: &str,
|
||||
expected: &[(&str, &str, bool, Option<&str>)],
|
||||
) -> Result<(), MigrationError> {
|
||||
let rows = query(
|
||||
"select column_name, data_type, is_nullable, column_default,
|
||||
datetime_precision, collation_name
|
||||
from information_schema.columns
|
||||
where table_schema = current_schema() and table_name = $1
|
||||
order by ordinal_position",
|
||||
)
|
||||
.bind(table)
|
||||
.fetch_all(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
if rows.len() != expected.len() {
|
||||
return Err(schema_error(12));
|
||||
}
|
||||
for (row, (name, data_type, nullable, default)) in rows.iter().zip(expected) {
|
||||
let actual_default = row
|
||||
.try_get::<Option<String>, _>("column_default")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let datetime_precision = row
|
||||
.try_get::<Option<i32>, _>("datetime_precision")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let collation = row
|
||||
.try_get::<Option<String>, _>("collation_name")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
if row.try_get::<String, _>("column_name").ok().as_deref() != Some(*name)
|
||||
|| row.try_get::<String, _>("data_type").ok().as_deref() != Some(*data_type)
|
||||
|| row.try_get::<String, _>("is_nullable").ok().as_deref()
|
||||
!= Some(if *nullable { "YES" } else { "NO" })
|
||||
|| actual_default.as_deref() != *default
|
||||
|| (data_type == &"timestamp with time zone" && datetime_precision != Some(6))
|
||||
|| collation.is_some()
|
||||
{
|
||||
return Err(schema_error(12));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_constraints(
|
||||
connection: &mut PgConnection,
|
||||
table: &str,
|
||||
expected: &[(&str, &str, &str)],
|
||||
) -> Result<(), MigrationError> {
|
||||
let rows = query(
|
||||
"select c.conname, c.contype::text as contype, pg_get_constraintdef(c.oid, true) as definition
|
||||
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
|
||||
where n.nspname = current_schema() and t.relname = $1
|
||||
order by c.conname",
|
||||
)
|
||||
.bind(table)
|
||||
.fetch_all(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
if rows.len() != expected.len() {
|
||||
return Err(schema_error(12));
|
||||
}
|
||||
for row in rows {
|
||||
let name = row
|
||||
.try_get::<String, _>("conname")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let kind = row
|
||||
.try_get::<String, _>("contype")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let definition = row
|
||||
.try_get::<String, _>("definition")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let Some((_, _, expected_definition)) =
|
||||
expected.iter().find(|(expected_name, expected_kind, _)| {
|
||||
name == *expected_name && kind == *expected_kind
|
||||
})
|
||||
else {
|
||||
return Err(schema_error(12));
|
||||
};
|
||||
if canonical_definition(&definition) != canonical_definition(expected_definition) {
|
||||
return Err(schema_error(12));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonical_definition(value: &str) -> String {
|
||||
let mut quoted = false;
|
||||
value
|
||||
.chars()
|
||||
.filter_map(|character| {
|
||||
if character == '\'' {
|
||||
quoted = !quoted;
|
||||
return Some(character);
|
||||
}
|
||||
if character.is_ascii_whitespace() {
|
||||
None
|
||||
} else if quoted {
|
||||
Some(character)
|
||||
} else {
|
||||
Some(character.to_ascii_lowercase())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn validate_indexes(connection: &mut PgConnection) -> Result<(), MigrationError> {
|
||||
let rows = query(
|
||||
"select idx.relname as index_name, t.relname as table_name, am.amname as access_method,
|
||||
i.indisvalid, i.indisready, i.indisunique,
|
||||
i.indnkeyatts, i.indnatts,
|
||||
pg_get_indexdef(i.indexrelid, 1, true) as first_column,
|
||||
pg_get_indexdef(i.indexrelid, 2, true) as second_column,
|
||||
pg_get_indexdef(i.indexrelid, 3, true) as third_column,
|
||||
pg_get_expr(i.indpred, i.indrelid) as predicate
|
||||
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 pg_catalog.pg_am am on am.oid = idx.relam
|
||||
where n.nspname = current_schema()
|
||||
and t.relname in ('artifact_blobs', 'artifact_sources')
|
||||
order by idx.relname",
|
||||
)
|
||||
.fetch_all(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let names = rows
|
||||
.iter()
|
||||
.filter_map(|row| row.try_get::<String, _>("index_name").ok())
|
||||
.collect::<Vec<_>>();
|
||||
if names
|
||||
!= [
|
||||
"artifact_blobs_artifact_ref_key",
|
||||
"artifact_blobs_pkey",
|
||||
"artifact_sources_pkey",
|
||||
"artifact_sources_workspace_created_idx",
|
||||
]
|
||||
{
|
||||
return Err(schema_error(12));
|
||||
}
|
||||
let row = rows.iter().find(|row| {
|
||||
row.try_get::<String, _>("index_name").ok().as_deref()
|
||||
== Some("artifact_sources_workspace_created_idx")
|
||||
});
|
||||
let valid = row.is_some_and(|row| {
|
||||
row.try_get::<String, _>("table_name").ok().as_deref() == Some("artifact_sources")
|
||||
&& row.try_get::<String, _>("access_method").ok().as_deref() == Some("btree")
|
||||
&& row.try_get::<bool, _>("indisvalid").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisready").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisunique").ok() == Some(false)
|
||||
&& row.try_get::<i16, _>("indnkeyatts").ok() == Some(3)
|
||||
&& row.try_get::<i16, _>("indnatts").ok() == Some(3)
|
||||
&& row.try_get::<String, _>("first_column").ok().as_deref() == Some("workspace_id")
|
||||
&& row.try_get::<String, _>("second_column").ok().as_deref() == Some("created_at")
|
||||
&& row.try_get::<String, _>("third_column").ok().as_deref() == Some("source_id")
|
||||
&& row
|
||||
.try_get::<Option<String>, _>("predicate")
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_none()
|
||||
});
|
||||
if valid { Ok(()) } else { Err(schema_error(12)) }
|
||||
}
|
||||
|
||||
async fn validate_no_runtime_objects(connection: &mut PgConnection) -> Result<(), MigrationError> {
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"select
|
||||
(select count(*) from pg_catalog.pg_trigger tr
|
||||
join pg_catalog.pg_class t on t.oid = tr.tgrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
where n.nspname = current_schema()
|
||||
and t.relname in ('artifact_blobs', 'artifact_sources')
|
||||
and not tr.tgisinternal)
|
||||
+ (select count(*) 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
|
||||
where n.nspname = current_schema()
|
||||
and t.relname in ('artifact_blobs', 'artifact_sources'))",
|
||||
)
|
||||
.fetch_one(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
if count == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(schema_error(12))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user