feat(import): resolve references and schema composition
This commit is contained in:
@@ -227,6 +227,7 @@ async fn run(
|
||||
.with_artifact_store(artifact_store.clone())
|
||||
.with_public_base_url(base_url)
|
||||
.with_outbound_http_policy(outbound_http_policy)
|
||||
.with_external_reference_import(&config.external_references)?
|
||||
.with_identity_provider(std::sync::Arc::new(identity_provider))
|
||||
.build();
|
||||
if config.demo_seed {
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use crank_artifacts::ArtifactStore;
|
||||
use crank_config::ExternalReferenceSettings;
|
||||
use crank_core::{
|
||||
AuditActor, AuditEvent, AuditEventId, AuditSink, AuditTarget, AuditTargetKind, AuthProfile,
|
||||
CapabilityProfile, CommunityCapabilityProfile, CorrelationContext, EditionCapabilities,
|
||||
@@ -19,7 +20,8 @@ use crank_registry::{
|
||||
UsageBucket,
|
||||
};
|
||||
use crank_runtime::{
|
||||
OutboundHttpPolicy, ResolvedAuth, RuntimeError, RuntimeExecutor, SecretCrypto,
|
||||
ExternalReferenceFetcher, OutboundHttpPolicy, ResolvedAuth, RuntimeError, RuntimeExecutor,
|
||||
SecretCrypto,
|
||||
};
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
use crank_trace::{DbOperation, ErrorCategory, Stage, StageOutcome, observe_db_query};
|
||||
@@ -68,6 +70,9 @@ pub struct AdminService {
|
||||
audit_sink: Arc<dyn AuditSink>,
|
||||
capability_profile: Arc<dyn CapabilityProfile>,
|
||||
outbound_http_policy: OutboundHttpPolicy,
|
||||
external_reference_fetcher: ExternalReferenceFetcher,
|
||||
external_reference_normalization: crank_import::rest::NormalizationConfig,
|
||||
external_reference_materialization_timeout: std::time::Duration,
|
||||
public_base_url: String,
|
||||
}
|
||||
|
||||
@@ -83,6 +88,9 @@ pub struct AdminServiceBuilder {
|
||||
audit_sink: Option<Arc<dyn AuditSink>>,
|
||||
capability_profile: Option<Arc<dyn CapabilityProfile>>,
|
||||
outbound_http_policy: OutboundHttpPolicy,
|
||||
external_reference_fetcher: ExternalReferenceFetcher,
|
||||
external_reference_normalization: crank_import::rest::NormalizationConfig,
|
||||
external_reference_settings: ExternalReferenceSettings,
|
||||
public_base_url: String,
|
||||
}
|
||||
|
||||
@@ -220,6 +228,22 @@ impl AdminServiceBuilder {
|
||||
secret_crypto: SecretCrypto,
|
||||
runtime: RuntimeExecutor,
|
||||
) -> Self {
|
||||
let outbound_http_policy = OutboundHttpPolicy::default();
|
||||
let external_reference_settings = ExternalReferenceSettings {
|
||||
allowed_url_prefixes: Vec::new(),
|
||||
max_depth: 8,
|
||||
max_documents: 32,
|
||||
max_fetch_bytes: 256 * 1024,
|
||||
fetch_timeout_ms: 5_000,
|
||||
max_expanded_nodes: 10_000,
|
||||
};
|
||||
let external_reference_fetcher = ExternalReferenceFetcher::try_new(
|
||||
outbound_http_policy.clone(),
|
||||
external_reference_settings.allowed_url_prefixes.clone(),
|
||||
external_reference_settings.max_fetch_bytes,
|
||||
std::time::Duration::from_millis(external_reference_settings.fetch_timeout_ms),
|
||||
)
|
||||
.expect("static external reference defaults are valid");
|
||||
Self {
|
||||
registry,
|
||||
storage_root,
|
||||
@@ -231,7 +255,10 @@ impl AdminServiceBuilder {
|
||||
policy_engine: None,
|
||||
audit_sink: None,
|
||||
capability_profile: None,
|
||||
outbound_http_policy: OutboundHttpPolicy::default(),
|
||||
outbound_http_policy,
|
||||
external_reference_fetcher,
|
||||
external_reference_normalization: Default::default(),
|
||||
external_reference_settings,
|
||||
public_base_url: "http://localhost:3000".to_owned(),
|
||||
}
|
||||
}
|
||||
@@ -242,10 +269,41 @@ impl AdminServiceBuilder {
|
||||
}
|
||||
|
||||
pub fn with_outbound_http_policy(mut self, policy: OutboundHttpPolicy) -> Self {
|
||||
self.external_reference_fetcher = ExternalReferenceFetcher::try_new(
|
||||
policy.clone(),
|
||||
self.external_reference_settings
|
||||
.allowed_url_prefixes
|
||||
.clone(),
|
||||
self.external_reference_settings.max_fetch_bytes,
|
||||
std::time::Duration::from_millis(self.external_reference_settings.fetch_timeout_ms),
|
||||
)
|
||||
.expect("validated external reference settings remain valid");
|
||||
self.outbound_http_policy = policy;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_external_reference_import(
|
||||
mut self,
|
||||
settings: &ExternalReferenceSettings,
|
||||
) -> Result<Self, crank_runtime::ExternalReferenceFetchError> {
|
||||
self.external_reference_fetcher = ExternalReferenceFetcher::try_new(
|
||||
self.outbound_http_policy.clone(),
|
||||
settings.allowed_url_prefixes.clone(),
|
||||
settings.max_fetch_bytes,
|
||||
std::time::Duration::from_millis(settings.fetch_timeout_ms),
|
||||
)?;
|
||||
self.external_reference_normalization.max_reference_depth = settings.max_depth;
|
||||
self.external_reference_normalization
|
||||
.max_reference_documents = settings.max_documents;
|
||||
self.external_reference_normalization
|
||||
.max_external_document_bytes = settings.max_fetch_bytes;
|
||||
self.external_reference_normalization.max_expanded_nodes = settings.max_expanded_nodes;
|
||||
self.external_reference_normalization
|
||||
.external_references_enabled = !settings.allowed_url_prefixes.is_empty();
|
||||
self.external_reference_settings = settings.clone();
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Reuses the process-wide immutable artifact authority for OpenAPI
|
||||
/// ingress and reconciliation. Tests may omit this and use their private
|
||||
/// storage root instead.
|
||||
@@ -299,6 +357,11 @@ impl AdminServiceBuilder {
|
||||
.capability_profile
|
||||
.unwrap_or_else(|| Arc::new(CommunityCapabilityProfile)),
|
||||
outbound_http_policy: self.outbound_http_policy,
|
||||
external_reference_fetcher: self.external_reference_fetcher,
|
||||
external_reference_normalization: self.external_reference_normalization,
|
||||
external_reference_materialization_timeout: std::time::Duration::from_millis(
|
||||
self.external_reference_settings.fetch_timeout_ms,
|
||||
),
|
||||
public_base_url: self.public_base_url,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use crank_import::rest::{
|
||||
};
|
||||
use crank_registry::{
|
||||
ApplyImportJobRequest, ArtifactSourceId, ArtifactSourceSensitivity,
|
||||
CreateArtifactSourceRequest, CreateImportJobRequest, DetachArtifactSourceRequest,
|
||||
CreateArtifactSourceRequest, CreateImportJobRequest, FinishImportJobRequest,
|
||||
ImportConflictMode, ImportJobApplyResult, ImportJobId, ImportJobKind, ImportJobSourceEnvelope,
|
||||
ImportJobStatus, ImportOperationDraft, RegistryError,
|
||||
};
|
||||
@@ -29,6 +29,13 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
mod external_references;
|
||||
mod job_contract;
|
||||
use external_references::ImportReplayContext;
|
||||
use job_contract::{
|
||||
SourceDetachGuard, import_job_dependencies, import_job_normalization_config, import_job_source,
|
||||
};
|
||||
|
||||
const IMPORT_JOB_TTL_HOURS: i64 = 24;
|
||||
const OPENAPI_PARSE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
const OPENAPI_PARSE_CONCURRENCY: usize = 4;
|
||||
@@ -95,9 +102,14 @@ impl AdminService {
|
||||
detach_guard.detach_now().await;
|
||||
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
||||
}
|
||||
let mut dependencies = self
|
||||
.materialize_external_reference_snapshots(workspace_id, &verified.bytes, now)
|
||||
.await;
|
||||
let parsed = match parse_verified_preview(
|
||||
verified.bytes,
|
||||
artifact.artifact_ref().digest_hex().to_owned(),
|
||||
dependencies.snapshots.clone(),
|
||||
self.external_reference_normalization.clone(),
|
||||
locale,
|
||||
false,
|
||||
)
|
||||
@@ -105,6 +117,7 @@ impl AdminService {
|
||||
{
|
||||
Ok(preview) => preview,
|
||||
Err(error) => {
|
||||
dependencies.detach_all().await;
|
||||
detach_guard.detach_now().await;
|
||||
return Err(error);
|
||||
}
|
||||
@@ -115,6 +128,7 @@ impl AdminService {
|
||||
.iter()
|
||||
.all(|group| group.operations.is_empty())
|
||||
{
|
||||
dependencies.detach_all().await;
|
||||
detach_guard.detach_now().await;
|
||||
return Err(ApiError::openapi_upload(locale, "no_methods"));
|
||||
}
|
||||
@@ -130,12 +144,15 @@ impl AdminService {
|
||||
"source_id": source_envelope.source_id.as_str(),
|
||||
"digest": source_envelope.digest.as_str(),
|
||||
},
|
||||
"dependencies": dependencies.payload(),
|
||||
"dependency_snapshots": dependencies.snapshot_payload(),
|
||||
"preview": preview_value,
|
||||
"preview_digest": preview_digest,
|
||||
"normalization": {
|
||||
"normalizer_version": NORMALIZER_VERSION,
|
||||
"projection_version": PROJECTION_VERSION,
|
||||
"ir_fingerprint": parsed.ir_fingerprint,
|
||||
"config": self.external_reference_normalization,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -155,9 +172,11 @@ impl AdminService {
|
||||
})
|
||||
.await
|
||||
{
|
||||
dependencies.detach_all().await;
|
||||
detach_guard.detach_now().await;
|
||||
return Err(ApiError::from(error));
|
||||
}
|
||||
dependencies.disarm();
|
||||
detach_guard.disarm();
|
||||
|
||||
Ok(OpenApiImportPreviewResponse {
|
||||
@@ -234,6 +253,13 @@ impl AdminService {
|
||||
} else {
|
||||
ImportConflictMode::Rename
|
||||
};
|
||||
let replay_context = ImportReplayContext {
|
||||
workspace_id,
|
||||
job_id,
|
||||
application_key: &application_key,
|
||||
conflict_mode,
|
||||
finished_at: &finished_at,
|
||||
};
|
||||
if job.status == ImportJobStatus::Completed {
|
||||
let applied = self
|
||||
.registry
|
||||
@@ -250,7 +276,18 @@ impl AdminService {
|
||||
return Ok(openapi_import_response(applied));
|
||||
}
|
||||
|
||||
let source = import_job_source(&job.preview_payload, locale)?;
|
||||
let source = match import_job_source(&job.preview_payload, locale) {
|
||||
Ok(source) => source,
|
||||
Err(error) => {
|
||||
return self
|
||||
.fail_openapi_import_or_replay(
|
||||
&replay_context,
|
||||
"import_source_verification_failed",
|
||||
error,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
};
|
||||
let verified = match self
|
||||
.registry
|
||||
.read_artifact_source(
|
||||
@@ -264,44 +301,123 @@ impl AdminService {
|
||||
Err(
|
||||
error @ (RegistryError::SourceNotFound { .. } | RegistryError::SourceUnavailable),
|
||||
) => {
|
||||
// Another request may have finished and detached the source
|
||||
// after our unlocked Pending read. Re-read the job and ask
|
||||
// the registry for its canonical, locked replay instead of
|
||||
// exposing a false `source_unavailable` outcome.
|
||||
let latest = self.registry.get_import_job(workspace_id, job_id).await?;
|
||||
if latest.is_some_and(|latest| latest.status == ImportJobStatus::Completed) {
|
||||
let applied = self
|
||||
.registry
|
||||
.apply_import_job(ApplyImportJobRequest {
|
||||
id: job_id,
|
||||
workspace_id,
|
||||
application_key: &application_key,
|
||||
conflict_mode,
|
||||
operations: &[],
|
||||
pre_skipped: &[],
|
||||
finished_at: &finished_at,
|
||||
})
|
||||
.await?;
|
||||
return Ok(openapi_import_response(applied));
|
||||
}
|
||||
return Err(openapi_source_error(locale, error));
|
||||
return self
|
||||
.fail_openapi_import_or_replay(
|
||||
&replay_context,
|
||||
"import_source_verification_failed",
|
||||
openapi_source_error(locale, error),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(error @ RegistryError::SourceIntegrity) => {
|
||||
return self
|
||||
.fail_openapi_import_or_replay(
|
||||
&replay_context,
|
||||
"import_source_verification_failed",
|
||||
openapi_source_error(locale, error),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(error) => return Err(openapi_source_error(locale, error)),
|
||||
};
|
||||
if verified.source.blob.artifact_ref != source.digest {
|
||||
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
||||
return self
|
||||
.fail_openapi_import_or_replay(
|
||||
&replay_context,
|
||||
"import_source_verification_failed",
|
||||
ApiError::openapi_upload(locale, "source_integrity"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let legacy_v1 = job.preview_payload.get("normalization").is_none();
|
||||
let parsed = parse_verified_preview(
|
||||
verified.bytes,
|
||||
source.digest.digest_hex().to_owned(),
|
||||
locale,
|
||||
legacy_v1,
|
||||
)
|
||||
.await?;
|
||||
verify_preview_contract(&job.preview_payload, &parsed, locale)?;
|
||||
if preview_has_blocker(&parsed.preview) {
|
||||
return Err(ApiError::openapi_upload(locale, "invalid_document"));
|
||||
let replay_contract = match import_replay_contract(&job.preview_payload, locale) {
|
||||
Ok(contract) => contract,
|
||||
Err(error) => {
|
||||
return self
|
||||
.fail_openapi_import_or_replay(
|
||||
&replay_context,
|
||||
"import_replay_verification_failed",
|
||||
error,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
};
|
||||
let parsed = match replay_contract {
|
||||
ImportReplayContract::PersistedV2(parsed) => parsed,
|
||||
ImportReplayContract::LegacyV1 | ImportReplayContract::Current => {
|
||||
let legacy_v1 = matches!(replay_contract, ImportReplayContract::LegacyV1);
|
||||
let dependency_snapshots = match self
|
||||
.read_import_job_dependencies(workspace_id, &job.preview_payload, locale)
|
||||
.await
|
||||
{
|
||||
Ok(snapshots) => snapshots,
|
||||
Err(error) => {
|
||||
return self
|
||||
.fail_openapi_import_or_replay(
|
||||
&replay_context,
|
||||
"import_dependency_verification_failed",
|
||||
error,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
};
|
||||
let normalization_config = match import_job_normalization_config(
|
||||
&job.preview_payload,
|
||||
&self.external_reference_normalization,
|
||||
legacy_v1,
|
||||
locale,
|
||||
) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
return self
|
||||
.fail_openapi_import_or_replay(
|
||||
&replay_context,
|
||||
"import_replay_verification_failed",
|
||||
error,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
};
|
||||
let parsed = match parse_verified_preview(
|
||||
verified.bytes,
|
||||
source.digest.digest_hex().to_owned(),
|
||||
dependency_snapshots,
|
||||
normalization_config,
|
||||
locale,
|
||||
legacy_v1,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(parsed) => parsed,
|
||||
Err(error) => {
|
||||
return self
|
||||
.fail_openapi_import_or_replay(
|
||||
&replay_context,
|
||||
"import_replay_verification_failed",
|
||||
error,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
};
|
||||
if let Err(error) = verify_preview_contract(&job.preview_payload, &parsed, locale) {
|
||||
return self
|
||||
.fail_openapi_import_or_replay(
|
||||
&replay_context,
|
||||
"import_replay_verification_failed",
|
||||
error,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
parsed
|
||||
}
|
||||
};
|
||||
if preview_has_blocker(&parsed.preview, &selected) {
|
||||
return self
|
||||
.fail_openapi_import_or_replay(
|
||||
&replay_context,
|
||||
"reference_resolution_blocked",
|
||||
ApiError::openapi_upload(locale, "invalid_document"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let mut candidates = BTreeMap::new();
|
||||
for group in &parsed.preview.groups {
|
||||
@@ -372,6 +488,24 @@ impl AdminService {
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_external_document_uri(base: Option<&str>, reference: &str) -> Option<String> {
|
||||
if reference.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
let mut url = match base {
|
||||
Some(base) => url::Url::parse(base).ok()?.join(reference).ok()?,
|
||||
None => url::Url::parse(reference).ok()?,
|
||||
};
|
||||
if !matches!(url.scheme(), "http" | "https")
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
url.set_fragment(None);
|
||||
Some(url.to_string())
|
||||
}
|
||||
|
||||
fn openapi_import_response(applied: ImportJobApplyResult) -> OpenApiImportCreateResponse {
|
||||
let created = applied
|
||||
.created
|
||||
@@ -470,9 +604,72 @@ struct ParsedOpenApiPreview {
|
||||
ir_fingerprint: String,
|
||||
}
|
||||
|
||||
enum ImportReplayContract {
|
||||
LegacyV1,
|
||||
PersistedV2(ParsedOpenApiPreview),
|
||||
Current,
|
||||
}
|
||||
|
||||
fn import_replay_contract(
|
||||
payload: &serde_json::Value,
|
||||
locale: OpenApiUploadLocale,
|
||||
) -> Result<ImportReplayContract, ApiError> {
|
||||
let Some(normalization) = payload.get("normalization") else {
|
||||
return Ok(ImportReplayContract::LegacyV1);
|
||||
};
|
||||
let normalizer = normalization
|
||||
.get("normalizer_version")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
|
||||
let projection = normalization
|
||||
.get("projection_version")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
|
||||
let ir_fingerprint = normalization
|
||||
.get("ir_fingerprint")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| is_lower_sha256(value))
|
||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
|
||||
let expected_preview_digest = payload
|
||||
.get("preview_digest")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| is_lower_sha256(value))
|
||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
|
||||
|
||||
if normalizer == NORMALIZER_VERSION && projection == PROJECTION_VERSION {
|
||||
return Ok(ImportReplayContract::Current);
|
||||
}
|
||||
if normalizer != "normalized-ir-v2" || projection != "preview-v2" {
|
||||
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
||||
}
|
||||
|
||||
let preview_value = payload
|
||||
.get("preview")
|
||||
.cloned()
|
||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
|
||||
if preview_digest(&preview_value)? != expected_preview_digest {
|
||||
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
||||
}
|
||||
let preview = serde_json::from_value(preview_value)
|
||||
.map_err(|_| ApiError::openapi_upload(locale, "source_integrity"))?;
|
||||
Ok(ImportReplayContract::PersistedV2(ParsedOpenApiPreview {
|
||||
preview,
|
||||
ir_fingerprint: ir_fingerprint.to_owned(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn is_lower_sha256(value: &str) -> bool {
|
||||
value.len() == 64
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
}
|
||||
|
||||
async fn parse_verified_preview(
|
||||
bytes: Vec<u8>,
|
||||
digest: String,
|
||||
snapshots: Vec<crank_import::rest::ExternalDocumentSnapshot>,
|
||||
normalization_config: crank_import::rest::NormalizationConfig,
|
||||
locale: OpenApiUploadLocale,
|
||||
legacy_v1: bool,
|
||||
) -> Result<ParsedOpenApiPreview, ApiError> {
|
||||
@@ -498,14 +695,18 @@ async fn parse_verified_preview(
|
||||
}
|
||||
let digest = crank_import::rest::SourceDigest::parse(digest)
|
||||
.map_err(|_| ApiError::openapi_upload(locale, "source_integrity"))?;
|
||||
let ir =
|
||||
crank_import::rest::normalize_verified_document(document, digest, &Default::default())
|
||||
.map_err(|error| match error {
|
||||
crank_import::rest::ImportParseError::NoMethods => {
|
||||
ApiError::openapi_upload(locale, "no_methods")
|
||||
}
|
||||
_ => ApiError::openapi_upload(locale, "invalid_document"),
|
||||
})?;
|
||||
let ir = crank_import::rest::normalize_verified_bundle(
|
||||
document,
|
||||
digest,
|
||||
&snapshots,
|
||||
&normalization_config,
|
||||
)
|
||||
.map_err(|error| match error {
|
||||
crank_import::rest::ImportParseError::NoMethods => {
|
||||
ApiError::openapi_upload(locale, "no_methods")
|
||||
}
|
||||
_ => ApiError::openapi_upload(locale, "invalid_document"),
|
||||
})?;
|
||||
let ir_fingerprint = preview_digest(
|
||||
&serde_json::to_value(&ir)
|
||||
.map_err(|_| ApiError::openapi_upload(locale, "invalid_document"))?,
|
||||
@@ -597,113 +798,21 @@ fn verify_preview_contract(
|
||||
}
|
||||
}
|
||||
|
||||
fn preview_has_blocker(preview: &crank_import::rest::ImportPreview) -> bool {
|
||||
fn preview_has_blocker(
|
||||
preview: &crank_import::rest::ImportPreview,
|
||||
selected_operation_keys: &BTreeSet<String>,
|
||||
) -> bool {
|
||||
preview
|
||||
.findings
|
||||
.iter()
|
||||
.chain(
|
||||
preview
|
||||
.groups
|
||||
.iter()
|
||||
.flat_map(|group| group.operations.iter())
|
||||
.flat_map(|operation| operation.findings.iter()),
|
||||
)
|
||||
.any(|finding| finding.severity == ImportFindingSeverity::Error)
|
||||
}
|
||||
|
||||
fn import_job_source(
|
||||
payload: &serde_json::Value,
|
||||
locale: OpenApiUploadLocale,
|
||||
) -> Result<ImportJobSourceEnvelope, ApiError> {
|
||||
let source = payload
|
||||
.get("source")
|
||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_unavailable"))?;
|
||||
let source_id = source
|
||||
.get("source_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| value.len() <= 132)
|
||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_unavailable"))?;
|
||||
let digest = source
|
||||
.get("digest")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(|value| value.parse().ok())
|
||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
|
||||
Ok(ImportJobSourceEnvelope {
|
||||
source_id: ArtifactSourceId::new(source_id),
|
||||
digest,
|
||||
})
|
||||
}
|
||||
|
||||
struct SourceDetachGuard {
|
||||
registry: crank_registry::PostgresRegistry,
|
||||
workspace_id: WorkspaceId,
|
||||
source_id: ArtifactSourceId,
|
||||
expected_updated_at: OffsetDateTime,
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
impl SourceDetachGuard {
|
||||
fn new(
|
||||
registry: crank_registry::PostgresRegistry,
|
||||
workspace_id: WorkspaceId,
|
||||
source_id: ArtifactSourceId,
|
||||
expected_updated_at: OffsetDateTime,
|
||||
) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
workspace_id,
|
||||
source_id,
|
||||
expected_updated_at,
|
||||
armed: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn disarm(&mut self) {
|
||||
self.armed = false;
|
||||
}
|
||||
|
||||
async fn detach_now(&mut self) {
|
||||
if !self.armed {
|
||||
return;
|
||||
}
|
||||
self.armed = false;
|
||||
// This is the normal error path: await the database transition so a
|
||||
// caller receives a completed cleanup before it can retry. `Drop`
|
||||
// remains only the cancellation/shutdown safety net.
|
||||
let _ = self
|
||||
.registry
|
||||
.detach_artifact_source(DetachArtifactSourceRequest {
|
||||
workspace_id: &self.workspace_id,
|
||||
source_id: &self.source_id,
|
||||
expected_updated_at: Some(self.expected_updated_at),
|
||||
detached_at: OffsetDateTime::now_utc(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SourceDetachGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.armed {
|
||||
return;
|
||||
}
|
||||
let registry = self.registry.clone();
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let source_id = self.source_id.clone();
|
||||
let expected_updated_at = Some(self.expected_updated_at);
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
handle.spawn(async move {
|
||||
let _ = registry
|
||||
.detach_artifact_source(DetachArtifactSourceRequest {
|
||||
workspace_id: &workspace_id,
|
||||
source_id: &source_id,
|
||||
expected_updated_at,
|
||||
detached_at: OffsetDateTime::now_utc(),
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|| preview
|
||||
.groups
|
||||
.iter()
|
||||
.flat_map(|group| group.operations.iter())
|
||||
.filter(|operation| selected_operation_keys.contains(&operation.key))
|
||||
.flat_map(|operation| operation.findings.iter())
|
||||
.any(|finding| finding.severity == ImportFindingSeverity::Error)
|
||||
}
|
||||
|
||||
fn openapi_application_key(payload: &OpenApiImportCreatePayload) -> Result<String, ApiError> {
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
use tracing::warn;
|
||||
|
||||
pub(super) struct MaterializedDependencies {
|
||||
pub(super) snapshots: Vec<crank_import::rest::ExternalDocumentSnapshot>,
|
||||
envelopes: Vec<ImportJobSourceEnvelope>,
|
||||
canonical_uris: Vec<String>,
|
||||
guards: Vec<SourceDetachGuard>,
|
||||
}
|
||||
|
||||
pub(super) struct ImportReplayContext<'a> {
|
||||
pub(super) workspace_id: &'a WorkspaceId,
|
||||
pub(super) job_id: &'a ImportJobId,
|
||||
pub(super) application_key: &'a str,
|
||||
pub(super) conflict_mode: ImportConflictMode,
|
||||
pub(super) finished_at: &'a OffsetDateTime,
|
||||
}
|
||||
|
||||
impl MaterializedDependencies {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
snapshots: Vec::new(),
|
||||
envelopes: Vec::new(),
|
||||
canonical_uris: Vec::new(),
|
||||
guards: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn payload(&self) -> Vec<serde_json::Value> {
|
||||
self.envelopes
|
||||
.iter()
|
||||
.zip(&self.canonical_uris)
|
||||
.map(|(dependency, canonical_uri)| {
|
||||
json!({
|
||||
"source_id": dependency.source_id.as_str(),
|
||||
"digest": dependency.digest.as_str(),
|
||||
"canonical_uri": canonical_uri,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn snapshot_payload(&self) -> Vec<serde_json::Value> {
|
||||
self.snapshots
|
||||
.iter()
|
||||
.filter_map(|snapshot| {
|
||||
self.envelopes
|
||||
.iter()
|
||||
.find(|dependency| dependency.digest.digest_hex() == snapshot.digest.as_str())
|
||||
.map(|dependency| {
|
||||
json!({
|
||||
"source_id": dependency.source_id.as_str(),
|
||||
"digest": dependency.digest.as_str(),
|
||||
"canonical_uri": snapshot.canonical_uri,
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn disarm(&mut self) {
|
||||
for guard in &mut self.guards {
|
||||
guard.disarm();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn detach_all(&mut self) {
|
||||
for guard in &mut self.guards {
|
||||
guard.detach_now().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AdminService {
|
||||
pub(super) async fn fail_openapi_import_or_replay(
|
||||
&self,
|
||||
context: &ImportReplayContext<'_>,
|
||||
error_text: &'static str,
|
||||
error: ApiError,
|
||||
) -> Result<OpenApiImportCreateResponse, ApiError> {
|
||||
let empty = json!([]);
|
||||
self.registry
|
||||
.finish_import_job(FinishImportJobRequest {
|
||||
id: context.job_id,
|
||||
status: ImportJobStatus::Failed,
|
||||
created_operation_ids: &empty,
|
||||
error_text: Some(error_text),
|
||||
finished_at: context.finished_at,
|
||||
})
|
||||
.await?;
|
||||
|
||||
// `finish_import_job` preserves an already Completed row under its
|
||||
// lock. This read distinguishes that race from the terminal Failed
|
||||
// transition and returns the immutable canonical application result.
|
||||
let latest = self
|
||||
.registry
|
||||
.get_import_job(context.workspace_id, context.job_id)
|
||||
.await?;
|
||||
if latest.is_some_and(|job| job.status == ImportJobStatus::Completed) {
|
||||
let applied = self
|
||||
.registry
|
||||
.apply_import_job(ApplyImportJobRequest {
|
||||
id: context.job_id,
|
||||
workspace_id: context.workspace_id,
|
||||
application_key: context.application_key,
|
||||
conflict_mode: context.conflict_mode,
|
||||
operations: &[],
|
||||
pre_skipped: &[],
|
||||
finished_at: context.finished_at,
|
||||
})
|
||||
.await?;
|
||||
return Ok(openapi_import_response(applied));
|
||||
}
|
||||
|
||||
Err(error)
|
||||
}
|
||||
|
||||
pub(super) async fn materialize_external_reference_snapshots(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
primary_bytes: &[u8],
|
||||
created_at: OffsetDateTime,
|
||||
) -> MaterializedDependencies {
|
||||
let materialized_count = std::sync::atomic::AtomicUsize::new(0);
|
||||
match tokio::time::timeout(
|
||||
self.external_reference_materialization_timeout,
|
||||
self.materialize_external_reference_snapshots_inner(
|
||||
workspace_id,
|
||||
primary_bytes,
|
||||
created_at,
|
||||
&materialized_count,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(dependencies) => dependencies,
|
||||
Err(_) => {
|
||||
materialization_failure(
|
||||
"chain",
|
||||
"timeout",
|
||||
materialized_count.load(std::sync::atomic::Ordering::Relaxed),
|
||||
);
|
||||
MaterializedDependencies::empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn materialize_external_reference_snapshots_inner(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
primary_bytes: &[u8],
|
||||
created_at: OffsetDateTime,
|
||||
materialized_count: &std::sync::atomic::AtomicUsize,
|
||||
) -> MaterializedDependencies {
|
||||
if !self
|
||||
.external_reference_normalization
|
||||
.external_references_enabled
|
||||
{
|
||||
return MaterializedDependencies::empty();
|
||||
}
|
||||
let Ok(primary) = std::str::from_utf8(primary_bytes) else {
|
||||
materialization_failure("primary_decode", "invalid_utf8", 0);
|
||||
return MaterializedDependencies::empty();
|
||||
};
|
||||
let Ok(primary_references) =
|
||||
crank_import::rest::reference_uris(primary, &self.external_reference_normalization)
|
||||
else {
|
||||
materialization_failure("primary_scan", "invalid_document", 0);
|
||||
return MaterializedDependencies::empty();
|
||||
};
|
||||
let mut queue = VecDeque::new();
|
||||
for reference in primary_references {
|
||||
if let Some(uri) = canonical_external_document_uri(None, &reference) {
|
||||
queue.push_back((uri, 1usize));
|
||||
}
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut result = MaterializedDependencies::empty();
|
||||
let mut dependency_by_digest = BTreeMap::<String, usize>::new();
|
||||
while let Some((canonical_uri, depth)) = queue.pop_front() {
|
||||
if !seen.insert(canonical_uri.clone())
|
||||
|| depth > self.external_reference_normalization.max_reference_depth
|
||||
|| seen.len()
|
||||
> self
|
||||
.external_reference_normalization
|
||||
.max_reference_documents
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let bytes = match self.external_reference_fetcher.get(&canonical_uri).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
materialization_failure(
|
||||
"fetch",
|
||||
external_fetch_error_code(&error),
|
||||
result.snapshots.len(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Ok(document) = std::str::from_utf8(&bytes).map(str::to_owned) else {
|
||||
materialization_failure(
|
||||
"dependency_decode",
|
||||
"invalid_utf8",
|
||||
result.snapshots.len(),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let Ok(references) = crank_import::rest::external_reference_uris(
|
||||
&document,
|
||||
&self.external_reference_normalization,
|
||||
) else {
|
||||
materialization_failure(
|
||||
"dependency_scan",
|
||||
"invalid_document",
|
||||
result.snapshots.len(),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let store = self.artifact_store.clone();
|
||||
let artifact_bytes = bytes.clone();
|
||||
let Ok(Ok(artifact)) =
|
||||
tokio::task::spawn_blocking(move || store.put_registered(&artifact_bytes)).await
|
||||
else {
|
||||
materialization_failure(
|
||||
"artifact_store",
|
||||
"storage_unavailable",
|
||||
result.snapshots.len(),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let digest_key = artifact.artifact_ref().as_str().to_owned();
|
||||
let envelope_index = if let Some(index) = dependency_by_digest.get(&digest_key) {
|
||||
*index
|
||||
} else {
|
||||
let source_id = ArtifactSourceId::new(new_prefixed_id("src_openapi_dep"));
|
||||
let Ok(source) = self
|
||||
.registry
|
||||
.create_artifact_source(CreateArtifactSourceRequest {
|
||||
workspace_id,
|
||||
source_id: &source_id,
|
||||
artifact: &artifact,
|
||||
mime_type: "application/octet-stream",
|
||||
sensitivity: ArtifactSourceSensitivity::Internal,
|
||||
created_at,
|
||||
})
|
||||
.await
|
||||
else {
|
||||
materialization_failure(
|
||||
"source_create",
|
||||
"registry_unavailable",
|
||||
result.snapshots.len(),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let mut guard = SourceDetachGuard::new(
|
||||
self.registry.clone(),
|
||||
workspace_id.clone(),
|
||||
source_id.clone(),
|
||||
source.updated_at,
|
||||
);
|
||||
let Ok(verified) = self
|
||||
.registry
|
||||
.read_artifact_source(
|
||||
std::sync::Arc::clone(&self.artifact_store),
|
||||
workspace_id,
|
||||
&source_id,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
materialization_failure(
|
||||
"source_verify",
|
||||
"source_unavailable",
|
||||
result.snapshots.len(),
|
||||
);
|
||||
guard.detach_now().await;
|
||||
continue;
|
||||
};
|
||||
if verified.source.blob.artifact_ref != *artifact.artifact_ref() {
|
||||
materialization_failure(
|
||||
"source_verify",
|
||||
"source_integrity",
|
||||
result.snapshots.len(),
|
||||
);
|
||||
guard.detach_now().await;
|
||||
continue;
|
||||
}
|
||||
let index = result.envelopes.len();
|
||||
result.envelopes.push(ImportJobSourceEnvelope {
|
||||
source_id: source_id.clone(),
|
||||
digest: artifact.artifact_ref().clone(),
|
||||
});
|
||||
result.guards.push(guard);
|
||||
dependency_by_digest.insert(digest_key, index);
|
||||
index
|
||||
};
|
||||
let dependency = &result.envelopes[envelope_index];
|
||||
let snapshot_digest = match crank_import::rest::SourceDigest::parse(
|
||||
dependency.digest.digest_hex().to_owned(),
|
||||
) {
|
||||
Ok(digest) => digest,
|
||||
Err(_) => {
|
||||
materialization_failure("snapshot", "source_integrity", result.snapshots.len());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
result
|
||||
.snapshots
|
||||
.push(crank_import::rest::ExternalDocumentSnapshot {
|
||||
canonical_uri: canonical_uri.clone(),
|
||||
digest: snapshot_digest,
|
||||
document,
|
||||
});
|
||||
materialized_count.store(result.snapshots.len(), std::sync::atomic::Ordering::Relaxed);
|
||||
result.canonical_uris.push(canonical_uri.clone());
|
||||
for reference in references {
|
||||
if let Some(uri) = canonical_external_document_uri(Some(&canonical_uri), &reference)
|
||||
{
|
||||
queue.push_back((uri, depth.saturating_add(1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Registry ownership is one envelope per immutable digest, while the
|
||||
// resolver may map several canonical URIs to that same snapshot. Keep
|
||||
// payload rows aligned with envelopes and add aliases separately.
|
||||
if result.envelopes.len() != result.canonical_uris.len() {
|
||||
let mut canonical_by_digest = BTreeMap::new();
|
||||
for snapshot in &result.snapshots {
|
||||
canonical_by_digest
|
||||
.entry(snapshot.digest.as_str().to_owned())
|
||||
.or_insert_with(|| snapshot.canonical_uri.clone());
|
||||
}
|
||||
result.canonical_uris = result
|
||||
.envelopes
|
||||
.iter()
|
||||
.map(|envelope| {
|
||||
canonical_by_digest
|
||||
.get(envelope.digest.digest_hex())
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) async fn read_import_job_dependencies(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
payload: &serde_json::Value,
|
||||
locale: OpenApiUploadLocale,
|
||||
) -> Result<Vec<crank_import::rest::ExternalDocumentSnapshot>, ApiError> {
|
||||
let dependencies = import_job_dependencies(payload, locale)?;
|
||||
let mut snapshots = Vec::with_capacity(dependencies.len());
|
||||
for (canonical_uri, dependency) in dependencies {
|
||||
let verified = self
|
||||
.registry
|
||||
.read_artifact_source(
|
||||
std::sync::Arc::clone(&self.artifact_store),
|
||||
workspace_id,
|
||||
&dependency.source_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| openapi_source_error(locale, error))?;
|
||||
if verified.source.blob.artifact_ref != dependency.digest {
|
||||
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
||||
}
|
||||
let document = String::from_utf8(verified.bytes)
|
||||
.map_err(|_| ApiError::openapi_upload(locale, "source_integrity"))?;
|
||||
let digest =
|
||||
crank_import::rest::SourceDigest::parse(dependency.digest.digest_hex().to_owned())
|
||||
.map_err(|_| ApiError::openapi_upload(locale, "source_integrity"))?;
|
||||
snapshots.push(crank_import::rest::ExternalDocumentSnapshot {
|
||||
canonical_uri,
|
||||
digest,
|
||||
document,
|
||||
});
|
||||
}
|
||||
Ok(snapshots)
|
||||
}
|
||||
}
|
||||
fn external_fetch_error_code(error: &crank_runtime::ExternalReferenceFetchError) -> &'static str {
|
||||
match error {
|
||||
crank_runtime::ExternalReferenceFetchError::Disabled => "disabled",
|
||||
crank_runtime::ExternalReferenceFetchError::InvalidUrl => "invalid_url",
|
||||
crank_runtime::ExternalReferenceFetchError::TargetNotAllowed => "target_not_allowed",
|
||||
crank_runtime::ExternalReferenceFetchError::RedirectNotAllowed => "redirect_not_allowed",
|
||||
crank_runtime::ExternalReferenceFetchError::ResponseTooLarge { .. } => "response_too_large",
|
||||
crank_runtime::ExternalReferenceFetchError::UnexpectedStatus { .. } => "unexpected_status",
|
||||
crank_runtime::ExternalReferenceFetchError::Transport { timeout: true, .. } => "timeout",
|
||||
crank_runtime::ExternalReferenceFetchError::Transport { .. } => "transport",
|
||||
crank_runtime::ExternalReferenceFetchError::InvalidConfiguration => "invalid_configuration",
|
||||
}
|
||||
}
|
||||
|
||||
fn materialization_failure(stage: &'static str, error_code: &'static str, count: usize) {
|
||||
warn!(
|
||||
name: "admin.openapi_import.materialization_failed",
|
||||
stage,
|
||||
error_code,
|
||||
count,
|
||||
"external OpenAPI materialization failed"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crank_core::WorkspaceId;
|
||||
use crank_registry::{ArtifactSourceId, DetachArtifactSourceRequest, ImportJobSourceEnvelope};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{error::ApiError, service::OpenApiUploadLocale};
|
||||
|
||||
use super::canonical_external_document_uri;
|
||||
|
||||
const MAX_IMPORT_JOB_DOCUMENTS: usize = 32;
|
||||
|
||||
pub(super) fn import_job_source(
|
||||
payload: &serde_json::Value,
|
||||
locale: OpenApiUploadLocale,
|
||||
) -> Result<ImportJobSourceEnvelope, ApiError> {
|
||||
let source = payload
|
||||
.get("source")
|
||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_unavailable"))?;
|
||||
let source_id = source
|
||||
.get("source_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| value.len() <= 132)
|
||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_unavailable"))?;
|
||||
let digest = source
|
||||
.get("digest")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(|value| value.parse().ok())
|
||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
|
||||
Ok(ImportJobSourceEnvelope {
|
||||
source_id: ArtifactSourceId::new(source_id),
|
||||
digest,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn import_job_dependencies(
|
||||
payload: &serde_json::Value,
|
||||
locale: OpenApiUploadLocale,
|
||||
) -> Result<Vec<(String, ImportJobSourceEnvelope)>, ApiError> {
|
||||
let empty = Vec::new();
|
||||
let items = payload
|
||||
.get("dependency_snapshots")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.unwrap_or(&empty);
|
||||
if items.len() > MAX_IMPORT_JOB_DOCUMENTS {
|
||||
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
||||
}
|
||||
let mut canonical_uris = BTreeSet::new();
|
||||
items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let canonical_uri = item
|
||||
.get("canonical_uri")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| {
|
||||
canonical_external_document_uri(None, value).as_deref() == Some(*value)
|
||||
})
|
||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
|
||||
if !canonical_uris.insert(canonical_uri.to_owned()) {
|
||||
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
||||
}
|
||||
let source_id = item
|
||||
.get("source_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| value.len() <= 132)
|
||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
|
||||
let digest = item
|
||||
.get("digest")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(|value| value.parse().ok())
|
||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))?;
|
||||
Ok((
|
||||
canonical_uri.to_owned(),
|
||||
ImportJobSourceEnvelope {
|
||||
source_id: ArtifactSourceId::new(source_id),
|
||||
digest,
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn import_job_normalization_config(
|
||||
payload: &serde_json::Value,
|
||||
current: &crank_import::rest::NormalizationConfig,
|
||||
legacy_v1: bool,
|
||||
locale: OpenApiUploadLocale,
|
||||
) -> Result<crank_import::rest::NormalizationConfig, ApiError> {
|
||||
if legacy_v1 {
|
||||
return Ok(current.clone());
|
||||
}
|
||||
payload
|
||||
.pointer("/normalization/config")
|
||||
.cloned()
|
||||
.ok_or_else(|| ApiError::openapi_upload(locale, "source_integrity"))
|
||||
.and_then(|value| {
|
||||
serde_json::from_value(value)
|
||||
.map_err(|_| ApiError::openapi_upload(locale, "source_integrity"))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) struct SourceDetachGuard {
|
||||
registry: crank_registry::PostgresRegistry,
|
||||
workspace_id: WorkspaceId,
|
||||
source_id: ArtifactSourceId,
|
||||
expected_updated_at: OffsetDateTime,
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
impl SourceDetachGuard {
|
||||
pub(super) fn new(
|
||||
registry: crank_registry::PostgresRegistry,
|
||||
workspace_id: WorkspaceId,
|
||||
source_id: ArtifactSourceId,
|
||||
expected_updated_at: OffsetDateTime,
|
||||
) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
workspace_id,
|
||||
source_id,
|
||||
expected_updated_at,
|
||||
armed: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn disarm(&mut self) {
|
||||
self.armed = false;
|
||||
}
|
||||
|
||||
pub(super) async fn detach_now(&mut self) {
|
||||
if !self.armed {
|
||||
return;
|
||||
}
|
||||
self.armed = false;
|
||||
let _ = self
|
||||
.registry
|
||||
.detach_artifact_source(DetachArtifactSourceRequest {
|
||||
workspace_id: &self.workspace_id,
|
||||
source_id: &self.source_id,
|
||||
expected_updated_at: Some(self.expected_updated_at),
|
||||
detached_at: OffsetDateTime::now_utc(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SourceDetachGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.armed {
|
||||
return;
|
||||
}
|
||||
let registry = self.registry.clone();
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let source_id = self.source_id.clone();
|
||||
let expected_updated_at = Some(self.expected_updated_at);
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
handle.spawn(async move {
|
||||
let _ = registry
|
||||
.detach_artifact_source(DetachArtifactSourceRequest {
|
||||
workspace_id: &workspace_id,
|
||||
source_id: &source_id,
|
||||
expected_updated_at,
|
||||
detached_at: OffsetDateTime::now_utc(),
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user