Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/phpunit-feature.yml
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,9 @@ jobs:
DW_REQUIRE_COMPOSER_TRUST_TEST: '1'
run: python3 -m unittest tests/Unit/Support/composer_trust_test.py tests/Unit/Support/release_composer_audit_test.py

- name: Prove profile SDK installs use only a verified job-scoped cache or the unchanged install
run: python3 -m unittest tests/Unit/Support/sdk_composer_cache_test.py

- name: Prove the image installs and checks the fixed Brotli decoder
run: python3 -m unittest tests/Unit/Support/brotli_trust_test.py

Expand Down Expand Up @@ -392,7 +395,7 @@ jobs:
-w /app \
--entrypoint sh \
durable-workflow-server-corpus-validator \
-lc 'set -eu; tar --no-same-owner -xf - -C /app; cp -a /app/workflow-package /workflow; php scripts/ci/prepare-release-workflow-composer-metadata.php; composer update durable-workflow/workflow --with-dependencies --minimal-changes --no-install --no-scripts --no-autoloader --no-interaction --no-progress --prefer-dist; composer install --no-interaction --no-progress --prefer-dist; git --version; vendor/bin/phpunit --version; git rev-parse --verify "$CORPUS_BASE_REF^{commit}"; php scripts/ci/check-worker-openapi-evolution.php "$CORPUS_BASE_REF"; vendor/bin/phpunit tests/Feature tests/Unit/NativeReadyOriginSchemaTest.php tests/Unit/NativeScopedDispatchSchemaTest.php tests/Unit/NativePostgresScopedDispatchSchemaTest.php tests/Unit/WorkerProtocolOpenApiContractTest.php tests/Unit/NexusContractTest.php tests/Unit/NativePublishedComposeContractTest.php tests/Unit/PublishedServerAutoSelectionTest.php tests/Unit/CodecRegressionCorpusTest.php tests/Unit/PhpunitFeatureWorkflowContractTest.php tests/Unit/OpenApiDocumentEvolutionTest.php tests/Unit/NamespaceConformanceRunnerContractTest.php tests/Unit/NamespaceRuntimeContractTest.php tests/Unit/SchedulesConformanceRunnerContractTest.php tests/Unit/SchedulesRuntimeContractTest.php tests/Unit/ServerParitySdkDirectionResponseSummaryTest.php tests/Unit/ServerParitySdkQueryTransportTest.php tests/Unit/ServerParitySdkUpdateTransportTest.php tests/Unit/ServerParityLockPressureRetryTest.php tests/Unit/ServerParityManyUpdatesRetryTest.php --no-progress --colors=never; vendor/bin/phpunit tests/Unit/ReleaseImageContinuationTest.php tests/Unit/ReleaseImageImmutabilityGuardTest.php --no-progress --colors=never; vendor/bin/phpunit tests/Unit/ReleaseImagePublishWorkflowContractTest.php --filter test_php_security_audits_fail_before_release_publication --no-progress --colors=never; python3 scripts/ci/validate-regression-corpus.py --base-ref "$CORPUS_BASE_REF" --verify-counterfactual'
-lc 'set -eu; tar --no-same-owner -xf - -C /app; cp -a /app/workflow-package /workflow; php scripts/ci/prepare-release-workflow-composer-metadata.php; composer update durable-workflow/workflow --with-dependencies --minimal-changes --no-install --no-scripts --no-autoloader --no-interaction --no-progress --prefer-dist; composer install --no-interaction --no-progress --prefer-dist; git --version; vendor/bin/phpunit --version; git rev-parse --verify "$CORPUS_BASE_REF^{commit}"; php scripts/ci/check-worker-openapi-evolution.php "$CORPUS_BASE_REF"; vendor/bin/phpunit tests/Feature tests/Unit/NativeReadyOriginSchemaTest.php tests/Unit/NativeScopedDispatchSchemaTest.php tests/Unit/NativePostgresScopedDispatchSchemaTest.php tests/Unit/WorkerProtocolOpenApiContractTest.php tests/Unit/NexusContractTest.php tests/Unit/NativePublishedComposeContractTest.php tests/Unit/PublishedServerAutoSelectionTest.php tests/Unit/CodecRegressionCorpusTest.php tests/Unit/PhpunitFeatureWorkflowContractTest.php tests/Unit/OpenApiDocumentEvolutionTest.php tests/Unit/NamespaceConformanceRunnerContractTest.php tests/Unit/NamespaceRuntimeContractTest.php tests/Unit/SchedulesConformanceRunnerContractTest.php tests/Unit/SchedulesRuntimeContractTest.php tests/Unit/ServerParitySdkDirectionResponseSummaryTest.php tests/Unit/ServerParitySdkQueryTransportTest.php tests/Unit/ServerParitySdkUpdateTransportTest.php tests/Unit/ServerParityLockPressureRetryTest.php tests/Unit/ServerParityManyUpdatesRetryTest.php tests/Unit/ServerParityReadyAgeRestartDiagnosticsTest.php --no-progress --colors=never; vendor/bin/phpunit tests/Unit/ReleaseImageContinuationTest.php tests/Unit/ReleaseImageImmutabilityGuardTest.php --no-progress --colors=never; vendor/bin/phpunit tests/Unit/ReleaseImagePublishWorkflowContractTest.php --filter test_php_security_audits_fail_before_release_publication --no-progress --colors=never; python3 scripts/ci/validate-regression-corpus.py --base-ref "$CORPUS_BASE_REF" --verify-counterfactual'

- name: Select production image qualification for changed build inputs
id: image_inputs
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ yarn-error.log
/rust/target/
/corrected-marker-adapters/
/parity-corrected-state/
/parity-composer-cache-*
/parity-external-consumers-state/
/parity-upgrade-source-state/
/scripts/conformance/server-parity/corrected-markers/php/vendor/
Expand Down
16 changes: 16 additions & 0 deletions scripts/conformance/server-parity/range-repair-profile.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,24 @@ case "$backend" in
database=(-e DB_CONNECTION=sqlite -v "$state:/range-state") ;;
*) echo 'Choose mysql, pgsql or sqlite' >&2; exit 2 ;;
esac
# Install from this job's verified read-only dist cache (sdk-composer-cache.sh);
# when no verified cache exists, run the unchanged authenticated install.
set +e
bash scripts/conformance/server-parity/sdk-composer-cache.sh install "$state/sdk" "${limits[@]}" \
> "$evidence/sdk-install.txt" 2>&1
cached=$?
set -e
case "$cached" in
0) ;;
3)
rm -rf "$state/sdk/vendor"
bash scripts/ci/run-composer.sh --name "$resource-sdk" "${limits[@]}" -v "$state/sdk:/sdk" -w /sdk -- \
install --no-interaction --no-progress --no-cache > "$evidence/sdk-install.txt" 2>&1
;;
*) echo "Refusing the cache-backed SDK install (exit $cached); see $evidence/sdk-install.txt" >&2; exit 1 ;;
esac
# The job log records which install produced this profile's SDK.
if test "$cached" = 0; then echo "SDK install ($tag): verified job cache, offline"; else echo "SDK install ($tag): authenticated install (no verified cache)"; fi
printf '%s %s\n' "$(jq -r '.sdk_php_composer_lock_sha256' "$manifest")" "$state/sdk/composer.lock" \
| sha256sum -c > "$evidence/sdk-lock.txt"
reader_tests=(tests/Unit/ServerParityRangeRepairContractTest.mjs)
Expand Down
72 changes: 72 additions & 0 deletions scripts/conformance/server-parity/ready-age-claim.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

// The owning source job kills the native process between these two phases.
// Raw worker HTTP isolates this optional producer field from SDK observations.
function readyAgeRequest(string $url, string $path, array $body): array
{
$headers = ['Content-Type: application/json', 'X-Namespace: default',
'X-Durable-Workflow-Protocol-Version: 1.20',
'Authorization: Bearer '.(getenv('DW_PARITY_TOKEN') ?: 'parity-test-token')];
$context = stream_context_create(['http' => ['method' => 'POST', 'header' => implode("\r\n", $headers),
'content' => json_encode($body, JSON_THROW_ON_ERROR), 'timeout' => 5, 'ignore_errors' => true]]);
$response = file_get_contents(rtrim($url, '/').$path, false, $context);
$status = $http_response_header[0] ?? '';
$GLOBALS['readyAgeLastHttpStatus'] = substr($status, 0, 64);
if ($response === false || preg_match('/^HTTP\/\S+ 2[0-9]{2}\b/', $status) !== 1) {
throw new RuntimeException('Ready-age source request refused: '.$path.' '.$status);
}

return json_decode($response, true, flags: JSON_THROW_ON_ERROR);
}

function readyAgeClaim(array $response, array $context = []): array
{
$task = $response['task'] ?? null;
if (($response['server_capabilities']['claimed_task_ready_age'] ?? null) !== true || ! is_array($task)
|| ! is_int($task['ready_age_ms'] ?? null) || $task['ready_age_ms'] < 0 || $task['ready_age_ms'] > 9007199254740991) {
throw new RuntimeException('Native successful claim lacks its declared literal ready-age value. '
.json_encode(readyAgeRefusalShape($response, $context), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES));
}
$projection = array_intersect_key($task, array_flip(['task_id', 'run_id', 'workflow_task_attempt', 'activity_attempt_id', 'attempt_number', 'ready_age_ms']));
if (! is_string($projection['task_id'] ?? null) || ! is_string($projection['run_id'] ?? null)) {
throw new RuntimeException('Ready-age claim lost its original task/run identity.');
}

// JSONB may reorder object keys. Field names and literal values are exact.
ksort($projection);

return $projection;
}

// Bounded facts for a refused claim (Server #1096): statuses, key names,
// types and the two ready-age values only. No payload, token or other value.
function readyAgeRefusalShape(array $response, array $context): array
{
$scalar = static fn (mixed $value): array => ['present' => $value !== null, 'type' => get_debug_type($value),
'value' => is_bool($value) || is_int($value) ? $value : null];
$keys = static fn (array $value): array => array_slice(array_map('strval', array_keys($value)), 0, 64);
$capabilities = is_array($response['server_capabilities'] ?? null) ? $response['server_capabilities'] : [];
$task = $response['task'] ?? null;
$shape = [
'http_status' => is_string($GLOBALS['readyAgeLastHttpStatus'] ?? null) ? $GLOBALS['readyAgeLastHttpStatus'] : null,
'poll_status' => is_string($response['poll_status'] ?? null) ? substr($response['poll_status'], 0, 64) : null,
'reason' => is_string($response['reason'] ?? null) ? substr($response['reason'], 0, 64) : null,
'response_keys' => $keys($response),
'task_type' => get_debug_type($task),
'task_keys' => is_array($task) ? $keys($task) : null,
'claimed_task_ready_age' => $scalar($capabilities['claimed_task_ready_age'] ?? null),
'ready_age_ms' => $scalar(is_array($task) ? ($task['ready_age_ms'] ?? null) : null),
];
if (isset($context['saved_task_id'])) {
$shape['same_task_id'] = is_array($task) && ($task['task_id'] ?? null) === $context['saved_task_id'];
}
foreach (['phase', 'kind', 'seconds_since_prepare'] as $field) {
if (array_key_exists($field, $context)) {
$shape[$field] = $context[$field];
}
}

return $shape;
}
43 changes: 6 additions & 37 deletions scripts/conformance/server-parity/ready-age-restart.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,8 @@

declare(strict_types=1);

// The owning source job kills the native process between these two phases.
// Raw worker HTTP isolates this optional producer field from SDK observations.
function readyAgeRequest(string $url, string $path, array $body): array
{
$headers = ['Content-Type: application/json', 'X-Namespace: default',
'X-Durable-Workflow-Protocol-Version: 1.20',
'Authorization: Bearer '.(getenv('DW_PARITY_TOKEN') ?: 'parity-test-token')];
$context = stream_context_create(['http' => ['method' => 'POST', 'header' => implode("\r\n", $headers),
'content' => json_encode($body, JSON_THROW_ON_ERROR), 'timeout' => 5, 'ignore_errors' => true]]);
$response = file_get_contents(rtrim($url, '/').$path, false, $context);
$status = $http_response_header[0] ?? '';
if ($response === false || preg_match('/^HTTP\/\S+ 2[0-9]{2}\b/', $status) !== 1) {
throw new RuntimeException('Ready-age source request refused: '.$path.' '.$status);
}

return json_decode($response, true, flags: JSON_THROW_ON_ERROR);
}

function readyAgeClaim(array $response): array
{
$task = $response['task'] ?? null;
if (($response['server_capabilities']['claimed_task_ready_age'] ?? null) !== true || ! is_array($task)
|| ! is_int($task['ready_age_ms'] ?? null) || $task['ready_age_ms'] < 0 || $task['ready_age_ms'] > 9007199254740991) {
throw new RuntimeException('Native successful claim lacks its declared literal ready-age value.');
}
$projection = array_intersect_key($task, array_flip(['task_id', 'run_id', 'workflow_task_attempt', 'activity_attempt_id', 'attempt_number', 'ready_age_ms']));
if (! is_string($projection['task_id'] ?? null) || ! is_string($projection['run_id'] ?? null)) {
throw new RuntimeException('Ready-age claim lost its original task/run identity.');
}

// JSONB may reorder object keys. Field names and literal values are exact.
ksort($projection);

return $projection;
}
// Raw worker HTTP and the claim predicate live in ready-age-claim.php.
require_once __DIR__.'/ready-age-claim.php';

if ($argc !== 4 || ! in_array($argv[1], ['prepare', 'finish'], true)) {
throw new InvalidArgumentException('Usage: ready-age-restart.php prepare|finish URL RECEIPT');
Expand Down Expand Up @@ -65,7 +32,7 @@ function readyAgeClaim(array $response): array
'arguments' => $client->payloadCodec()->envelope(['source restart'])]]]);
$response = readyAgeRequest($url, '/api/worker/activity-tasks/poll', $request);
}
$claims[$kind] = ['request' => $request, 'claim' => readyAgeClaim($response)];
$claims[$kind] = ['request' => $request, 'claim' => readyAgeClaim($response, ['phase' => 'prepare', 'kind' => $kind])];
}
file_put_contents($path, json_encode(['prepared_at' => microtime(true), 'claims' => $claims], JSON_THROW_ON_ERROR)."\n");
echo json_encode(['outcome' => 'prepared', 'claims' => array_keys($claims)], JSON_THROW_ON_ERROR)."\n";
Expand All @@ -77,7 +44,9 @@ function readyAgeClaim(array $response): array
}
foreach ($receipt['claims'] as $kind => $saved) {
$response = readyAgeRequest($url, '/api/worker/'.$kind.'-tasks/poll', $saved['request']);
$actual = readyAgeClaim($response);
$actual = readyAgeClaim($response, ['phase' => 'finish', 'kind' => $kind,
'seconds_since_prepare' => round(microtime(true) - $receipt['prepared_at'], 3),
'saved_task_id' => $saved['claim']['task_id'] ?? null]);
if ($actual !== $saved['claim']) {
$changed = array_values(array_filter(array_unique([...array_keys($actual), ...array_keys($saved['claim'])]),
static fn (string $key): bool => ! array_key_exists($key, $actual) || ! array_key_exists($key, $saved['claim']) || $actual[$key] !== $saved['claim'][$key]));
Expand Down
112 changes: 112 additions & 0 deletions scripts/conformance/server-parity/sdk-composer-cache.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# A job-scoped, read-only Composer dist cache for the locked published-SDK
# fixture installs of the parity profiles (Server #1093).
#
# Each profile used to download every pinned dist from GitHub again in its
# own single-attempt named container, so one transient zipball 504 failed the
# profile. Here the first profile in a job warms one cache with an ordinary
# locked install through run-composer.sh (its existing transient retry), and
# records the exact lock, every cached file's SHA-256 and the installed vendor
# tree. Every profile then installs from a verified, read-only copy of those
# bytes with no network, and must reproduce the recorded vendor tree.
#
# Nothing is restored from another job or a global cache: the root lives in
# this job's workspace. The lock, the profiles, the trust context and the
# post-install lock check are unchanged.
#
# Usage:
# sdk-composer-cache.sh warm LOCK_DIR
# sdk-composer-cache.sh install LOCK_DIR [docker run options...]
# Exit status of install: 0 installed from the cache; 3 the cache is not
# usable (the caller runs its unchanged authenticated install instead);
# 4 a cache-backed install did not reproduce the recorded vendor tree (refuse).
set -euo pipefail

here=$(dirname "$0")
command=${1:?warm or install}
lock_dir=${2:?lock directory}
lock_sha=$(sha256sum < "$lock_dir/composer.lock" | cut -d' ' -f1)
json_sha=$(sha256sum < "$lock_dir/composer.json" | cut -d' ' -f1)
# Keyed by the full lock and manifest, so a different lock never reuses it.
root="$PWD/parity-composer-cache-$lock_sha-$json_sha"
home_config='{"config":{"cache-read-only":true,"cache-dir":"/composer-cache"}}'
# The job that built a root. A root from any other job, restored or left in a
# reused workspace, does not verify and is never used.
job="${GITHUB_RUN_ID:-local}.${GITHUB_RUN_ATTEMPT:-0}.${GITHUB_JOB:-local}.${RUNNER_NAME:-$(hostname)}"

# A tree's identity: every entry's type and mode, then each regular file's
# SHA-256, in byte order. Ownership and times are deliberately excluded.
listing() {
(cd "$1" && find . -mindepth 1 -printf '%y %m %P\n' | LC_ALL=C sort \
&& find . -type f -print0 | LC_ALL=C sort -z | xargs -0 -r sha256sum)
}
# Only regular files and directories: no symlink, device, socket or pipe.
plain() { test -z "$(find "$1" -mindepth 1 ! -type f ! -type d -print -quit)"; }

verified() {
test -d "$root" && ! test -L "$root" || return 1
test "$(cat "$root/manifest/lock" 2>/dev/null)" = "$lock_sha $json_sha" || return 1
test "$(cat "$root/manifest/job" 2>/dev/null)" = "$job" || return 1
plain "$root/cache" && plain "$root/home" || return 1
test "$(cat "$root/home/config.json" 2>/dev/null)" = "$home_config" || return 1
test -s "$root/manifest/files" && test -s "$root/manifest/vendor" || return 1
listing "$root/cache" | cmp -s - "$root/manifest/files"
}

case "$command" in
warm)
exec 9> "$root.lock"
flock 9
if verified; then exit 0; fi
# An unverifiable existing root is never repaired or reused.
if test -e "$root"; then
echo "sdk-composer-cache: $root exists but does not verify; not using it" >&2
exit 3
fi
work=$(mktemp -d "$root.tmp.XXXXXX")
trap 'rm -rf -- "$work"' EXIT
mkdir "$work/project" "$work/cache" "$work/home" "$work/manifest"
cp "$lock_dir/composer.json" "$lock_dir/composer.lock" "$work/project/"
chmod 0777 "$work/project" "$work/cache"
chmod 0666 "$work/project/composer.json" "$work/project/composer.lock"
bash scripts/ci/run-composer.sh --rm -v "$work/project:/sdk" \
-v "$work/cache:/composer-cache" -e COMPOSER_CACHE_DIR=/composer-cache -w /sdk -- \
install --no-interaction --no-progress
plain "$work/cache" && plain "$work/project/vendor" || exit 3
printf '%s\n' "$home_config" > "$work/home/config.json"
printf '%s %s\n' "$lock_sha" "$json_sha" > "$work/manifest/lock"
printf '%s\n' "$job" > "$work/manifest/job"
listing "$work/project/vendor" > "$work/manifest/vendor"
rm -rf "$work/project"
# Record the cache's identity after it becomes read-only.
chmod -R a-w "$work/cache" "$work/home"
listing "$work/cache" > "$work/manifest/files"
chmod -R a-w "$work/manifest"
mv "$work" "$root"
trap - EXIT
verified
;;
install)
shift 2
if ! bash "$here/sdk-composer-cache.sh" warm "$lock_dir" || ! verified; then
echo "sdk-composer-cache: no verified cache; use the authenticated install" >&2
exit 3
fi
# No network: every dist must come from the verified bytes. The
# container removes itself, so no cleanup step owns a new name.
if ! bash scripts/ci/run-composer.sh --rm "$@" --network none -v "$lock_dir:/sdk" \
-v "$root/cache:/composer-cache:ro" -v "$root/home:/composer-home:ro" \
-e COMPOSER_HOME=/composer-home -w /sdk -- install --no-interaction --no-progress; then
echo "sdk-composer-cache: cache-backed install failed; use the authenticated install" >&2
exit 3
fi
if ! plain "$lock_dir/vendor" || ! listing "$lock_dir/vendor" | cmp -s - "$root/manifest/vendor"; then
echo "sdk-composer-cache: installed vendor tree differs from the recorded authenticated install" >&2
exit 4
fi
;;
*)
echo 'Usage: sdk-composer-cache.sh warm LOCK_DIR | install LOCK_DIR [docker run options...]' >&2
exit 2
;;
esac
Loading
Loading