From 4de4b7236501d92fdeb2d85f8abbd9707df2ff7f Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 22:01:14 +0000 Subject: [PATCH 1/2] Serve the external payload storage diagnostic natively POST /api/storage/test validates its request with Laravel's messages, refuses an unconfigured, disabled or mismatched driver with published PHP's bodies, and round-trips a generated small and large payload through the local driver. The object is removed only when no registered payload shares its content-addressed URI. A storage-test parity fixture records identical bodies from PHP 2.5.14 and native. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/server-parity.yml | 4 +- docs/rust-server-port.md | 23 ++ rust/src/external_payload/local.rs | 13 + rust/src/runtime/execution.rs | 9 + rust/src/runtime/external_registry.rs | 53 +++ rust/src/runtime/http.rs | 12 + rust/src/runtime/laravel_validation.rs | 60 ++++ rust/src/runtime/mod.rs | 4 +- rust/src/runtime/search_attributes.rs | 95 ++---- rust/src/runtime/storage_test.rs | 320 ++++++++++++++++++ rust/tests/execution.rs | 3 + rust/tests/execution/storage_test.rs | 145 ++++++++ .../conformance/server-parity/contract.mjs | 3 + scripts/conformance/server-parity/probe.php | 6 + .../server-parity/storage-test-contract.mjs | 64 ++++ .../server-parity/storage-test-probe.php | 46 +++ tests/Fixtures/ServerParity/storage-test.json | 23 ++ .../ServerParityStorageTestContractTest.mjs | 78 +++++ 18 files changed, 883 insertions(+), 78 deletions(-) create mode 100644 rust/src/runtime/laravel_validation.rs create mode 100644 rust/src/runtime/storage_test.rs create mode 100644 rust/tests/execution/storage_test.rs create mode 100644 scripts/conformance/server-parity/storage-test-contract.mjs create mode 100644 scripts/conformance/server-parity/storage-test-probe.php create mode 100644 tests/Fixtures/ServerParity/storage-test.json create mode 100644 tests/Unit/ServerParityStorageTestContractTest.mjs diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index e512b6c46..dd28014bd 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -25,6 +25,7 @@ on: - tests/Unit/ServerParityRoleTokenContractTest.mjs - tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs - tests/Unit/ServerParitySearchAttributeContractTest.mjs + - tests/Unit/ServerParityStorageTestContractTest.mjs - tests/Unit/ServerParityWaitingHistoryContractTest.mjs - tests/Unit/ServerParityTaskRetryContractTest.mjs - tests/Unit/ServerParityPublishedPatchContractTest.mjs @@ -63,6 +64,7 @@ on: - tests/Unit/ServerParityRoleTokenContractTest.mjs - tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs - tests/Unit/ServerParitySearchAttributeContractTest.mjs + - tests/Unit/ServerParityStorageTestContractTest.mjs - tests/Unit/ServerParityWaitingHistoryContractTest.mjs - tests/Unit/ServerParityTaskRetryContractTest.mjs - tests/Unit/ServerParityPublishedPatchContractTest.mjs @@ -1422,7 +1424,7 @@ jobs: -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress docker run --rm --user=1000:1000 --entrypoint node -v "$PWD:/source:ro" -w /source \ - "$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs > parity-evidence/comparator-tests.txt + "$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs > parity-evidence/comparator-tests.txt docker run --rm --user=1000:1000 --cpus=1 --memory=256m --memory-swap=256m \ --network=none --entrypoint node -v "$PWD:/source:ro" -w /source \ "$image" --test tests/Unit/ServerParityCodecDeadlineLoadTest.mjs > parity-evidence/codec-load-tests.txt diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 7f3c32247..341e8c663 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -142,6 +142,29 @@ support is not evidence of multi-node safety. Newest checkpoints first. Each entry is exact to the source, tree and runs it names, including unsuccessful observations. +October 20, external payload storage diagnostic: + +- **Source.** Native serves `POST /api/storage/test` for operators, in the + default or a named namespace, as PHP's `StorageController::test` does: + - Laravel's validation messages and error counts for `driver` and the two + payload sizes (shared with the search attribute registry through + `laravel_validation`); + - `external_storage_not_configured`, `external_storage_disabled` and + `storage_driver_unavailable` refusals with PHP's bodies; + - a small and large generated payload written to, read back from and + removed from the local driver, with each one's size, SHA-256 and the + digest of its object URI. +- **Shared objects.** Objects are content-addressed, so a diagnostic payload + can have the same bytes as a registered external payload. Native holds the + payload object lock across the round trip and removes the object only when + no registered payload refers to it. PHP removes it unconditionally. +- **Not covered.** S3, GCS, Azure and custom drivers are refused as + unavailable in this runtime, as published PHP does without their SDKs. +- **Fixture.** `storage-test` drives a fresh namespace through no policy, a + disabled policy, another driver, three invalid bodies and two passing round + trips. Published PHP 2.5.14 and native returned identical bodies, including + the reference identities, and embedded PHP declares it inapplicable. + October 20, search attribute registry: - **Source.** Native serves `GET` and `POST /api/search-attributes` and diff --git a/rust/src/external_payload/local.rs b/rust/src/external_payload/local.rs index 822661669..364e660b0 100644 --- a/rust/src/external_payload/local.rs +++ b/rust/src/external_payload/local.rs @@ -149,6 +149,19 @@ impl LocalStorage { .map_err(unavailable)? } + /// Removes the object. The caller holds the object lock and has checked + /// that no registered payload shares it. + pub async fn delete(&self, reference: &Reference) -> Result<(), SnapshotError> { + let path = self.path(reference)?; + tokio::task::spawn_blocking(move || match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(unavailable(error)), + }) + .await + .map_err(unavailable)? + } + pub async fn fetch( &self, reference: Reference, diff --git a/rust/src/runtime/execution.rs b/rust/src/runtime/execution.rs index 15acf25bb..a5a7ada02 100644 --- a/rust/src/runtime/execution.rs +++ b/rust/src/runtime/execution.rs @@ -295,6 +295,15 @@ impl Runtime { delegate!(describe_namespace(name: &str) -> Result); delegate!(create_namespace(body: Value) -> Result); delegate!(list_search_attributes() -> Result); + delegate!( + storage_test_round_trip( + local: &crate::external_payload::LocalStorage, + reference: crate::external_payload::Reference, + payload: Vec, + max_bytes: u64, + workers: Arc, + ) -> Result<()> + ); delegate!(create_search_attribute(body: Value) -> Result); delegate!(delete_search_attribute(name: &str) -> Result); delegate!(set_external_policy(name: &str, body: Value) -> Result); diff --git a/rust/src/runtime/external_registry.rs b/rust/src/runtime/external_registry.rs index 472b4256f..484dab8d0 100644 --- a/rust/src/runtime/external_registry.rs +++ b/rust/src/runtime/external_registry.rs @@ -100,6 +100,59 @@ where Ok(()) } + /// The storage diagnostic's write, read and delete of a generated object, + /// under the object lock that uploads and cleanup take. Local objects are + /// content-addressed, so a registered payload with the same bytes keeps + /// its object: the diagnostic deletes only an object nothing references. + pub(super) async fn storage_test_round_trip( + &self, + local: &crate::external_payload::LocalStorage, + reference: Reference, + payload: Vec, + max_bytes: u64, + workers: std::sync::Arc, + ) -> Result<()> { + use super::external_transport::snapshot_error; + let uri = local.uri_for(&reference).map_err(snapshot_error)?; + let mut tx = self.begin().await?; + Self::payload_object_lock(&mut tx, &uri).await?; + let registered = + Self::scalar("SELECT COUNT(*) FROM runtime_external_payloads WHERE storage_uri=$1") + .bind(&uri) + .fetch_one(&mut *tx) + .await? + > 0; + let snapshot = crate::external_payload::capture( + std::io::Cursor::new(payload), + reference.clone(), + max_bytes, + std::env::temp_dir(), + workers.clone(), + ) + .await + .map_err(snapshot_error)?; + local + .put(reference.clone(), snapshot) + .await + .map_err(snapshot_error)?; + // Fetch verifies the stored size and digest against the reference. + local + .fetch( + reference.clone(), + &uri, + max_bytes, + std::env::temp_dir(), + workers, + ) + .await + .map_err(snapshot_error)?; + if !registered { + local.delete(&reference).await.map_err(snapshot_error)?; + } + tx.commit().await?; + Ok(()) + } + /// The caller commits this writing row before any provider effect. A retry /// keeps the existing ID, ready status and retained ownership; abandoned /// unclaimed retries renew only their upload expiry, matching PHP. diff --git a/rust/src/runtime/http.rs b/rust/src/runtime/http.rs index 6f279dab4..c3c63389f 100644 --- a/rust/src/runtime/http.rs +++ b/rust/src/runtime/http.rs @@ -42,6 +42,7 @@ pub fn router(runtime: Runtime) -> Router { "/api/external-payloads/v1/{reference_id}", get(super::external_transport::fetch), ) + .route("/api/storage/test", post(storage_test)) .route( "/api/search-attributes", get(list_search_attributes).post(create_search_attribute), @@ -276,6 +277,7 @@ async fn guard(State(runtime): State, mut request: Request, next: Next) &["worker"] } else if path.starts_with("/api/workers/") || path == "/api/namespaces" && request.method() == Method::POST + || path == "/api/storage/test" || path.starts_with("/api/namespaces/") && path.ends_with("/external-storage") && request.method() == Method::PUT @@ -413,6 +415,9 @@ fn named_namespace_operation(method: &str, path: &str) -> bool { if path == "/api/workflows" { return method == "POST"; } + if path == "/api/storage/test" { + return method == "POST"; + } // The registry is namespace-scoped state with no default-namespace selector. if path == "/api/search-attributes" { return matches!(method, "GET" | "POST"); @@ -493,6 +498,13 @@ async fn create_namespace( )) } +async fn storage_test( + Extension(runtime): Extension, + Json(body): Json, +) -> Result> { + Ok(Json(super::storage_test::storage_test(&runtime, body).await?)) +} + async fn list_search_attributes(Extension(runtime): Extension) -> Result> { Ok(Json(runtime.list_search_attributes().await?)) } diff --git a/rust/src/runtime/laravel_validation.rs b/rust/src/runtime/laravel_validation.rs new file mode 100644 index 000000000..a24d950ca --- /dev/null +++ b/rust/src/runtime/laravel_validation.rs @@ -0,0 +1,60 @@ +//! Laravel's request normalization and validation failure body, as published +//! PHP renders them for control-plane requests. +use super::RuntimeError; +use axum::http::StatusCode; +use serde_json::{Map, Value, json}; + +/// Laravel's TrimStrings and ConvertEmptyStringsToNull run before validation. +pub(super) fn normalized(value: Option<&Value>) -> Option { + let trimmed = |c: char| c.is_whitespace() || matches!(c, '\u{feff}' | '\u{200b}' | '\u{200e}'); + match value { + Some(Value::String(text)) => { + let text = text.trim_matches(trimmed); + Some(if text.is_empty() { + Value::Null + } else { + json!(text) + }) + } + other => other.cloned(), + } +} + +/// Laravel's `required`: null, an empty string and an empty array fail. +pub(super) fn present(value: Option<&Value>) -> bool { + match value { + None | Some(Value::Null) => false, + Some(Value::String(text)) => !text.trim().is_empty(), + Some(Value::Array(items)) => !items.is_empty(), + Some(Value::Object(items)) => !items.is_empty(), + Some(_) => true, + } +} + +/// Laravel's 422 ValidationException body for control-plane requests. Fields +/// without messages are left out; the summary counts the remaining messages. +pub(super) fn failure(errors: Vec<(&'static str, Vec<&'static str>)>) -> Option { + let errors: Vec<_> = errors + .into_iter() + .filter(|(_, messages)| !messages.is_empty()) + .collect(); + let all: Vec<&str> = errors + .iter() + .flat_map(|(_, messages)| messages.iter().copied()) + .collect(); + let mut message = (*all.first()?).to_owned(); + match all.len() - 1 { + 0 => {} + 1 => message.push_str(" (and 1 more error)"), + more => message.push_str(&format!(" (and {more} more errors)")), + } + let errors: Map = errors + .into_iter() + .map(|(field, messages)| (field.to_owned(), json!(messages))) + .collect(); + Some(RuntimeError::Protocol { + status: StatusCode::UNPROCESSABLE_ENTITY, + response: json!({"message":message,"reason":"validation_failed", + "errors":errors.clone(),"validation_errors":errors}), + }) +} diff --git a/rust/src/runtime/mod.rs b/rust/src/runtime/mod.rs index f99645da1..8df06bcc8 100644 --- a/rust/src/runtime/mod.rs +++ b/rust/src/runtime/mod.rs @@ -29,6 +29,7 @@ mod external_wait_summary; mod failure_commands; mod history_budget; mod http; +mod laravel_validation; mod memo_json; mod memo_limits; mod memos; @@ -46,11 +47,12 @@ mod schedule_execution; mod schedule_http; mod schedule_storage; mod schedules; -mod search_attributes; mod schema; +mod search_attributes; mod selection_summary; mod selections; mod sqlite; +mod storage_test; mod store; mod task_failures; mod task_repair; diff --git a/rust/src/runtime/search_attributes.rs b/rust/src/runtime/search_attributes.rs index 2a3bfc46c..4713cbac3 100644 --- a/rust/src/runtime/search_attributes.rs +++ b/rust/src/runtime/search_attributes.rs @@ -4,6 +4,7 @@ use super::{ Result, RuntimeError, backend::Backend, + laravel_validation::{failure, normalized, present}, memo_limits, store::{Store, now}, }; @@ -85,33 +86,6 @@ fn reserved(name: &str) -> bool { || name.starts_with("__") } -/// Laravel's TrimStrings and ConvertEmptyStringsToNull run before validation. -fn normalized(value: Option<&Value>) -> Option { - let trimmed = |c: char| c.is_whitespace() || matches!(c, '\u{feff}' | '\u{200b}' | '\u{200e}'); - match value { - Some(Value::String(text)) => { - let text = text.trim_matches(trimmed); - Some(if text.is_empty() { - Value::Null - } else { - json!(text) - }) - } - other => other.cloned(), - } -} - -/// Laravel's `required`: null, an empty string and an empty array fail. -fn present(value: Option<&Value>) -> bool { - match value { - None | Some(Value::Null) => false, - Some(Value::String(text)) => !text.trim().is_empty(), - Some(Value::Array(items)) => !items.is_empty(), - Some(Value::Object(items)) => !items.is_empty(), - Some(_) => true, - } -} - /// The messages for `required|string|max:128|regex:/^[a-zA-Z][a-zA-Z0-9_]*$/`. /// A failed `required` stops the remaining rules, as Laravel's implicit rules do. fn name_messages(value: Option<&Value>) -> Vec<&'static str> { @@ -163,27 +137,18 @@ fn type_messages(value: Option<&Value>) -> Vec<&'static str> { messages } -/// Laravel's 422 ValidationException body for control-plane requests. -fn validation_failure(errors: Vec<(&'static str, Vec<&'static str>)>) -> RuntimeError { - let all: Vec<&str> = errors - .iter() - .flat_map(|(_, messages)| messages.iter().copied()) - .collect(); - let mut message = all[0].to_owned(); - match all.len() - 1 { - 0 => {} - 1 => message.push_str(" (and 1 more error)"), - more => message.push_str(&format!(" (and {more} more errors)")), - } - let errors: Map = errors - .into_iter() - .map(|(field, messages)| (field.to_owned(), json!(messages))) - .collect(); - RuntimeError::Protocol { - status: StatusCode::UNPROCESSABLE_ENTITY, - response: json!({"message":message,"reason":"validation_failed", - "errors":errors.clone(),"validation_errors":errors}), +/// Laravel's validation of a registration body: the normalized name and type, +/// or PHP's 422 body. +fn validated(body: &Value) -> Result<(Value, Value)> { + let name = normalized(body.get("name")); + let kind = normalized(body.get("type")); + if let Some(error) = failure(vec![ + ("name", name_messages(name.as_ref())), + ("type", type_messages(kind.as_ref())), + ]) { + return Err(error); } + Ok((name.unwrap_or_default(), kind.unwrap_or_default())) } fn conflict(status: StatusCode, response: Value) -> RuntimeError { @@ -242,20 +207,9 @@ where } pub(super) async fn create_search_attribute(&self, body: Value) -> Result { - let name = normalized(body.get("name")); - let kind = normalized(body.get("type")); - let errors: Vec<_> = [ - ("name", name_messages(name.as_ref())), - ("type", type_messages(kind.as_ref())), - ] - .into_iter() - .filter(|(_, messages)| !messages.is_empty()) - .collect(); - if !errors.is_empty() { - return Err(validation_failure(errors)); - } - let name = name.as_ref().and_then(Value::as_str).unwrap_or_default(); - let kind = kind.as_ref().and_then(Value::as_str).unwrap_or_default(); + let (name, kind) = validated(&body)?; + let name = name.as_str().unwrap_or_default(); + let kind = kind.as_str().unwrap_or_default(); if reserved(name) { return Err(conflict( StatusCode::CONFLICT, @@ -337,21 +291,10 @@ mod tests { use super::*; fn messages(body: Value) -> Option { - let name = normalized(body.get("name")); - let kind = normalized(body.get("type")); - let errors: Vec<_> = [ - ("name", name_messages(name.as_ref())), - ("type", type_messages(kind.as_ref())), - ] - .into_iter() - .filter(|(_, messages)| !messages.is_empty()) - .collect(); - if errors.is_empty() { - return None; - } - match validation_failure(errors) { - RuntimeError::Protocol { response, .. } => Some(response), - _ => unreachable!(), + match validated(&body) { + Ok(_) => None, + Err(RuntimeError::Protocol { response, .. }) => Some(response), + Err(_) => unreachable!(), } } diff --git a/rust/src/runtime/storage_test.rs b/rust/src/runtime/storage_test.rs new file mode 100644 index 000000000..70a474392 --- /dev/null +++ b/rust/src/runtime/storage_test.rs @@ -0,0 +1,320 @@ +//! The external payload storage diagnostic, as PHP's StorageController::test +//! runs it: a generated small and large payload written to, read from and +//! deleted from the namespace's configured driver. Native serves the local +//! driver; other drivers are refused as unavailable in this runtime. +use super::{ + Result, Runtime, RuntimeError, + laravel_validation::{failure, normalized, present}, + store::id, +}; +use crate::external_payload::{REFERENCE_SCHEMA, Reference}; +use axum::http::StatusCode; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +const DRIVERS: [&str; 5] = ["local", "s3", "gcs", "azure", "custom"]; + +/// One `required|integer|min:1|max:N` field and its Laravel messages. +struct Bytes { + required: &'static str, + integer: &'static str, + min: &'static str, + max: &'static str, + limit: i64, +} + +const SMALL: Bytes = Bytes { + required: "The small payload bytes field is required.", + integer: "The small payload bytes field must be an integer.", + min: "The small payload bytes field must be at least 1.", + max: "The small payload bytes field must not be greater than 1048576.", + limit: 1_048_576, +}; + +const LARGE: Bytes = Bytes { + required: "The large payload bytes field is required.", + integer: "The large payload bytes field must be an integer.", + min: "The large payload bytes field must be at least 1.", + max: "The large payload bytes field must not be greater than 16777216.", + limit: 16_777_216, +}; + +/// PHP's `filter_var($value, FILTER_VALIDATE_INT)`, which Laravel's +/// `integer` rule uses: canonical decimal strings, integral numbers and true. +fn integer(value: &Value) -> Option { + match value { + Value::Number(number) => number.as_i64().or_else(|| { + number + .as_f64() + .filter(|float| float.fract() == 0.0 && float.abs() < 9.0e18) + .map(|float| float as i64) + }), + Value::String(text) => { + let digits = text.strip_prefix(['+', '-']).unwrap_or(text); + let canonical = digits == "0" + || !digits.is_empty() + && !digits.starts_with('0') + && digits.bytes().all(|byte| byte.is_ascii_digit()); + canonical.then(|| text.parse().ok()).flatten() + } + Value::Bool(true) => Some(1), + _ => None, + } +} + +/// Laravel's size under a numeric rule set: a numeric value's magnitude, +/// otherwise a string's length or an array's count. +fn size(value: &Value) -> f64 { + match value { + Value::Number(number) => number.as_f64().unwrap_or_default(), + Value::String(text) => { + let numeric = text + .bytes() + .all(|byte| byte.is_ascii_digit() || b"+-.eE".contains(&byte)) + && text.bytes().any(|byte| byte.is_ascii_digit()); + numeric + .then(|| text.parse::().ok()) + .flatten() + .unwrap_or(text.chars().count() as f64) + } + Value::Array(items) => items.len() as f64, + Value::Object(items) => items.len() as f64, + Value::Bool(true) => 1.0, + Value::Bool(false) | Value::Null => 0.0, + } +} + +/// A failed `required` stops the field's remaining rules. +fn bytes_messages(value: Option<&Value>, rules: &Bytes) -> Vec<&'static str> { + let Some(value) = value.filter(|value| present(Some(value))) else { + return vec![rules.required]; + }; + let mut messages = Vec::new(); + if integer(value).is_none() { + messages.push(rules.integer); + } + let size = size(value); + if size < 1.0 { + messages.push(rules.min); + } + if size > rules.limit as f64 { + messages.push(rules.max); + } + messages +} + +/// `nullable|string|in:local,s3,gcs,azure,custom`. +fn driver_messages(value: Option<&Value>) -> Vec<&'static str> { + match value { + None | Some(Value::Null) => Vec::new(), + Some(Value::String(driver)) if DRIVERS.contains(&driver.as_str()) => Vec::new(), + Some(Value::String(_)) => vec!["The selected driver is invalid."], + Some(_) => vec![ + "The driver field must be a string.", + "The selected driver is invalid.", + ], + } +} + +fn diagnostic( + reason: &str, + message: &str, + namespace: &str, + driver: Option<&str>, + extra: Value, +) -> RuntimeError { + let mut response = json!({"status":"failed","reason":reason,"message":message, + "namespace":namespace,"driver":driver}); + if let (Some(response), Value::Object(extra)) = (response.as_object_mut(), extra) { + response.extend(extra); + } + RuntimeError::Protocol { + status: StatusCode::UNPROCESSABLE_ENTITY, + response, + } +} + +fn hex(digest: impl AsRef<[u8]>) -> String { + digest + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +async fn round_trip( + runtime: &Runtime, + local: &crate::external_payload::LocalStorage, + fill: u8, + bytes: i64, +) -> Result { + let payload = vec![fill; usize::try_from(bytes).unwrap_or_default()]; + let sha256 = hex(Sha256::digest(&payload)); + let reference = Reference { + schema: REFERENCE_SCHEMA, + reference_id: format!("ep_{}", id()), + codec: "avro", + size_bytes: bytes, + sha256: sha256.clone(), + }; + let uri = local + .uri_for(&reference) + .map_err(super::external_transport::snapshot_error)?; + runtime + .storage_test_round_trip( + local, + reference, + payload, + runtime.external.max_bytes, + runtime.external.workers.clone(), + ) + .await?; + Ok(json!({"status":"passed","bytes":bytes,"sha256":sha256, + "reference_identity_sha256":hex(Sha256::digest(uri.as_bytes()))})) +} + +pub(super) async fn storage_test(runtime: &Runtime, body: Value) -> Result { + let requested = normalized(body.get("driver")); + let small = normalized(body.get("small_payload_bytes")); + let large = normalized(body.get("large_payload_bytes")); + if let Some(error) = failure(vec![ + ("driver", driver_messages(requested.as_ref())), + ( + "small_payload_bytes", + bytes_messages(small.as_ref(), &SMALL), + ), + ( + "large_payload_bytes", + bytes_messages(large.as_ref(), &LARGE), + ), + ]) { + return Err(error); + } + let namespace = runtime.namespace().to_owned(); + let metadata = runtime.describe_namespace(&namespace).await?; + let policy = &metadata["external_payload_storage"]; + let configured = policy["driver"].as_str(); + let Some(driver) = requested + .as_ref() + .and_then(Value::as_str) + .or(configured) + .filter(|driver| !driver.is_empty()) + else { + return Err(diagnostic( + "external_storage_not_configured", + "External payload storage is not configured for this namespace.", + &namespace, + None, + json!({}), + )); + }; + if policy["enabled"] == false { + return Err(diagnostic( + "external_storage_disabled", + "External payload storage is disabled for this namespace.", + &namespace, + Some(driver), + json!({}), + )); + } + if Some(driver) != configured { + return Err(diagnostic( + "storage_driver_unavailable", + "The requested external payload storage driver is not configured for this namespace.", + &namespace, + Some(driver), + json!({"supported_diagnostic_drivers":["local"]}), + )); + } + let unavailable = || { + diagnostic( + "storage_driver_unavailable", + "The server can persist this storage policy, but the configured storage driver is not available in this runtime.", + &namespace, + Some(driver), + json!({"configuration_error":null,"supported_diagnostic_drivers":DRIVERS}), + ) + }; + if driver != "local" { + return Err(unavailable()); + } + let local = super::external_transport::local(runtime) + .await + .map_err(|_| unavailable())?; + // Validation accepted both sizes as integers. + let size = |value: &Option| value.as_ref().and_then(integer).unwrap_or_default(); + let small = round_trip(runtime, &local, b's', size(&small)).await?; + let large = round_trip(runtime, &local, b'l', size(&large)).await?; + Ok( + json!({"status":"passed","namespace":namespace,"driver":driver, + "small_payload":small,"large_payload":large}), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn messages(body: Value) -> Option { + let errors = failure(vec![ + ( + "driver", + driver_messages(normalized(body.get("driver")).as_ref()), + ), + ( + "small_payload_bytes", + bytes_messages(normalized(body.get("small_payload_bytes")).as_ref(), &SMALL), + ), + ( + "large_payload_bytes", + bytes_messages(normalized(body.get("large_payload_bytes")).as_ref(), &LARGE), + ), + ])?; + match errors { + RuntimeError::Protocol { response, .. } => Some(response), + _ => unreachable!(), + } + } + + #[test] + fn validation_follows_laravel_rules() { + assert_eq!( + messages(json!({"small_payload_bytes":1,"large_payload_bytes":16777216})), + None + ); + assert_eq!( + messages(json!({"driver":"","small_payload_bytes":" 7 ","large_payload_bytes":9.0})), + None + ); + let missing = messages(json!({})).unwrap(); + assert_eq!( + missing["message"], + "The small payload bytes field is required. (and 1 more error)" + ); + assert_eq!( + missing["errors"], + json!({"small_payload_bytes":["The small payload bytes field is required."], + "large_payload_bytes":["The large payload bytes field is required."]}) + ); + assert_eq!( + messages( + json!({"driver":"ftp","small_payload_bytes":0,"large_payload_bytes":16777217}) + ) + .unwrap()["errors"], + json!({"driver":["The selected driver is invalid."], + "small_payload_bytes":["The small payload bytes field must be at least 1."], + "large_payload_bytes":["The large payload bytes field must not be greater than 16777216."]}) + ); + assert_eq!( + messages(json!({"driver":5,"small_payload_bytes":"abc","large_payload_bytes":1.5})) + .unwrap()["errors"], + json!({"driver":["The driver field must be a string.","The selected driver is invalid."], + "small_payload_bytes":["The small payload bytes field must be an integer."], + "large_payload_bytes":["The large payload bytes field must be an integer."]}) + ); + assert_eq!( + messages(json!({"small_payload_bytes":"05","large_payload_bytes":1})).unwrap()["errors"], + json!({"small_payload_bytes":["The small payload bytes field must be an integer."]}) + ); + } +} diff --git a/rust/tests/execution.rs b/rust/tests/execution.rs index b1cf72cc8..36bc486f7 100644 --- a/rust/tests/execution.rs +++ b/rust/tests/execution.rs @@ -40,6 +40,9 @@ mod namespaces; #[path = "execution/search_attributes.rs"] mod search_attributes; +#[path = "execution/storage_test.rs"] +mod storage_test; + #[path = "execution/named_timers.rs"] mod named_timers; diff --git a/rust/tests/execution/storage_test.rs b/rust/tests/execution/storage_test.rs new file mode 100644 index 000000000..285ff6179 --- /dev/null +++ b/rust/tests/execution/storage_test.rs @@ -0,0 +1,145 @@ +use super::*; +use sha2::{Digest, Sha256}; + +fn digest(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn files(root: &std::path::Path) -> usize { + std::fs::read_dir(root) + .map(|entries| { + entries + .flatten() + .map(|entry| { + let path = entry.path(); + if path.is_dir() { files(&path) } else { 1 } + }) + .sum() + }) + .unwrap_or(0) +} + +async fn upload(app: &Router, bytes: &[u8]) -> StatusCode { + let request = Request::builder() + .method("POST") + .uri("/api/external-payloads/v1") + .header("authorization", "Bearer test-token") + .header("x-namespace", "default") + .header("content-type", "application/octet-stream") + .header("x-durable-workflow-payload-codec", "avro") + .header("x-durable-workflow-payload-size", bytes.len().to_string()) + .header("x-durable-workflow-payload-sha256", digest(bytes)) + .body(Body::from(bytes.to_vec())) + .unwrap(); + app.clone().oneshot(request).await.unwrap().status() +} + +#[tokio::test] +async fn the_storage_diagnostic_round_trips_through_the_local_driver() { + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let app = router(runtime.clone()); + let sizes = json!({"small_payload_bytes":64,"large_payload_bytes":70000}); + let (status, refused) = request(&app, "POST", "/api/storage/test", sizes.clone()).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + refused, + json!({"status":"failed","reason":"external_storage_not_configured", + "message":"External payload storage is not configured for this namespace.", + "namespace":"default","driver":null}) + ); + let root = tempfile::tempdir().unwrap(); + let policy = |enabled: bool| { + json!({"driver":"local","enabled":enabled, + "config":{"uri":format!("file://{}", root.path().display())}}) + }; + let path = "/api/namespaces/default/external-storage"; + assert_eq!( + request(&app, "PUT", path, policy(false)).await.0, + StatusCode::OK + ); + let (status, disabled) = request(&app, "POST", "/api/storage/test", sizes.clone()).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(disabled["reason"], "external_storage_disabled"); + assert_eq!(disabled["driver"], "local"); + assert_eq!( + request(&app, "PUT", path, policy(true)).await.0, + StatusCode::OK + ); + let mut other = sizes.clone(); + other["driver"] = json!("s3"); + let (status, mismatch) = request(&app, "POST", "/api/storage/test", other).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + mismatch, + json!({"status":"failed","reason":"storage_driver_unavailable", + "message":"The requested external payload storage driver is not configured for this namespace.", + "namespace":"default","driver":"s3","supported_diagnostic_drivers":["local"]}) + ); + + let (status, passed) = request(&app, "POST", "/api/storage/test", sizes.clone()).await; + assert_eq!(status, StatusCode::OK, "{passed}"); + assert_eq!( + [&passed["status"], &passed["namespace"], &passed["driver"]], + [&json!("passed"), &json!("default"), &json!("local")] + ); + for (key, fill, bytes) in [("small_payload", b's', 64), ("large_payload", b'l', 70000)] { + let result = &passed[key]; + assert_eq!( + [&result["status"], &result["bytes"], &result["sha256"]], + [ + &json!("passed"), + &json!(bytes), + &json!(digest(&vec![fill; bytes])) + ], + "{key}" + ); + let identity = result["reference_identity_sha256"].as_str().unwrap(); + assert!(identity.len() == 64 && identity.bytes().all(|byte| byte.is_ascii_hexdigit())); + } + // The diagnostic leaves no object and registers nothing. + assert_eq!(files(root.path()), 0); + assert_eq!( + task_retry::rows(&database, "SELECT COUNT(*) FROM runtime_external_payloads").await, + 0 + ); + + // An uploaded payload with the diagnostic's exact bytes keeps its object. + assert_eq!(upload(&app, &[b's'; 64]).await, StatusCode::CREATED); + assert_eq!(files(root.path()), 1); + let (status, _) = request(&app, "POST", "/api/storage/test", sizes).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(files(root.path()), 1); + runtime.close().await; + database.remove().await; +} + +#[tokio::test] +async fn storage_diagnostic_requests_are_validated_as_published_php_does() { + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let app = router(runtime.clone()); + let (status, invalid) = request( + &app, + "POST", + "/api/storage/test", + json!({"driver":"ftp","small_payload_bytes":0}), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + invalid, + json!({"message":"The selected driver is invalid. (and 2 more errors)","reason":"validation_failed", + "errors":{"driver":["The selected driver is invalid."], + "small_payload_bytes":["The small payload bytes field must be at least 1."], + "large_payload_bytes":["The large payload bytes field is required."]}, + "validation_errors":{"driver":["The selected driver is invalid."], + "small_payload_bytes":["The small payload bytes field must be at least 1."], + "large_payload_bytes":["The large payload bytes field is required."]}}) + ); + runtime.close().await; + database.remove().await; +} diff --git a/scripts/conformance/server-parity/contract.mjs b/scripts/conformance/server-parity/contract.mjs index efad78956..85c435968 100644 --- a/scripts/conformance/server-parity/contract.mjs +++ b/scripts/conformance/server-parity/contract.mjs @@ -10,6 +10,7 @@ import {checkAdmission} from './admission-contract.mjs'; import {checkNamespaces} from './namespace-contract.mjs'; import {checkWorkerDeregistration} from './worker-deregistration-contract.mjs'; import {checkSearchAttributes} from './search-attribute-contract.mjs'; +import {checkStorageTest} from './storage-test-contract.mjs'; import {checkPatchDeployment, checkPublishedPatchArtifacts, checkPatchPackageObservation} from './patch-deployment-contract.mjs'; import {checkLegacyMarkerArtifacts, checkLegacyMarkerPackageObservation, checkLegacyMarkerDeployment} from './legacy-marker-contract.mjs'; import {checkWaitingHistory} from './waiting-history-contract.mjs'; @@ -130,6 +131,7 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { const namespaces = fixture.namespace_isolation ? checkNamespaces(fixture, observation, workflowId, checkObservation) : null; const workerDeregistration = fixture.worker_deregistration ? checkWorkerDeregistration(fixture, observation, workflowId) : null; const searchAttributes = fixture.search_attributes ? checkSearchAttributes(fixture, observation, workflowId) : null; + const storageTest = fixture.storage_test ? checkStorageTest(fixture, observation, workflowId) : null; const patchDeployment = fixture.patch_deployment ? (fixture.patch_deployment.legacy_marker_history ? checkLegacyMarkerDeployment(fixture, observation, workflowId) : checkPatchDeployment(fixture, observation, workflowId)) : null; const waitingHistory = fixture.waiting_for_history ? checkWaitingHistory(fixture, observation, workflowId) : null; @@ -668,6 +670,7 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { ...(namespaces ? {namespace_isolation: namespaces} : {}), ...(workerDeregistration ? {worker_deregistration: workerDeregistration} : {}), ...(searchAttributes ? {search_attributes: searchAttributes} : {}), + ...(storageTest ? {storage_test: storageTest} : {}), ...(patchDeployment ? {patch_deployment: patchDeployment} : {}), ...(waitingHistory ? {waiting_for_history: waitingHistory} : {}), ...(taskRetry ? {workflow_task_retry: taskRetry} : {}), diff --git a/scripts/conformance/server-parity/probe.php b/scripts/conformance/server-parity/probe.php index 8c0e8b958..5f87bb102 100644 --- a/scripts/conformance/server-parity/probe.php +++ b/scripts/conformance/server-parity/probe.php @@ -428,6 +428,7 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, $namespaces = isset($fixture['namespace_isolation']) ? finishHttpNamespaces($client, $fixture, $workflowId, $queue, $url) : null; $workerDeregistration = isset($fixture['worker_deregistration']) ? finishHttpWorkerDeregistration($client, $fixture, $workflowId, $queue, $url) : null; $searchAttributes = isset($fixture['search_attributes']) ? finishHttpSearchAttributes($client, $fixture, $workflowId, $url) : null; + $storageTest = isset($fixture['storage_test']) ? finishHttpStorageTest($client, $fixture, $workflowId, $url) : null; $waitingHistory = isset($fixture['waiting_for_history']) ? finishHttpWaitingHistory($client, $fixture, $workflowId, $queue, $url) : null; $taskRetry = isset($fixture['workflow_task_retry']) ? finishHttpTaskRetry($client, $fixture, $workflowId, $queue, $url) : null; if ($scheduleState !== null) { @@ -515,6 +516,7 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, 'namespace_isolation' => $namespaces, 'worker_deregistration' => $workerDeregistration, 'search_attributes' => $searchAttributes, + 'storage_test' => $storageTest, 'waiting_for_history' => $waitingHistory, 'workflow_task_retry' => $taskRetry, 'run_id' => $execution->runId, @@ -811,6 +813,7 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa require __DIR__.'/admission-probe.php'; require __DIR__.'/namespace-probe.php'; require __DIR__.'/search-attribute-probe.php'; + require __DIR__.'/storage-test-probe.php'; require __DIR__.'/worker-deregistration-probe.php'; require __DIR__.'/waiting-history-probe.php'; require __DIR__.'/task-retry-probe.php'; @@ -948,6 +951,9 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa if ($mode === 'embedded' && isset($fixture['search_attributes'])) { $observation['search_attributes'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_search_attribute_registry']; } + if ($mode === 'embedded' && isset($fixture['storage_test'])) { + $observation['storage_test'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_storage_diagnostic']; + } $observation['mode'] = $mode; $observation['typed_output'] = typedValue($observation['output']); if ($mode === 'embedded' && isset($fixture['waiting_for_history'])) { diff --git a/scripts/conformance/server-parity/storage-test-contract.mjs b/scripts/conformance/server-parity/storage-test-contract.mjs new file mode 100644 index 000000000..0cc3aacb6 --- /dev/null +++ b/scripts/conformance/server-parity/storage-test-contract.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; + +// Reviewed external payload storage diagnostic (StorageController::test with +// Laravel's validation messages). The local driver stores content-addressed +// objects, so each round trip's reference identity is the digest of the +// object URI under the namespace's configured root. Embedded applications +// have no HTTP diagnostic, so the projection is the definition alone. +const sha256 = value => createHash('sha256').update(value).digest('hex'); +const REQUIRED = field => `The ${field} field is required.`; +const INVALID_ERRORS = [ + {small_payload_bytes: [REQUIRED('small payload bytes')], large_payload_bytes: [REQUIRED('large payload bytes')]}, + {driver: ['The selected driver is invalid.'], + small_payload_bytes: ['The small payload bytes field must be at least 1.'], + large_payload_bytes: ['The large payload bytes field must not be greater than 16777216.']}, + {driver: ['The driver field must be a string.', 'The selected driver is invalid.'], + small_payload_bytes: ['The small payload bytes field must be an integer.'], + large_payload_bytes: ['The large payload bytes field must be an integer.']}, +]; + +function validation(errors) { + const messages = Object.values(errors).flat(); + const more = messages.length - 1; + const message = messages[0] + (more === 0 ? '' : ` (and ${more} more ${more === 1 ? 'error' : 'errors'})`); + return {status: 422, response: {message, reason: 'validation_failed', errors, validation_errors: errors}}; +} + +export function checkStorageTest(fixture, observation, workflowId) { + const definition = fixture.storage_test; + const state = observation.storage_test; + const projection = {scope: 'storage_test', sizes: definition.sizes}; + assert.ok(state && typeof state === 'object', 'actual storage diagnostic observation'); + if (observation.mode === 'embedded') { + assert.deepStrictEqual(state, {applicable: false, reason: 'embedded_has_no_http_storage_diagnostic'}, + 'embedded storage diagnostic explicitly inapplicable'); + return projection; + } + const equal = (actual, expected, label) => assert.deepStrictEqual(actual, expected, label); + const namespace = `${workflowId}-storage`.toLowerCase(); + equal([state.applicable, state.namespace], [true, namespace], 'diagnostic namespace'); + const failed = (reason, message, driver, extra = {}) => ({status: 422, response: {status: 'failed', reason, message, + namespace, driver, ...extra}}); + equal(state.not_configured, failed('external_storage_not_configured', + 'External payload storage is not configured for this namespace.', null), 'no policy'); + equal([state.disabled_policy, state.enabled_policy], [200, 200], 'policies stored'); + equal(state.disabled, failed('external_storage_disabled', 'External payload storage is disabled for this namespace.', + 'local'), 'disabled policy'); + equal(state.other_driver, failed('storage_driver_unavailable', + 'The requested external payload storage driver is not configured for this namespace.', 's3', + {supported_diagnostic_drivers: ['local']}), 'a driver other than the configured one'); + equal(definition.invalid.length, INVALID_ERRORS.length, 'every invalid body has reviewed messages'); + equal(state.invalid, INVALID_ERRORS.map(validation), 'Laravel validation messages'); + const roundTrip = (fill, bytes) => { + const digest = sha256(fill.repeat(bytes)); + return {status: 'passed', bytes, sha256: digest, + reference_identity_sha256: sha256(`file:///state/${namespace}/avro/${digest.slice(0, 2)}/${digest}`)}; + }; + const passed = {status: 200, response: {status: 'passed', namespace, driver: 'local', + small_payload: roundTrip('s', definition.sizes.small_payload_bytes), + large_payload: roundTrip('l', definition.sizes.large_payload_bytes)}}; + equal(state.passed, passed, 'a round trip through the local driver'); + equal(state.repeated, passed, 'the diagnostic can run again over the same objects'); + return projection; +} diff --git a/scripts/conformance/server-parity/storage-test-probe.php b/scripts/conformance/server-parity/storage-test-probe.php new file mode 100644 index 000000000..1f95c9041 --- /dev/null +++ b/scripts/conformance/server-parity/storage-test-probe.php @@ -0,0 +1,46 @@ +createNamespace($namespace, 'storage test λ', 30); + $token = getenv('DW_PARITY_TOKEN') ?: null; + $http = new StorageTestHttp(['base_uri' => rtrim($url, '/').'/api/', 'http_errors' => false, + 'connect_timeout' => 1, 'timeout' => 30]); + $raw = static function (string $method, string $path, array|object|null $body = null) use ($http, $token, $namespace): array { + $response = $http->request($method, $path, ['headers' => array_filter([ + 'Authorization' => $token === null ? null : 'Bearer '.$token, + 'Accept' => 'application/json', 'X-Namespace' => $namespace, + 'X-Durable-Workflow-Control-Plane-Version' => '2', + ]), ...($body === null ? [] : ['json' => $body])]); + + return ['status' => $response->getStatusCode(), + 'response' => json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR)]; + }; + $policy = static fn (bool $enabled): array => ['driver' => 'local', 'enabled' => $enabled, + 'config' => ['uri' => 'file:///state/'.$namespace]]; + $sizes = $definition['sizes']; + $state = ['applicable' => true, 'namespace' => $namespace]; + $state['not_configured'] = $raw('POST', 'storage/test', $sizes); + $state['disabled_policy'] = $raw('PUT', 'namespaces/'.$namespace.'/external-storage', $policy(false))['status']; + $state['disabled'] = $raw('POST', 'storage/test', $sizes); + $state['enabled_policy'] = $raw('PUT', 'namespaces/'.$namespace.'/external-storage', $policy(true))['status']; + $state['other_driver'] = $raw('POST', 'storage/test', ['driver' => 's3', ...$sizes]); + foreach ($definition['invalid'] as $body) { + $state['invalid'][] = $raw('POST', 'storage/test', $body === [] ? (object) [] : $body); + } + $state['passed'] = $raw('POST', 'storage/test', $sizes); + // A repeated diagnostic writes and removes the same content-addressed objects. + $state['repeated'] = $raw('POST', 'storage/test', $sizes); + + return $state; +} diff --git a/tests/Fixtures/ServerParity/storage-test.json b/tests/Fixtures/ServerParity/storage-test.json new file mode 100644 index 000000000..6ace8d30e --- /dev/null +++ b/tests/Fixtures/ServerParity/storage-test.json @@ -0,0 +1,23 @@ +{ + "schema": "durable-workflow.server-parity-fixture/v1", + "id": "storage-test", + "review_state": "reviewed", + "workflow_type": "parity.v1.echo", + "input": {"message": "storage test λ", "count": 9007199254740993, "optional": null}, + "typed_value": {"type": "map", "value": { + "message": {"type": "string", "value": "storage test λ"}, + "count": {"type": "int64", "value": "9007199254740993"}, + "optional": {"type": "null", "value": null} + }}, + "activity": false, + "expected_events": ["StartAccepted", "WorkflowStarted", "WorkflowCompleted"], + "storage_test": { + "sizes": {"small_payload_bytes": 64, "large_payload_bytes": 70000}, + "invalid": [ + {}, + {"driver": "ftp", "small_payload_bytes": 0, "large_payload_bytes": 16777217}, + {"driver": 5, "small_payload_bytes": "abc", "large_payload_bytes": 1.5} + ] + }, + "contract": "POST /api/storage/test diagnoses the current namespace's external payload storage policy. Without a policy it is 422 external_storage_not_configured with a null driver; with a disabled policy it is 422 external_storage_disabled; a requested driver other than the configured one is 422 storage_driver_unavailable naming the supported local diagnostic driver. Laravel validation refuses missing, non-integer, out-of-range and unknown-driver fields with 422 validation_failed and the published messages and error counts. With an enabled local policy it writes, reads back and removes a generated small and large payload, reporting each one's size, SHA-256 and the digest of its content-addressed object URI under the configured root; the diagnostic can run again over the same objects. Other drivers need separate fixtures. Embedded applications have no HTTP diagnostic." +} diff --git a/tests/Unit/ServerParityStorageTestContractTest.mjs b/tests/Unit/ServerParityStorageTestContractTest.mjs new file mode 100644 index 000000000..c159e4368 --- /dev/null +++ b/tests/Unit/ServerParityStorageTestContractTest.mjs @@ -0,0 +1,78 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {readFileSync} from 'node:fs'; +import {checkStorageTest} from '../../scripts/conformance/server-parity/storage-test-contract.mjs'; + +const fixture = JSON.parse(readFileSync(new URL('../Fixtures/ServerParity/storage-test.json', import.meta.url))); +const namespace = 'test-storage'; +const sha256 = value => createHash('sha256').update(value).digest('hex'); +const invalid = errors => { + const messages = Object.values(errors).flat(); + const more = messages.length - 1; + return {status: 422, response: {message: messages[0] + (more ? ` (and ${more} more error${more === 1 ? '' : 's'})` : ''), + reason: 'validation_failed', errors, validation_errors: errors}}; +}; +const failed = (reason, message, driver, extra = {}) => + ({status: 422, response: {status: 'failed', reason, message, namespace, driver, ...extra}}); +const roundTrip = (fill, bytes) => { + const digest = sha256(fill.repeat(bytes)); + return {status: 'passed', bytes, sha256: digest, + reference_identity_sha256: sha256(`file:///state/${namespace}/avro/${digest.slice(0, 2)}/${digest}`)}; +}; +function model() { + const passed = {status: 200, response: {status: 'passed', namespace, driver: 'local', + small_payload: roundTrip('s', 64), large_payload: roundTrip('l', 70000)}}; + return structuredClone({mode: 'http', storage_test: {applicable: true, namespace, + not_configured: failed('external_storage_not_configured', 'External payload storage is not configured for this namespace.', null), + disabled_policy: 200, + disabled: failed('external_storage_disabled', 'External payload storage is disabled for this namespace.', 'local'), + enabled_policy: 200, + other_driver: failed('storage_driver_unavailable', + 'The requested external payload storage driver is not configured for this namespace.', 's3', + {supported_diagnostic_drivers: ['local']}), + invalid: [ + invalid({small_payload_bytes: ['The small payload bytes field is required.'], + large_payload_bytes: ['The large payload bytes field is required.']}), + invalid({driver: ['The selected driver is invalid.'], + small_payload_bytes: ['The small payload bytes field must be at least 1.'], + large_payload_bytes: ['The large payload bytes field must not be greater than 16777216.']}), + invalid({driver: ['The driver field must be a string.', 'The selected driver is invalid.'], + small_payload_bytes: ['The small payload bytes field must be an integer.'], + large_payload_bytes: ['The large payload bytes field must be an integer.']}), + ], + passed, repeated: passed}}); +} +const projection = {scope: 'storage_test', sizes: {small_payload_bytes: 64, large_payload_bytes: 70000}}; +test('published storage diagnostic behavior passes', () => + assert.deepStrictEqual(checkStorageTest(fixture, model(), 'TEST'), projection)); +test('embedded explicitly declares the HTTP diagnostic inapplicable', () => assert.deepStrictEqual(checkStorageTest(fixture, + {mode: 'embedded', storage_test: {applicable: false, reason: 'embedded_has_no_http_storage_diagnostic'}}, 'test'), projection)); +test('rejects an embedded observation that runs the diagnostic', () => assert.throws(() => checkStorageTest(fixture, + {...model(), mode: 'embedded'}, 'test'))); +for (const [label, mutate] of [ + ['a missing observation', s => { for (const key of Object.keys(s)) delete s[key]; }], + ['another namespace', s => s.namespace = 'default'], + ['a diagnostic without a policy that passes', s => s.not_configured = s.passed], + ['a refusal without its driver', s => delete s.not_configured.response.driver], + ['a policy refused', s => s.disabled_policy = 422], + ['a disabled policy that runs', s => s.disabled = {status: 200, response: s.passed.response}], + ['another driver accepted', s => s.other_driver.status = 200], + ['supported drivers omitted', s => delete s.other_driver.response.supported_diagnostic_drivers], + ['a validation message changed', s => s.invalid[2].response.errors.driver.pop()], + ['the summary miscounts errors', s => s.invalid[0].response.message = 'The small payload bytes field is required.'], + ['validation errors without the legacy field', s => delete s.invalid[1].response.validation_errors], + ['an invalid body accepted', s => s.invalid[1] = s.passed], + ['an invalid body missing', s => s.invalid.pop()], + ['a payload of another size', s => s.passed.response.large_payload.bytes = 69999], + ['a digest of other bytes', s => s.passed.response.small_payload.sha256 = sha256('x'.repeat(64))], + ['a reference outside the namespace root', s => s.passed.response.small_payload.reference_identity_sha256 = sha256('file:///state/other')], + ['a failed round trip', s => s.passed.response.large_payload.status = 'failed'], + ['a repeated diagnostic refused', s => s.repeated = failed('storage_round_trip_failed', 'x', 'local')], +]) { + test(`rejects ${label}`, () => { + const observation = model(); + mutate(observation.storage_test); + assert.throws(() => checkStorageTest(fixture, observation, 'TEST')); + }); +} From 8b346e93a4f9ffda571578847099ffc3944c1ec4 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Wed, 21 Oct 2026 03:52:13 +0000 Subject: [PATCH 2/2] Give each storage diagnostic target its own object root PHP and native wrote the diagnostic's objects under the same /state/ root. CI runs PHP as uid 1000 and native as the runner's uid, so native could not write into the directory PHP created and answered 503. Each target now writes under file:///state/storage-test-/, which the probe records and the contract checks and hashes. Reproduced and fixed locally with native running as another uid. Co-Authored-By: Claude Opus 5.5 --- .../conformance/server-parity/storage-test-contract.mjs | 6 +++++- scripts/conformance/server-parity/storage-test-probe.php | 8 ++++++-- tests/Unit/ServerParityStorageTestContractTest.mjs | 8 ++++++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/scripts/conformance/server-parity/storage-test-contract.mjs b/scripts/conformance/server-parity/storage-test-contract.mjs index 0cc3aacb6..9188c57fb 100644 --- a/scripts/conformance/server-parity/storage-test-contract.mjs +++ b/scripts/conformance/server-parity/storage-test-contract.mjs @@ -38,6 +38,10 @@ export function checkStorageTest(fixture, observation, workflowId) { const equal = (actual, expected, label) => assert.deepStrictEqual(actual, expected, label); const namespace = `${workflowId}-storage`.toLowerCase(); equal([state.applicable, state.namespace], [true, namespace], 'diagnostic namespace'); + // A storage root of this target's own, under the namespace. + const root = state.storage_root; + assert.ok(typeof root === 'string' && /^file:\/\/\/state\/storage-test-[a-z0-9-]+\//.test(root) + && root.endsWith(`/${namespace}`) && root.split('/').length === 6, 'a storage root of its own'); const failed = (reason, message, driver, extra = {}) => ({status: 422, response: {status: 'failed', reason, message, namespace, driver, ...extra}}); equal(state.not_configured, failed('external_storage_not_configured', @@ -53,7 +57,7 @@ export function checkStorageTest(fixture, observation, workflowId) { const roundTrip = (fill, bytes) => { const digest = sha256(fill.repeat(bytes)); return {status: 'passed', bytes, sha256: digest, - reference_identity_sha256: sha256(`file:///state/${namespace}/avro/${digest.slice(0, 2)}/${digest}`)}; + reference_identity_sha256: sha256(`${root}/avro/${digest.slice(0, 2)}/${digest}`)}; }; const passed = {status: 200, response: {status: 'passed', namespace, driver: 'local', small_payload: roundTrip('s', definition.sizes.small_payload_bytes), diff --git a/scripts/conformance/server-parity/storage-test-probe.php b/scripts/conformance/server-parity/storage-test-probe.php index 1f95c9041..bb61bfcbf 100644 --- a/scripts/conformance/server-parity/storage-test-probe.php +++ b/scripts/conformance/server-parity/storage-test-probe.php @@ -26,10 +26,14 @@ function finishHttpStorageTest(Client $admin, array $fixture, string $workflowId return ['status' => $response->getStatusCode(), 'response' => json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR)]; }; + // Each target writes under its own root: the servers can run as different + // users, and an object one target wrote must not satisfy another. + $host = preg_replace('/[^a-z0-9-]+/', '-', strtolower((string) parse_url($url, PHP_URL_HOST))); + $root = 'file:///state/storage-test-'.$host.'/'.$namespace; $policy = static fn (bool $enabled): array => ['driver' => 'local', 'enabled' => $enabled, - 'config' => ['uri' => 'file:///state/'.$namespace]]; + 'config' => ['uri' => $root]]; $sizes = $definition['sizes']; - $state = ['applicable' => true, 'namespace' => $namespace]; + $state = ['applicable' => true, 'namespace' => $namespace, 'storage_root' => $root]; $state['not_configured'] = $raw('POST', 'storage/test', $sizes); $state['disabled_policy'] = $raw('PUT', 'namespaces/'.$namespace.'/external-storage', $policy(false))['status']; $state['disabled'] = $raw('POST', 'storage/test', $sizes); diff --git a/tests/Unit/ServerParityStorageTestContractTest.mjs b/tests/Unit/ServerParityStorageTestContractTest.mjs index c159e4368..7a3083d72 100644 --- a/tests/Unit/ServerParityStorageTestContractTest.mjs +++ b/tests/Unit/ServerParityStorageTestContractTest.mjs @@ -6,6 +6,7 @@ import {checkStorageTest} from '../../scripts/conformance/server-parity/storage- const fixture = JSON.parse(readFileSync(new URL('../Fixtures/ServerParity/storage-test.json', import.meta.url))); const namespace = 'test-storage'; +const root = `file:///state/storage-test-parity-rust/${namespace}`; const sha256 = value => createHash('sha256').update(value).digest('hex'); const invalid = errors => { const messages = Object.values(errors).flat(); @@ -18,12 +19,12 @@ const failed = (reason, message, driver, extra = {}) => const roundTrip = (fill, bytes) => { const digest = sha256(fill.repeat(bytes)); return {status: 'passed', bytes, sha256: digest, - reference_identity_sha256: sha256(`file:///state/${namespace}/avro/${digest.slice(0, 2)}/${digest}`)}; + reference_identity_sha256: sha256(`${root}/avro/${digest.slice(0, 2)}/${digest}`)}; }; function model() { const passed = {status: 200, response: {status: 'passed', namespace, driver: 'local', small_payload: roundTrip('s', 64), large_payload: roundTrip('l', 70000)}}; - return structuredClone({mode: 'http', storage_test: {applicable: true, namespace, + return structuredClone({mode: 'http', storage_test: {applicable: true, namespace, storage_root: root, not_configured: failed('external_storage_not_configured', 'External payload storage is not configured for this namespace.', null), disabled_policy: 200, disabled: failed('external_storage_disabled', 'External payload storage is disabled for this namespace.', 'local'), @@ -53,6 +54,9 @@ test('rejects an embedded observation that runs the diagnostic', () => assert.th for (const [label, mutate] of [ ['a missing observation', s => { for (const key of Object.keys(s)) delete s[key]; }], ['another namespace', s => s.namespace = 'default'], + ['a root shared with another target', s => s.storage_root = `file:///state/${namespace}`], + ['a root outside the namespace', s => s.storage_root = 'file:///state/storage-test-parity-rust/default'], + ['no recorded root', s => delete s.storage_root], ['a diagnostic without a policy that passes', s => s.not_configured = s.passed], ['a refusal without its driver', s => delete s.not_configured.response.driver], ['a policy refused', s => s.disabled_policy = 422],