feat(import): resolve references and schema composition

This commit is contained in:
2026-08-29 08:54:26 +03:00
parent 6c2a3712d8
commit 55209a9bbc
46 changed files with 4848 additions and 437 deletions
+1
View File
@@ -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 {
+65 -2
View File
@@ -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,
}
}
+255 -146
View File
@@ -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;
});
}
}
}
@@ -108,6 +108,24 @@ pub(super) fn build_test_app(
})
}
pub(super) fn build_test_app_with_external_references(
registry: PostgresRegistry,
storage_root: std::path::PathBuf,
allowed_url_prefixes: Vec<String>,
) -> Router {
build_app(AppState {
service: test_service_with_external_references(
registry,
storage_root,
allowed_url_prefixes,
),
api_rate_limiter: crank_runtime::RequestRateLimiter::new(
crank_runtime::RequestRateLimitConfig::new(10_000, 10_000).unwrap(),
),
trusted_proxy_ips: Vec::new(),
})
}
pub(super) fn build_test_app_with_audit_sink(
registry: PostgresRegistry,
storage_root: std::path::PathBuf,
@@ -152,6 +170,33 @@ pub(super) fn test_service(
.build()
}
pub(super) fn test_service_with_external_references(
registry: PostgresRegistry,
storage_root: std::path::PathBuf,
allowed_url_prefixes: Vec<String>,
) -> AdminService {
let outbound_policy = crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]);
let runtime = crank_runtime::community_with_outbound_policy(outbound_policy.clone()).build();
AdminServiceBuilder::new(
registry,
storage_root,
test_auth_settings(),
test_secret_crypto(),
runtime,
)
.with_external_reference_import(&crank_config::ExternalReferenceSettings {
allowed_url_prefixes,
max_depth: 8,
max_documents: 32,
max_fetch_bytes: 64 * 1024,
fetch_timeout_ms: 2_000,
max_expanded_nodes: 10_000,
})
.unwrap()
.with_outbound_http_policy(outbound_policy)
.build()
}
pub(super) async fn spawn_upstream_server() -> String {
let app = Router::new().route("/crm/leads", post(create_lead));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -1,4 +1,6 @@
use admin_api::service::{OpenApiImportCreatePayload, OpenApiUpload, OpenApiUploadLocale};
use admin_api::service::{
AdminServiceBuilder, OpenApiImportCreatePayload, OpenApiUpload, OpenApiUploadLocale,
};
use crank_core::WorkspaceId;
use crank_registry::ImportJobStatus;
use serial_test::serial;
@@ -7,6 +9,8 @@ use super::common::{
test_auth_settings, test_registry, test_secret_crypto, test_service, test_storage_root,
};
mod external_references;
const OPENAPI3: &str = r#"
openapi: 3.0.3
info:
@@ -73,11 +77,11 @@ async fn previews_openapi_and_creates_draft_operations() {
assert_eq!(preview_job.status, ImportJobStatus::Pending);
assert_eq!(
preview_job.preview_payload["normalization"]["normalizer_version"],
"normalized-ir-v2"
"normalized-ir-v3"
);
assert_eq!(
preview_job.preview_payload["normalization"]["projection_version"],
"preview-v2"
"preview-v3"
);
assert!(preview_job.preview_payload["normalization"]["ir_fingerprint"].is_string());
@@ -236,7 +240,7 @@ async fn unknown_selected_operations_are_persisted_in_the_canonical_replay() {
async fn concurrent_openapi_import_replays_the_same_atomic_result() {
let registry = test_registry().await;
let service = test_service(
registry,
registry.clone(),
test_storage_root("openapi_import_replay"),
test_auth_settings(),
test_secret_crypto(),
@@ -271,6 +275,31 @@ async fn concurrent_openapi_import_replays_the_same_atomic_result() {
service.list_operations(&workspace_id).await.unwrap().len(),
1
);
let completed_before = registry
.get_import_job(&workspace_id, &job_id)
.await
.unwrap()
.unwrap();
let empty = serde_json::json!([]);
let failed_at = time::OffsetDateTime::now_utc();
registry
.finish_import_job(crank_registry::FinishImportJobRequest {
id: &job_id,
status: ImportJobStatus::Failed,
created_operation_ids: &empty,
error_text: Some("late_verification_failure"),
finished_at: &failed_at,
})
.await
.unwrap();
let completed_after = registry
.get_import_job(&workspace_id, &job_id)
.await
.unwrap()
.unwrap();
assert_eq!(completed_after.status, ImportJobStatus::Completed);
assert_eq!(completed_after.error_text, completed_before.error_text);
assert_eq!(completed_after.finished_at, completed_before.finished_at);
let conflicting_replay = service
.create_openapi_import(
@@ -326,6 +355,13 @@ async fn apply_fails_closed_when_the_preview_parser_contract_drifts() {
.await;
assert!(result.is_err());
assert_failed_job_and_detached_sources(
&registry,
&workspace_id,
&job_id,
"import_replay_verification_failed",
)
.await;
assert!(
service
.list_operations(&workspace_id)
@@ -395,6 +431,13 @@ async fn apply_fails_closed_for_each_normalization_contract_field_and_digest_sha
.await
.is_err()
);
assert_failed_job_and_detached_sources(
&registry,
&workspace_id,
&job_id,
"import_replay_verification_failed",
)
.await;
assert!(
service
.list_operations(&workspace_id)
@@ -449,7 +492,7 @@ async fn legacy_job_without_normalization_contract_replays_until_expiry() {
async fn blocker_finding_keeps_valid_preview_but_prevents_draft_mutation() {
let registry = test_registry().await;
let service = test_service(
registry,
registry.clone(),
test_storage_root("openapi_import_blocker"),
test_auth_settings(),
test_secret_crypto(),
@@ -503,11 +546,124 @@ paths:
.unwrap()
.is_empty()
);
assert_failed_job_and_detached_sources(
&registry,
&workspace_id,
&preview.job_id.as_str().into(),
"reference_resolution_blocked",
)
.await;
}
#[tokio::test]
#[serial]
async fn operation_level_reference_blocker_prevents_draft_mutation() {
async fn operation_blockers_apply_only_to_selected_keys_and_full_selection_fails_atomically() {
let registry = test_registry().await;
let service = test_service(
registry.clone(),
test_storage_root("openapi_import_selected_blockers"),
test_auth_settings(),
test_secret_crypto(),
);
let workspace_id = WorkspaceId::new("ws_default");
let upload = OpenApiUpload {
bytes: br#"
openapi: 3.1.0
info: { title: Selected blockers }
servers: [{ url: https://api.example.test }]
paths:
/valid:
get:
operationId: validOperation
responses: { '200': { description: ok } }
/broken:
get:
operationId: brokenOperation
responses:
'200':
description: unresolved external schema
content:
application/json:
schema: { $ref: 'https://schemas.example.test/missing.yaml#/Result' }
"#
.to_vec(),
mime_type: "application/yaml".to_owned(),
locale: OpenApiUploadLocale::En,
};
let valid_preview = service
.preview_openapi_import(&workspace_id, upload.clone())
.await
.unwrap();
assert!(
valid_preview.preview.findings.iter().all(|finding| {
finding.severity != crank_import::rest::ImportFindingSeverity::Error
})
);
let broken = valid_preview
.preview
.groups
.iter()
.flat_map(|group| &group.operations)
.find(|operation| operation.key == "GET /broken")
.unwrap();
assert!(
broken.findings.iter().any(|finding| {
finding.severity == crank_import::rest::ImportFindingSeverity::Error
})
);
let valid = service
.create_openapi_import(
&workspace_id,
&valid_preview.job_id.as_str().into(),
OpenApiImportCreatePayload {
selected_operation_keys: vec!["GET /valid".to_owned()],
server_url: None,
conflict_mode: "skip".to_owned(),
},
)
.await
.unwrap();
assert_eq!(valid.created.len(), 1);
let full_preview = service
.preview_openapi_import(&workspace_id, upload)
.await
.unwrap();
let result = service
.create_openapi_import(
&workspace_id,
&full_preview.job_id.as_str().into(),
OpenApiImportCreatePayload {
selected_operation_keys: vec!["GET /valid".to_owned(), "GET /broken".to_owned()],
server_url: None,
conflict_mode: "rename".to_owned(),
},
)
.await;
assert!(result.is_err());
assert_eq!(
service.list_operations(&workspace_id).await.unwrap().len(),
1,
"the full selection must not create either selected Draft"
);
let failed = registry
.get_import_job(&workspace_id, &full_preview.job_id.as_str().into())
.await
.unwrap()
.unwrap();
assert_eq!(failed.status, ImportJobStatus::Failed);
assert_eq!(
failed.error_text.as_deref(),
Some("reference_resolution_blocked")
);
assert!(failed.finished_at.is_some());
}
#[tokio::test]
#[serial]
async fn local_operation_reference_resolves_and_creates_draft() {
let registry = test_registry().await;
let service = test_service(
registry,
@@ -520,6 +676,7 @@ async fn operation_level_reference_blocker_prevents_draft_mutation() {
bytes: br#"
openapi: 3.0.3
info: { title: References }
servers: [{ url: https://api.example.test }]
paths:
/referenced:
get:
@@ -551,32 +708,26 @@ components:
preview.preview.groups[0].operations[0]
.findings
.iter()
.any(|finding| {
finding.code == "unresolved_reference"
&& finding.severity == crank_import::rest::ImportFindingSeverity::Error
})
.all(|finding| finding.code != "unresolved_reference")
);
assert_eq!(preview.preview.groups[0].operations[0].output_fields, 1);
assert!(
service
.create_openapi_import(
&workspace_id,
&preview.job_id.as_str().into(),
OpenApiImportCreatePayload {
selected_operation_keys: vec!["GET /referenced".to_owned()],
server_url: None,
conflict_mode: "skip".to_owned(),
},
)
.await
.is_err()
);
assert!(
service
.list_operations(&workspace_id)
.await
.unwrap()
.is_empty()
let created = service
.create_openapi_import(
&workspace_id,
&preview.job_id.as_str().into(),
OpenApiImportCreatePayload {
selected_operation_keys: vec!["GET /referenced".to_owned()],
server_url: None,
conflict_mode: "skip".to_owned(),
},
)
.await
.unwrap();
assert_eq!(created.created.len(), 1);
assert_eq!(
service.list_operations(&workspace_id).await.unwrap().len(),
1
);
}
@@ -587,3 +738,44 @@ fn openapi_upload() -> OpenApiUpload {
locale: OpenApiUploadLocale::En,
}
}
async fn assert_failed_job_and_detached_sources(
registry: &crank_registry::PostgresRegistry,
workspace_id: &WorkspaceId,
job_id: &crank_registry::ImportJobId,
expected_error: &str,
) {
let job = registry
.get_import_job(workspace_id, job_id)
.await
.unwrap()
.unwrap();
assert_eq!(job.status, ImportJobStatus::Failed);
assert!(job.finished_at.is_some());
assert_eq!(job.error_text.as_deref(), Some(expected_error));
let mut source_ids = vec![
job.preview_payload["source"]["source_id"]
.as_str()
.unwrap()
.to_owned(),
];
source_ids.extend(
job.preview_payload["dependencies"]
.as_array()
.into_iter()
.flatten()
.map(|dependency| dependency["source_id"].as_str().unwrap().to_owned()),
);
for source_id in source_ids {
let lifecycle: String = sqlx::query_scalar(
"select lifecycle from artifact_sources where workspace_id = $1 and source_id = $2",
)
.bind(workspace_id.as_str())
.bind(source_id)
.fetch_one(registry.pool())
.await
.unwrap();
assert_eq!(lifecycle, "detached");
}
}
@@ -0,0 +1,538 @@
use super::super::common::test_service_with_external_references;
use super::*;
use axum::{Router, routing::get};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
#[tokio::test]
#[serial]
async fn external_relative_chain_survives_reversed_builder_order_and_apply_never_refetches() {
let fetches = Arc::new(AtomicUsize::new(0));
let root_fetches = Arc::clone(&fetches);
let child_fetches = Arc::clone(&fetches);
let app = Router::new()
.route(
"/root.yaml",
get(move || {
let fetches = Arc::clone(&root_fetches);
async move {
fetches.fetch_add(1, Ordering::SeqCst);
"Item: { $ref: './child.yaml#/Item' }"
}
}),
)
.route(
"/child.yaml",
get(move || {
let fetches = Arc::clone(&child_fetches);
async move {
fetches.fetch_add(1, Ordering::SeqCst);
"Item: { type: object, required: [id], properties: { id: { type: string } } }"
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let origin = format!("http://{address}");
let registry = test_registry().await;
let service = test_service_with_external_references(
registry.clone(),
test_storage_root("openapi_import_external_snapshot"),
vec![format!("{origin}/")],
);
let workspace_id = WorkspaceId::new("ws_default");
let upload = OpenApiUpload {
bytes: format!(
r#"
openapi: 3.1.0
info: {{ title: External }}
servers: [{{ url: https://api.example.test }}]
paths:
/items:
get:
operationId: listItems
responses:
'200':
description: ok
content:
application/json:
schema: {{ $ref: '{origin}/root.yaml#/Item' }}
"#
)
.into_bytes(),
mime_type: "application/yaml".to_owned(),
locale: OpenApiUploadLocale::En,
};
let preview = service
.preview_openapi_import(&workspace_id, upload)
.await
.unwrap();
assert_eq!(fetches.load(Ordering::SeqCst), 2);
assert_eq!(preview.preview.groups[0].operations[0].output_fields, 1);
let job = registry
.get_import_job(&workspace_id, &preview.job_id.as_str().into())
.await
.unwrap()
.unwrap();
assert_eq!(
job.preview_payload["dependencies"]
.as_array()
.unwrap()
.len(),
2
);
assert_eq!(
job.preview_payload["dependency_snapshots"]
.as_array()
.unwrap()
.len(),
2
);
let applied = service
.create_openapi_import(
&workspace_id,
&preview.job_id.as_str().into(),
OpenApiImportCreatePayload {
selected_operation_keys: vec!["GET /items".to_owned()],
server_url: None,
conflict_mode: "skip".to_owned(),
},
)
.await
.unwrap();
assert_eq!(applied.created.len(), 1);
assert_eq!(fetches.load(Ordering::SeqCst), 2);
let active_sources: i64 = sqlx::query_scalar(
"select count(*) from artifact_sources
where source_id like 'src_openapi_%' and lifecycle = 'active'",
)
.fetch_one(registry.pool())
.await
.unwrap();
assert_eq!(active_sources, 0);
}
#[tokio::test]
#[serial]
async fn missing_external_snapshot_fails_closed_before_draft_mutation() {
let app = Router::new().route(
"/schemas.yaml",
get(|| async { "Item: { type: object, properties: { id: { type: string } } }" }),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let origin = format!("http://{address}");
let registry = test_registry().await;
let storage_root = test_storage_root("openapi_import_missing_external_snapshot");
let service = test_service_with_external_references(
registry.clone(),
storage_root.clone(),
vec![format!("{origin}/")],
);
let workspace_id = WorkspaceId::new("ws_default");
let preview = service
.preview_openapi_import(
&workspace_id,
OpenApiUpload {
bytes: format!(
r#"
openapi: 3.1.0
info: {{ title: External integrity }}
servers: [{{ url: https://api.example.test }}]
paths:
/items:
get:
operationId: listItems
responses:
'200':
description: ok
content: {{ application/json: {{ schema: {{ $ref: '{origin}/schemas.yaml#/Item' }} }} }}
"#
)
.into_bytes(),
mime_type: "application/yaml".to_owned(),
locale: OpenApiUploadLocale::En,
},
)
.await
.unwrap();
let job = registry
.get_import_job(&workspace_id, &preview.job_id.as_str().into())
.await
.unwrap()
.unwrap();
let dependency_source_id = job.preview_payload["dependencies"][0]["source_id"]
.as_str()
.unwrap();
sqlx::query(
"update artifact_sources
set lifecycle = 'detached', updated_at = now(), detached_at = now()
where workspace_id = $1 and source_id = $2",
)
.bind(workspace_id.as_str())
.bind(dependency_source_id)
.execute(registry.pool())
.await
.unwrap();
assert!(
service
.create_openapi_import(
&workspace_id,
&preview.job_id.as_str().into(),
OpenApiImportCreatePayload {
selected_operation_keys: vec!["GET /items".to_owned()],
server_url: None,
conflict_mode: "skip".to_owned(),
},
)
.await
.is_err()
);
assert!(
service
.list_operations(&workspace_id)
.await
.unwrap()
.is_empty()
);
assert_failed_job_and_detached_sources(
&registry,
&workspace_id,
&preview.job_id.as_str().into(),
"import_dependency_verification_failed",
)
.await;
let corrupt_preview = service
.preview_openapi_import(
&workspace_id,
OpenApiUpload {
bytes: format!(
r#"
openapi: 3.1.0
info: {{ title: Corrupt external integrity }}
servers: [{{ url: https://api.example.test }}]
paths:
/items:
get:
operationId: listItemsCorrupt
responses:
'200':
description: ok
content: {{ application/json: {{ schema: {{ $ref: '{origin}/schemas.yaml#/Item' }} }} }}
"#
)
.into_bytes(),
mime_type: "application/yaml".to_owned(),
locale: OpenApiUploadLocale::En,
},
)
.await
.unwrap();
let corrupt_job = registry
.get_import_job(&workspace_id, &corrupt_preview.job_id.as_str().into())
.await
.unwrap()
.unwrap();
let dependency_ref: crank_artifacts::ArtifactRef = corrupt_job.preview_payload["dependencies"]
[0]["digest"]
.as_str()
.unwrap()
.parse()
.unwrap();
let dependency_path = storage_root
.join("sha256")
.join(&dependency_ref.digest_hex()[..2])
.join(dependency_ref.digest_hex());
std::fs::set_permissions(
&dependency_path,
std::os::unix::fs::PermissionsExt::from_mode(0o600),
)
.unwrap();
std::fs::write(&dependency_path, b"corrupt external dependency").unwrap();
std::fs::set_permissions(
&dependency_path,
std::os::unix::fs::PermissionsExt::from_mode(0o400),
)
.unwrap();
assert!(
service
.create_openapi_import(
&workspace_id,
&corrupt_preview.job_id.as_str().into(),
OpenApiImportCreatePayload {
selected_operation_keys: vec!["GET /items".to_owned()],
server_url: None,
conflict_mode: "skip".to_owned(),
},
)
.await
.is_err()
);
assert_failed_job_and_detached_sources(
&registry,
&workspace_id,
&corrupt_preview.job_id.as_str().into(),
"import_dependency_verification_failed",
)
.await;
}
#[tokio::test]
#[serial]
async fn external_materialization_uses_one_wall_clock_timeout_and_cleans_cancelled_sources() {
let fetches = Arc::new(AtomicUsize::new(0));
let root_fetches = Arc::clone(&fetches);
let child_fetches = Arc::clone(&fetches);
let app = Router::new()
.route(
"/root.yaml",
get(move || {
let fetches = Arc::clone(&root_fetches);
async move {
fetches.fetch_add(1, Ordering::SeqCst);
"Item: { $ref: './slow-child.yaml#/Item' }"
}
}),
)
.route(
"/slow-child.yaml",
get(move || {
let fetches = Arc::clone(&child_fetches);
async move {
fetches.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
"Item: { type: object, properties: { id: { type: string } } }"
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let origin = format!("http://{address}");
let registry = test_registry().await;
let outbound_policy = crank_runtime::OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]);
let runtime = crank_runtime::community_with_outbound_policy(outbound_policy.clone()).build();
let service = AdminServiceBuilder::new(
registry.clone(),
test_storage_root("openapi_import_chain_timeout"),
test_auth_settings(),
test_secret_crypto(),
runtime,
)
.with_external_reference_import(&crank_config::ExternalReferenceSettings {
allowed_url_prefixes: vec![format!("{origin}/")],
max_depth: 8,
max_documents: 32,
max_fetch_bytes: 64 * 1024,
fetch_timeout_ms: 200,
max_expanded_nodes: 10_000,
})
.unwrap()
.with_outbound_http_policy(outbound_policy)
.build();
let workspace_id = WorkspaceId::new("ws_default");
let started = tokio::time::Instant::now();
let preview = service
.preview_openapi_import(
&workspace_id,
OpenApiUpload {
bytes: format!(
r#"
openapi: 3.1.0
info: {{ title: Timed chain }}
servers: [{{ url: https://api.example.test }}]
paths:
/items:
get:
operationId: timedItems
responses:
'200':
description: ok
content: {{ application/json: {{ schema: {{ $ref: '{origin}/root.yaml#/Item' }} }} }}
"#
)
.into_bytes(),
mime_type: "application/yaml".to_owned(),
locale: OpenApiUploadLocale::En,
},
)
.await
.unwrap();
assert!(started.elapsed() < std::time::Duration::from_millis(800));
assert_eq!(fetches.load(Ordering::SeqCst), 2);
let job = registry
.get_import_job(&workspace_id, &preview.job_id.as_str().into())
.await
.unwrap()
.unwrap();
assert_eq!(
job.preview_payload["dependencies"]
.as_array()
.unwrap()
.len(),
0
);
for _ in 0..100 {
let active_dependencies: i64 = sqlx::query_scalar(
"select count(*) from artifact_sources
where source_id like 'src_openapi_dep_%' and lifecycle = 'active'",
)
.fetch_one(registry.pool())
.await
.unwrap();
if active_dependencies == 0 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
let detached_dependencies: i64 = sqlx::query_scalar(
"select count(*) from artifact_sources
where source_id like 'src_openapi_dep_%' and lifecycle = 'detached'",
)
.fetch_one(registry.pool())
.await
.unwrap();
assert_eq!(detached_dependencies, 1);
}
#[tokio::test]
#[serial]
async fn expired_job_detaches_primary_and_external_dependency_without_orphan() {
let app = Router::new().route(
"/schemas.yaml",
get(|| async { "Item: { type: object, properties: { id: { type: string } } }" }),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let origin = format!("http://{address}");
let registry = test_registry().await;
let service = test_service_with_external_references(
registry.clone(),
test_storage_root("openapi_import_external_expiry"),
vec![format!("{origin}/")],
);
let workspace_id = WorkspaceId::new("ws_default");
let preview = service
.preview_openapi_import(
&workspace_id,
OpenApiUpload {
bytes: format!(
r#"
openapi: 3.1.0
info: {{ title: Expiring external }}
servers: [{{ url: https://api.example.test }}]
paths:
/items:
get:
operationId: listItems
responses:
'200':
description: ok
content: {{ application/json: {{ schema: {{ $ref: '{origin}/schemas.yaml#/Item' }} }} }}
"#
)
.into_bytes(),
mime_type: "application/yaml".to_owned(),
locale: OpenApiUploadLocale::En,
},
)
.await
.unwrap();
sqlx::query("update import_jobs set expires_at = now() - interval '1 second' where id = $1")
.bind(preview.job_id.as_str())
.execute(registry.pool())
.await
.unwrap();
let report = registry.cleanup_expired_import_jobs(16).await.unwrap();
assert_eq!(report.deleted_jobs, 1);
assert_eq!(report.detached_sources, 2);
let active_sources: i64 = sqlx::query_scalar(
"select count(*) from artifact_sources
where source_id like 'src_openapi_%' and lifecycle = 'active'",
)
.fetch_one(registry.pool())
.await
.unwrap();
assert_eq!(active_sources, 0);
}
#[tokio::test]
#[serial]
async fn exact_v2_contract_replays_persisted_preview_without_v3_or_dependency_reads() {
let registry = test_registry().await;
let service = test_service(
registry.clone(),
test_storage_root("openapi_import_v2_compatibility"),
test_auth_settings(),
test_secret_crypto(),
);
let workspace_id = WorkspaceId::new("ws_default");
let preview = service
.preview_openapi_import(&workspace_id, openapi_upload())
.await
.unwrap();
let job_id: crank_registry::ImportJobId = preview.job_id.as_str().into();
let missing_dependency = serde_json::json!([{
"source_id": "src_openapi_dep_v2_must_not_be_read",
"digest": format!("sha256:{}", "0".repeat(64)),
"canonical_uri": "https://schemas.example.test/v2.yaml"
}]);
sqlx::query(
"update import_jobs
set preview_payload = jsonb_set(
jsonb_set(
jsonb_set(
jsonb_set(
preview_payload,
'{normalization,normalizer_version}',
to_jsonb('normalized-ir-v2'::text)
),
'{normalization,projection_version}',
to_jsonb('preview-v2'::text)
),
'{normalization,ir_fingerprint}',
to_jsonb($1::text)
),
'{dependency_snapshots}',
$2::jsonb
)
where id = $3",
)
.bind("a".repeat(64))
.bind(missing_dependency)
.bind(job_id.as_str())
.execute(registry.pool())
.await
.unwrap();
let result = service
.create_openapi_import(
&workspace_id,
&job_id,
OpenApiImportCreatePayload {
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
server_url: Some("https://api.frankfurter.dev".to_owned()),
conflict_mode: "skip".to_owned(),
},
)
.await
.unwrap();
assert_eq!(result.created.len(), 1);
let completed = registry
.get_import_job(&workspace_id, &job_id)
.await
.unwrap()
.unwrap();
assert_eq!(completed.status, ImportJobStatus::Completed);
}
@@ -1,30 +1,18 @@
use std::{
io,
sync::{Arc, Mutex},
};
use admin_api::service::{OpenApiImportCreatePayload, OpenApiUpload, OpenApiUploadLocale};
use crank_artifacts::MAX_ARTIFACT_BYTES;
use crank_core::{Workspace, WorkspaceId, WorkspaceStatus};
use crank_registry::{ArtifactSourceId, ArtifactSourceLifecycle, CreateWorkspaceRequest};
use metrics_util::debugging::DebuggingRecorder;
use opentelemetry::trace::TracerProvider as _;
use opentelemetry_sdk::{
error::OTelSdkResult,
trace::{SdkTracerProvider, SpanData, SpanExporter},
};
use reqwest::multipart::{Form, Part};
use serde_json::Value;
use serial_test::serial;
use time::{Duration, OffsetDateTime};
use tracing_subscriber::{fmt::MakeWriter, layer::SubscriberExt};
use super::common::{
authorized_client, build_test_app, spawn_admin_api, test_auth_settings, test_registry,
test_secret_crypto, test_service, test_storage_root,
};
use admin_api::service::{OpenApiImportCreatePayload, OpenApiUpload, OpenApiUploadLocale};
use crank_artifacts::MAX_ARTIFACT_BYTES;
use crank_core::{Workspace, WorkspaceId, WorkspaceStatus};
use crank_registry::{ArtifactSourceId, ArtifactSourceLifecycle, CreateWorkspaceRequest};
use reqwest::multipart::{Form, Part};
use serde_json::Value;
use serial_test::serial;
use time::{Duration, OffsetDateTime};
mod apply_failures;
mod telemetry;
const OPENAPI: &str = r#"
openapi: 3.0.3
@@ -136,78 +124,6 @@ async fn multipart_requires_an_owner_membership_before_reading_the_file() {
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn parser_canary_never_reaches_diagnostics_logs_traces_or_metrics() {
const CANARY: &str = "openapi-telemetry-secret-canary";
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
recorder
.install()
.expect("isolated integration test metrics recorder");
let writer = SharedLogWriter::default();
let exported = Arc::new(Mutex::new(Vec::new()));
let provider = SdkTracerProvider::builder()
.with_simple_exporter(CapturingExporter(Arc::clone(&exported)))
.build();
let tracer = provider.tracer("admin-openapi-source-test");
let subscriber = tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer().with_writer(writer.clone()))
.with(tracing_opentelemetry::layer().with_tracer(tracer));
let dispatch = tracing::Dispatch::new(subscriber);
tracing::dispatcher::set_global_default(dispatch)
.expect("isolated integration test tracing subscriber");
let registry = test_registry().await;
let app = build_test_app(registry, test_storage_root("openapi_telemetry_canary"));
let server = spawn_admin_api(app).await;
let client = authorized_client(&server).await;
let document = format!(
"openapi: 3.0.3\ninfo: {{ title: Canary }}\npaths:\n /broken:\n get:\n description: {CANARY}\n responses: ["
);
let response = client
.post(format!("{server}/imports/openapi/preview"))
.multipart(Form::new().part(
"file",
file_part(document.as_bytes(), "openapi.yaml", "application/yaml"),
))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
let trace_id = response
.headers()
.get("x-trace-id")
.unwrap()
.to_str()
.unwrap()
.to_owned();
let body = response.text().await.unwrap();
assert!(!body.contains(CANARY));
let diagnostics: Value = serde_json::from_str(&body).unwrap();
assert_eq!(diagnostics["error"]["trace_id"], trace_id);
provider.force_flush().unwrap();
let logs = writer.output();
assert!(!logs.contains(CANARY));
assert!(logs.contains(&trace_id));
let spans = exported.lock().unwrap();
let rendered_spans = format!("{spans:?}");
assert!(!rendered_spans.contains(CANARY));
drop(spans);
for (key, _, _, _) in snapshotter.snapshot().into_vec() {
assert!(!key.key().name().contains(CANARY));
assert!(
!key.key()
.labels()
.any(|label| { label.key().contains(CANARY) || label.value().contains(CANARY) })
);
}
provider.shutdown().unwrap();
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn multipart_boundary_rejections_are_localized_and_leave_no_entities() {
@@ -939,49 +855,3 @@ async fn wait_for_specific_source_lifecycle(
}
panic!("artifact source {source_id:?} did not reach {expected}");
}
#[derive(Clone, Default)]
struct SharedLogWriter {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl SharedLogWriter {
fn output(&self) -> String {
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
}
}
impl<'a> MakeWriter<'a> for SharedLogWriter {
type Writer = SharedLogGuard;
fn make_writer(&'a self) -> Self::Writer {
SharedLogGuard {
buffer: Arc::clone(&self.buffer),
}
}
}
struct SharedLogGuard {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl io::Write for SharedLogGuard {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.buffer.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[derive(Clone, Debug)]
struct CapturingExporter(Arc<Mutex<Vec<SpanData>>>);
impl SpanExporter for CapturingExporter {
async fn export(&self, batch: Vec<SpanData>) -> OTelSdkResult {
self.0.lock().unwrap().extend(batch);
Ok(())
}
}
@@ -0,0 +1,184 @@
use super::super::common::build_test_app_with_external_references;
use super::*;
use std::{
io,
sync::{Arc, Mutex},
};
use metrics_util::debugging::DebuggingRecorder;
use opentelemetry::trace::TracerProvider as _;
use opentelemetry_sdk::{
error::OTelSdkResult,
trace::{SdkTracerProvider, SpanData, SpanExporter},
};
use tracing_subscriber::{fmt::MakeWriter, layer::SubscriberExt};
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn parser_canary_never_reaches_diagnostics_logs_traces_or_metrics() {
const CANARY: &str = "openapi-telemetry-secret-canary";
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
recorder
.install()
.expect("isolated integration test metrics recorder");
let writer = SharedLogWriter::default();
let exported = Arc::new(Mutex::new(Vec::new()));
let provider = SdkTracerProvider::builder()
.with_simple_exporter(CapturingExporter(Arc::clone(&exported)))
.build();
let tracer = provider.tracer("admin-openapi-source-test");
let subscriber = tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer().with_writer(writer.clone()))
.with(tracing_opentelemetry::layer().with_tracer(tracer));
let dispatch = tracing::Dispatch::new(subscriber);
tracing::dispatcher::set_global_default(dispatch)
.expect("isolated integration test tracing subscriber");
let registry = test_registry().await;
let app = build_test_app(registry, test_storage_root("openapi_telemetry_canary"));
let server = spawn_admin_api(app).await;
let client = authorized_client(&server).await;
let document = format!(
"openapi: 3.0.3\ninfo: {{ title: Canary }}\npaths:\n /broken:\n get:\n description: {CANARY}\n responses: ["
);
let response = client
.post(format!("{server}/imports/openapi/preview"))
.multipart(Form::new().part(
"file",
file_part(document.as_bytes(), "openapi.yaml", "application/yaml"),
))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
let trace_id = response
.headers()
.get("x-trace-id")
.unwrap()
.to_str()
.unwrap()
.to_owned();
let body = response.text().await.unwrap();
assert!(!body.contains(CANARY));
let diagnostics: Value = serde_json::from_str(&body).unwrap();
assert_eq!(diagnostics["error"]["trace_id"], trace_id);
let route = format!("/{CANARY}.yaml");
let external = axum::Router::new().route(
&route,
axum::routing::get(|| async { (axum::http::StatusCode::INTERNAL_SERVER_ERROR, CANARY) }),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, external).await.unwrap() });
let origin = format!("http://{address}");
let external_registry = test_registry().await;
let external_app = build_test_app_with_external_references(
external_registry,
test_storage_root("openapi_materialization_telemetry_canary"),
vec![format!("{origin}/")],
);
let external_server = spawn_admin_api(external_app).await;
let external_client = authorized_client(&external_server).await;
let external_document = format!(
r#"
openapi: 3.1.0
info: {{ title: Safe materialization }}
servers: [{{ url: https://api.example.test }}]
paths:
/items:
get:
responses:
'200':
description: ok
content:
application/json:
schema: {{ $ref: '{origin}/{CANARY}.yaml#/Item' }}
"#
);
let response = external_client
.post(format!("{external_server}/imports/openapi/preview"))
.multipart(Form::new().part(
"file",
file_part(
external_document.as_bytes(),
"external.yaml",
"application/yaml",
),
))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::OK);
provider.force_flush().unwrap();
let logs = writer.output();
assert!(!logs.contains(CANARY));
assert!(logs.contains(&trace_id));
assert!(logs.contains("external OpenAPI materialization failed"));
assert!(logs.contains("stage=\"fetch\""));
assert!(logs.contains("error_code=\"unexpected_status\""));
assert!(logs.contains("count=0"));
let spans = exported.lock().unwrap();
let rendered_spans = format!("{spans:?}");
assert!(!rendered_spans.contains(CANARY));
drop(spans);
for (key, _, _, _) in snapshotter.snapshot().into_vec() {
assert!(!key.key().name().contains(CANARY));
assert!(
!key.key()
.labels()
.any(|label| { label.key().contains(CANARY) || label.value().contains(CANARY) })
);
}
provider.shutdown().unwrap();
}
#[derive(Clone, Default)]
struct SharedLogWriter {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl SharedLogWriter {
fn output(&self) -> String {
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
}
}
impl<'a> MakeWriter<'a> for SharedLogWriter {
type Writer = SharedLogGuard;
fn make_writer(&'a self) -> Self::Writer {
SharedLogGuard {
buffer: Arc::clone(&self.buffer),
}
}
}
struct SharedLogGuard {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl io::Write for SharedLogGuard {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.buffer.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[derive(Clone, Debug)]
struct CapturingExporter(Arc<Mutex<Vec<SpanData>>>);
impl SpanExporter for CapturingExporter {
async fn export(&self, batch: Vec<SpanData>) -> OTelSdkResult {
self.0.lock().unwrap().extend(batch);
Ok(())
}
}