diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..aa219576a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +** +!docker/ +!docker/php-ci/ +!docker/php-ci/Dockerfile +!docker/php-ci/verify-php-trust.php +!docker/brotli/ +!docker/brotli/* +!.central-apt-source/ +!.central-apt-source/docker/ +!.central-apt-source/docker/apt/ +!.central-apt-source/docker/apt/require-fixed-bookworm-apt.sh diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 472172e69..e108f7c71 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -68,6 +68,9 @@ jobs: - name: Verify build and feature selection contracts run: python scripts/ci/test-build-routing.py + - name: Verify published-upgrade PHP container boundaries + run: python3 -I -S -B tests/ci/test_php_container_launcher.py + - name: Require APT retries off before networked APT (CVE-2026-109518) run: python -I scripts/ci/test-apt-no-retries.py @@ -91,7 +94,9 @@ jobs: scripts/ci/laravel-embedded-upgrade-smoke.sh \ scripts/ci/check-packagist-release.sh \ scripts/ci/check-platform-conformance-mirror.sh \ - scripts/ci/test-platform-conformance-resolver-audit.sh + scripts/ci/test-platform-conformance-resolver-audit.sh \ + scripts/ci/setup-php-container.sh \ + scripts/ci/run-php-container.sh php -l scripts/ci/compare-platform-conformance-mirrors.php php -l scripts/ci/check-v2-coverage.php php -l scripts/ci/list-worker-protocol-resolvers.php diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml index e347d8959..111a07c99 100644 --- a/.github/workflows/release-verification.yml +++ b/.github/workflows/release-verification.yml @@ -178,6 +178,8 @@ jobs: - name: Verify published platform contract env: WORKFLOW_PLATFORM_CONFORMANCE_VERSION: ${{ steps.release.outputs.tag }} + WORKFLOW_TIMEOUT_REVISION: ${{ steps.product.outputs.revision }} + WORKFLOW_TIMEOUT_EXPECTED_SOURCE: ${{ github.workspace }}/.release-product RUNNER_TEMP: ${{ env.TMPDIR }} run: scripts/ci/check-platform-conformance-mirror.sh - name: Revalidate PHP after product commands and clean owned state @@ -257,8 +259,15 @@ jobs: laravel-upgrade: name: Laravel ${{ matrix.laravel }} / PHP ${{ matrix.php }} published upgrade needs: published-package - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 25 + env: + GITHUB_TOKEN: '' + GH_TOKEN: '' + GITHUB_ENTERPRISE_TOKEN: '' + GH_ENTERPRISE_TOKEN: '' + COMPOSER_AUTH: '' + WORKFLOW_COMPOSER_TRUST_VERIFIER: ${{ github.workspace }}/.qualification-control/.github/actions/verify-composer-trust/verify.php strategy: fail-fast: false @@ -269,12 +278,28 @@ jobs: with: persist-credentials: false - - name: Disable APT download retries (CVE-2026-109518) - run: echo 'Acquire::Retries "0";' | sudo tee /etc/apt/apt.conf.d/99-no-retries >/dev/null - - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + - name: Check out the fixed Bookworm APT guard + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - php-version: ${{ matrix.php }} - tools: composer + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false + - name: Check out the accepted Composer trust verifier + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: durable-workflow/.github + ref: 242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 + path: .qualification-control + persist-credentials: false + - name: Select authenticated dependency trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust + with: + clients: pip,python,composer,curl,git + - name: Build and verify the fixed PHP runtime + run: scripts/ci/setup-php-container.sh '${{ matrix.php }}' + - name: Verify Composer trust before upgrade requests + uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust - name: Run the published-package upgrade journey env: diff --git a/.gitignore b/.gitignore index 2d6722519..48df7cf2f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ coverage.xml /.regression-corpus-consumer-*/ /.claude/settings.local.json **/.claude/settings.local.json + +.central-apt-source/ +.qualification-control/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ef222ece1..ca6754158 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +## 2.29.0 - 2026-11-13 + +Experimental embedded timeout cleanup is opt-in. Run the database migrations +before using it. The cleanup allowance stays within the original hard deadline; +the business outcome remains timed out. Poll/service admission remains refused. + - **Run migrations.** Opt-in deadline-triggered cleanup (#863, experimental, embedded). A start can reserve the last part of its hard execution or run timeout for cleanup with `StartOptions::withTimeoutCleanup($seconds)` (or diff --git a/composer.json b/composer.json index 75d44fea6..2289976c6 100644 --- a/composer.json +++ b/composer.json @@ -87,7 +87,7 @@ "dev-main": "2.0.x-dev" }, "durable-workflow": { - "product-train": "2.28.0", + "product-train": "2.29.0", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" }, diff --git a/docker/brotli/install-vendor.sh b/docker/brotli/install-vendor.sh new file mode 100644 index 000000000..a60803ef5 --- /dev/null +++ b/docker/brotli/install-vendor.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env sh +set -eu + +inputs="${1:?Brotli verification input directory is required.}" +shift +version="$(cat "$inputs/version")" +test -n "$version" + +apt-get update +apt-get install -y --no-install-recommends \ + "libbrotli1=$version" "libbrotli-dev=$version" "$@" diff --git a/docker/brotli/verify-runtime.php b/docker/brotli/verify-runtime.php new file mode 100644 index 000000000..b708ab0cc --- /dev/null +++ b/docker/brotli/verify-runtime.php @@ -0,0 +1,113 @@ + ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($process)) { + throw new BrotliRuntimeRefused('Cannot inspect the Brotli runtime.'); + } + $output = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + if (proc_close($process) !== 0) { + throw new BrotliRuntimeRefused(implode(' ', $command).' failed: '.trim($error)); + } + + return $output; +} + +function brotliElfMentionsDecoder(string $path): bool +{ + $input = fopen($path, 'rb'); + if ($input === false) { + throw new BrotliRuntimeRefused('Cannot inspect '.$path); + } + try { + if (fread($input, 4) !== "\x7fELF") { + return false; + } + $tail = ''; + while (!feof($input)) { + $chunk = $tail.fread($input, 1024 * 1024); + if (str_contains($chunk, "libbrotlidec.so.1\0")) { + return true; + } + $tail = substr($chunk, -32); + } + + return false; + } finally { + fclose($input); + } +} + +function brotliInspectFile(SplFileInfo $file, array $owned): int +{ + $path = $file->getPathname(); + if (str_starts_with($file->getFilename(), 'libbrotlidec.so') && !$file->isDir() + && !isset($owned[realpath($path) ?: $path])) { + throw new BrotliRuntimeRefused('A decoder is outside the patched package: '.$path); + } + if (!$file->isFile() || $file->isLink() || !brotliElfMentionsDecoder($path)) { + return 0; + } + $links = brotliCommand(['ldd', $path]); + // A library also contains its own SONAME. Only ldd's dependency mapping + // identifies a consumer; an unresolved mapping still fails. + if (!preg_match('~libbrotlidec\.so\.1\s+=>\s+(\S+)~', $links, $match)) { + return 0; + } + if (!isset($owned[realpath($match[1]) ?: $match[1]])) { + throw new BrotliRuntimeRefused('An ELF consumer does not load the patched decoder: '.$path); + } + + return 1; +} + +function verifyBrotliRuntime(): void +{ + $expected = trim((string) file_get_contents(__DIR__.'/version')); + $actual = trim(brotliCommand(['dpkg-query', '-W', '-f=${Version}', 'libbrotli1'])); + if ($actual !== $expected) { + throw new BrotliRuntimeRefused('libbrotli1 is '.$actual.'; expected the reviewed vendor package '.$expected.'.'); + } + $owned = []; + foreach (explode("\n", brotliCommand(['dpkg-query', '-L', 'libbrotli1'])) as $path) { + if (str_contains(basename($path), 'libbrotlidec.so')) { + $resolved = realpath($path); + if ($resolved !== false) { + $owned[$resolved] = true; + } + } + } + if ($owned === []) { + throw new BrotliRuntimeRefused('The patched package owns no decoder.'); + } + + $consumers = 0; + // These are the image's tool, dependency, application and browser roots. + // Symlinked directories are not followed; Debian /lib and /bin resolve into /usr. + foreach (['/usr', '/opt', '/app', '/var/www', '/ms-playwright'] as $root) { + if (!is_dir($root)) { + continue; + } + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)); + foreach ($files as $file) { + $consumers += brotliInspectFile($file, $owned); + } + } + if ($consumers === 0) { + throw new BrotliRuntimeRefused('No system Brotli consumer was inspected.'); + } + echo brotliCommand([__DIR__.'/verify-system-brotli']); + echo json_encode(['package' => $actual, 'elf_consumers' => $consumers], JSON_THROW_ON_ERROR).PHP_EOL; +} + +if (realpath($argv[0] ?? '') === __FILE__) { + verifyBrotliRuntime(); +} diff --git a/docker/brotli/verify-system-brotli.c b/docker/brotli/verify-system-brotli.c new file mode 100644 index 000000000..593560436 --- /dev/null +++ b/docker/brotli/verify-system-brotli.c @@ -0,0 +1,43 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include + +int main(void) { + const char *marker = BrotliDecoderErrorString((BrotliDecoderErrorCode)-17); + Dl_info origin = {0}; + const void *symbol = dlsym(RTLD_DEFAULT, "BrotliDecoderErrorString"); + unsigned version = BrotliDecoderVersion(); + if (!symbol || !dladdr(symbol, &origin) || !origin.dli_fname) { + fputs("Cannot resolve the loaded Brotli decoder.\n", stderr); + return 2; + } + printf("decoder=%u.%u.%u marker=%s library=%s\n", + version >> 24, (version >> 12) & 4095, version & 4095, + marker ? marker : "missing", origin.dli_fname); + /* Brotli 1.0 reports the error name without the newer branch's prefix. */ + if (!marker || strcmp(marker, "BLOCK_SWITCH") != 0) { + fputs("The loaded decoder lacks the CVE-2026-111497 fix.\n", stderr); + return 1; + } + + /* Only valid bytes produced here are decoded; no malformed corpus is used. */ + const unsigned char input[] = "Durable Workflow ordinary Brotli response"; + unsigned char encoded[1024]; + unsigned char decoded[sizeof(input)]; + size_t encoded_size = sizeof(encoded); + size_t decoded_size = sizeof(decoded); + if (!BrotliEncoderCompress(4, 22, BROTLI_MODE_GENERIC, sizeof(input) - 1, + input, &encoded_size, encoded) || + BrotliDecoderDecompress(encoded_size, encoded, &decoded_size, decoded) != + BROTLI_DECODER_RESULT_SUCCESS || + decoded_size != sizeof(input) - 1 || + memcmp(decoded, input, sizeof(input) - 1) != 0) { + fputs("Ordinary Brotli data did not round trip.\n", stderr); + return 2; + } + puts("Valid Brotli data round trips through the corrected system decoder."); + return 0; +} diff --git a/docker/brotli/version b/docker/brotli/version new file mode 100644 index 000000000..92b42278b --- /dev/null +++ b/docker/brotli/version @@ -0,0 +1 @@ +1.0.9-2+deb12u1 diff --git a/docker/php-ci/Dockerfile b/docker/php-ci/Dockerfile new file mode 100644 index 000000000..8e69dba43 --- /dev/null +++ b/docker/php-ci/Dockerfile @@ -0,0 +1,24 @@ +ARG PHP_IMAGE +FROM composer@sha256:2cf275428757e5435a667bfc0d038b14520ca02be2b1b70d44a05c8c19e2de48 AS composer + +FROM ${PHP_IMAGE} + +COPY .central-apt-source/docker/apt/require-fixed-bookworm-apt.sh /usr/local/share/durable-workflow/php-ci/require-fixed-bookworm-apt.sh +COPY --from=composer /usr/bin/composer /usr/local/bin/composer +COPY docker/php-ci/verify-php-trust.php /usr/local/share/durable-workflow/php-ci/verify-php-trust.php +COPY docker/brotli /usr/local/share/durable-workflow/php-ci/brotli + +RUN /bin/sh /usr/local/share/durable-workflow/php-ci/require-fixed-bookworm-apt.sh \ + && printf 'Acquire::Retries "0";\n' > /etc/apt/apt.conf.d/99-no-retries \ + && php /usr/local/share/durable-workflow/php-ci/verify-php-trust.php /usr/local/bin/composer \ + && sh /usr/local/share/durable-workflow/php-ci/brotli/install-vendor.sh /usr/local/share/durable-workflow/php-ci/brotli \ + git unzip python3 gcc libc6-dev libicu-dev libonig-dev libzip-dev \ + && docker-php-ext-install -j2 bcmath intl mbstring pcntl sockets zip \ + && cc -O2 /usr/local/share/durable-workflow/php-ci/brotli/verify-system-brotli.c -lbrotlidec -lbrotlienc -ldl \ + -o /usr/local/share/durable-workflow/php-ci/brotli/verify-system-brotli \ + && cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \ + && php /usr/local/share/durable-workflow/php-ci/verify-php-trust.php /usr/local/bin/composer \ + && php /usr/local/share/durable-workflow/php-ci/brotli/verify-runtime.php \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app diff --git a/docker/php-ci/README.md b/docker/php-ci/README.md new file mode 100644 index 000000000..67dcd9a99 --- /dev/null +++ b/docker/php-ci/README.md @@ -0,0 +1,38 @@ +# PHP runtime for published upgrade journeys + +The published Laravel upgrade job builds one fixed runtime for each PHP minor +in the existing product contract. `scripts/ci/php-container-images.json` pins +the official PHP inputs. The Dockerfile, Composer, trust and Brotli inputs +reuse the reviewed SDK PHP source at +`e6494b86f930c0c196b36a5e94d3b7f5f46c437e`; Workflow owns its additional +PHP minor and the actual Laravel consumers. Qualification does not transfer +between callers. + +The Docker context is an allowlist. The pinned central APT guard runs before +networked APT, extension compilation uses two jobs, and the build checks the +effective PHP/Composer CA bundles and Brotli runtime. The setup command checks +the real minor, required modules/functions, production INI, existing CI +defaults and disabled coverage, then runs the actual Composer TLS fixture. +Host Python uses isolated imports. + +The `php` and `composer` launchers select the inspected immutable config ID, +run as the caller with two CPUs, 2 GiB and 512 PIDs, and remove their containers +on exit. Only source, private dependency state, selected read-only trust inputs +and the owned temporary SQLite directory are mounted. That directory preserves +the original shared-memory database and its sidecars across PHP invocations. +Host credentials are not forwarded. Composer trust is checked before each +networked upgrade operation using the accepted read-only verifier. + +Run the offline launcher controls with: + +```sh +python3 -I -S -B tests/ci/test_php_container_launcher.py +``` + +The hosted caller selects authenticated trust, checks out the exact fixed APT +source, and invokes `scripts/ci/setup-php-container.sh` for its matrix minor. +Local controls may reuse a previously built identical runtime config ID, but +must exercise Workflow's own module/INI, TLS and unchanged upgrade journey. +The source recipe, actual caller, closed central admission record and installed +results all require review before the release matrix is qualified. The initial +published timeout-cleanup probe retains its separately qualified PHP setup. diff --git a/docker/php-ci/ci-defaults.ini b/docker/php-ci/ci-defaults.ini new file mode 100644 index 000000000..7962f647c --- /dev/null +++ b/docker/php-ci/ci-defaults.ini @@ -0,0 +1,6 @@ +; Existing setup-php f3e473d1 defaults from src/configs/ini/{php,jit}.ini. +date.timezone=UTC +memory_limit=-1 +opcache.enable=1 +opcache.jit_buffer_size=256M +opcache.jit=1235 diff --git a/docker/php-ci/verify-php-trust.php b/docker/php-ci/verify-php-trust.php new file mode 100644 index 000000000..79f2e8cce --- /dev/null +++ b/docker/php-ci/verify-php-trust.php @@ -0,0 +1,36 @@ +=") ? 0 : 1);' "$version"; then + WORKFLOW_TIMEOUT_PROJECT="$tmp_dir" \ + WORKFLOW_TIMEOUT_STATE="$tmp_dir/timeout-cleanup-state" \ + WORKFLOW_TIMEOUT_VERSION="$version" \ + php "$repo_root/scripts/ci/qualify-published-timeout-cleanup.php" + php -r '$r=json_decode(file_get_contents($argv[1]),true,flags:JSON_THROW_ON_ERROR); if (($r["outcome"]??null)!=="pass" || count($r["cases"]??[])!==9 || ($r["version"]??null)!==$argv[2] || ($r["source"]??null)!==getenv("WORKFLOW_TIMEOUT_REVISION")) { throw new RuntimeException("Published timeout qualification did not complete"); }' \ + "$tmp_dir/timeout-cleanup-state/result.json" "$version" + fi fi php "$repo_root/scripts/ci/compare-platform-conformance-mirrors.php" "$workflow_file" "$authority_file" diff --git a/scripts/ci/laravel-embedded-upgrade-smoke.sh b/scripts/ci/laravel-embedded-upgrade-smoke.sh index a726207db..d4d1d3824 100755 --- a/scripts/ci/laravel-embedded-upgrade-smoke.sh +++ b/scripts/ci/laravel-embedded-upgrade-smoke.sh @@ -26,7 +26,10 @@ else fi fixture_root=$(mktemp -d "${temporary_root%/}/laravel-embedded-upgrade.XXXXXX") -database_path=$(mktemp "${database_root%/}/laravel-embedded-upgrade.XXXXXX.sqlite") +database_directory=$(mktemp -d "${database_root%/}/laravel-embedded-upgrade.XXXXXX") +database_path="$database_directory/database.sqlite" +touch "$database_path" +export WORKFLOW_UPGRADE_DATABASE_DIRECTORY="$database_directory" app_path="$fixture_root/app" cleanup() { @@ -35,12 +38,19 @@ cleanup() { return fi - rm -f -- "$database_path" + rm -rf -- "$database_directory" rm -rf -- "$fixture_root" } trap cleanup EXIT trap 'echo "Laravel embedded upgrade smoke failed at line ${LINENO}: ${BASH_COMMAND}" >&2' ERR +verify_project_composer_trust() { + if [ -n "${WORKFLOW_COMPOSER_TRUST_VERIFIER:-}" ]; then + php "$WORKFLOW_COMPOSER_TRUST_VERIFIER" "$(command -v composer)" "$PWD" + fi +} + +verify_project_composer_trust composer create-project "laravel/laravel:${laravel_version}" "$app_path" \ --no-interaction --no-progress --prefer-dist --no-blocking --quiet cd "$app_path" @@ -79,6 +89,7 @@ set_env CACHE_STORE file set_env DW_V2_QUEUE workflow-v2 php artisan key:generate --force --no-interaction +verify_project_composer_trust composer require durable-workflow/workflow:1.0.82 \ --with-all-dependencies --no-interaction --no-progress --no-blocking --quiet @@ -363,10 +374,12 @@ if [ -n "$workflow_v2_path" ]; then ], ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); ' "$workflow_v2_path")" + verify_project_composer_trust composer require durable-workflow/workflow:2.0.x-dev \ --with-all-dependencies --no-interaction --no-progress --no-blocking --quiet expected_v2='2.0.x-dev' else + verify_project_composer_trust composer require "durable-workflow/workflow:=${workflow_v2_version}" \ --with-all-dependencies --no-interaction --no-progress --no-blocking --quiet expected_v2="$workflow_v2_version" @@ -672,6 +685,7 @@ composer dump-autoload --no-interaction --no-scripts --quiet if [ -n "$workflow_v2_path" ]; then composer config --unset repositories.workflow fi +verify_project_composer_trust composer require durable-workflow/workflow:^1.0 \ --with-all-dependencies --no-interaction --no-progress --no-blocking --quiet diff --git a/scripts/ci/php-container-images.json b/scripts/ci/php-container-images.json new file mode 100644 index 000000000..488de560b --- /dev/null +++ b/scripts/ci/php-container-images.json @@ -0,0 +1,5 @@ +{ + "8.2": "php:8.2.35-cli-bookworm@sha256:ce09ca1d4b532edf9b0347b44a1968a80d9175addb581c450ba0c9a43fa0c5ea", + "8.3": "php:8.3.36-cli-bookworm@sha256:232331112d3a7c5b98edeeddc3b85f610172f99352c0635fe6b3e166e47062cb", + "8.4": "php:8.4.27-cli-bookworm@sha256:b3e907a1108629c562c4bb313ce3449e34d49991f6bb2487913f6146f47200fe" +} diff --git a/scripts/ci/qualify-published-timeout-cleanup.php b/scripts/ci/qualify-published-timeout-cleanup.php new file mode 100644 index 000000000..3b0b43d32 --- /dev/null +++ b/scripts/ci/qualify-published-timeout-cleanup.php @@ -0,0 +1,418 @@ + $entry['name'] === 'durable-workflow/workflow' + ) + ); + check( + count($locked) === 1 && ($locked[0]['dist']['type'] ?? null) === 'zip' + && ($locked[0]['dist']['reference'] ?? null) === $revision + && ($locked[0]['source']['reference'] ?? null) === $revision, + 'Published qualification requires the exact registry distribution, not a path installation.' + ); + } + $source = requiredEnvironment('WORKFLOW_TIMEOUT_EXPECTED_SOURCE'); + check( + sourceInventory($package . '/src') === sourceInventory($source . '/src'), + 'Installed runtime bytes differ from committed source.' + ); + $manifest = json_decode(file_get_contents($package . '/composer.json'), true, flags: JSON_THROW_ON_ERROR); + check( + ! $published || ($manifest['extra']['durable-workflow']['product-train'] ?? null) === $version, + 'Installed product train differs.' + ); + + #[Type('qualification.timeout-cleanup')] + final class TimeoutCleanupQualificationWorkflow extends Workflow + { + public function handle(string $scenario): string + { + try { + timer(3600); + return 'not-interrupted'; + } catch (WorkflowTimeoutCleanupRequested $interruption) { + if ($scenario === 'failed') { + throw new RuntimeException('Synthetic cleanup failure.'); + } + if ($scenario === 'rethrow') { + throw $interruption; + } + if ($scenario === 'expired') { + timer(3600); + } + if ($scenario === 'cold-replacement') { + timer(2); + } + return activity(TimeoutCleanupQualificationActivity::class, $interruption->cancellation?->cause); + } + } + } + + #[Type('qualification.timeout-effect')] + final class TimeoutCleanupQualificationActivity extends Activity + { + public function handle(?string $cause): string + { + DB::table('timeout_cleanup_effects')->insertOrIgnore([ + 'activity_id' => $this->activityId(), + 'run_id' => $this->runId(), + 'cause' => $cause, + ]); + return 'cleaned-up'; + } + } + + foreach ([ + $project . '/bootstrap/cache', + $state . '/storage/framework/views', + $state . '/storage/logs', + ] as $directory) { + if (! is_dir($directory) && ! mkdir($directory, 0700, true)) { + throw new RuntimeException('Cannot create the owned qualification state.'); + } + } + if (($argv[1] ?? '') !== 'worker') { + check(! file_exists($state . '/database.sqlite'), 'Use fresh isolated state.'); + touch($state . '/database.sqlite'); + } + $settings = [ + 'app.env' => 'qualification', + 'app.timezone' => 'UTC', + 'database.default' => 'qualification', + 'database.connections.qualification' => [ + 'driver' => 'sqlite', + 'database' => $state . '/database.sqlite', + 'prefix' => '', + 'foreign_key_constraints' => true, + 'busy_timeout' => 1000, + 'journal_mode' => 'WAL', + ], + 'queue.default' => 'database', + 'queue.connections.database' => [ + 'driver' => 'database', + 'connection' => 'qualification', + 'table' => 'jobs', + 'queue' => 'timeout-cleanup', + 'retry_after' => 30, + 'after_commit' => false, + ], + 'queue.failed.driver' => 'null', + 'cache.default' => 'array', + 'cache.stores.array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + 'workflows.storage.connection' => 'qualification', + 'workflows.v2.task_dispatch_mode' => 'queue', + 'workflows.v2.matching_role.queue_wake_enabled' => false, + 'workflows.v2.workflow_task_lease_seconds' => 10, + 'workflows.v2.activity_task_lease_seconds' => 10, + 'workflows.v2.types.workflows' => [ + 'qualification.timeout-cleanup' => TimeoutCleanupQualificationWorkflow::class, + ], + 'workflows.v2.types.activities' => [ + 'qualification.timeout-effect' => TimeoutCleanupQualificationActivity::class, + ], + ]; + $app = Application::configure(basePath: $project)->withProviders( + [WorkflowServiceProvider::class] + )->withExceptions() + ->create(); + $app->useStoragePath($state . '/storage'); + $app->afterBootstrapping( + Illuminate\Foundation\Bootstrap\LoadConfiguration::class, + static function (Application $application) use ($settings): void { + $application->make('config') + ->set($settings); + } + ); + $app->make(Kernel::class)->bootstrap(); + + + if (($argv[1] ?? '') === 'worker') { + $workerStatus = Artisan::call('queue:work', [ + 'connection' => 'database', + '--queue' => 'timeout-cleanup', + '--sleep' => 1, + '--timeout' => 10, + '--tries' => 1, + '--max-time' => 40, + '--once' => ($argv[2] ?? '') === 'once', + ]); + echo Artisan::output(); + exit($workerStatus); + } + + Artisan::call('migrate', [ + '--force' => true, + ]); + Schema::create('jobs', static function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('queue') + ->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at') + ->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + Schema::create('timeout_cleanup_effects', static function (Blueprint $table): void { + $table->string('activity_id') + ->primary(); + $table->string('run_id'); + $table->string('cause'); + }); + + $cases = [ + 'completed' => 'completed', + 'failed' => 'failed', + 'rethrow' => 'completed', + 'expired' => 'expired', + 'no-worker' => 'expired', + 'late-detection' => 'expired', + 'preempted' => 'preempted', + 'cold-replacement' => 'completed', + 'legacy-active' => null, + ]; + $results = []; + foreach ($cases as $scenario => $expected) { + $processes = []; + $replaced = false; + try { + $stub = WorkflowStub::make(TimeoutCleanupQualificationWorkflow::class, 'timeout-probe-' . $scenario); + $stub->start( + $scenario, + new StartOptions( + executionTimeoutSeconds: 20, + timeoutCleanupSeconds: $scenario === 'legacy-active' ? null : 15 + ) + ); + $runId = $stub->runId(); + $run = WorkflowRun::query()->findOrFail($runId); + $hard = $run->execution_deadline_at->toISOString(); + $limit = microtime(true) + 35; + if ($scenario === 'preempted') { + $once = worker('once'); + $once->mustRun(); + } elseif (! in_array($scenario, ['no-worker', 'late-detection'], true)) { + $processes[] = worker(); + $processes[0]->start(); + } + while (! $run->status->isTerminal()) { + check(microtime(true) < $limit, 'Scenario exceeded its wall-clock bound: ' . $scenario); + if ($scenario !== 'late-detection' || now()->greaterThan( + $run->execution_deadline_at->copy() + ->addSecond() + )) { + TaskWatchdog::runPass(runIds: [$runId]); + } + $entries = WorkflowHistoryEvent::query()->where('workflow_run_id', $runId) + ->where('event_type', HistoryEventType::WorkflowTimeoutCleanupStarted->value)->count(); + if ($scenario === 'preempted' && $entries === 1) { + $stub->cancel('Qualification hard cancellation.'); + } + if ($scenario === 'cold-replacement' && ! $replaced && $entries === 1 + && WorkflowHistoryEvent::query()->where('workflow_run_id', $runId) + ->where('event_type', HistoryEventType::TimerScheduled->value)->where( + 'payload->delay_seconds', + 2 + )->exists()) { + $processes[0]->signal(SIGKILL); + $processes[0]->wait(); + $processes[] = worker(); + $processes[1]->start(); + $replaced = true; + } + foreach ($processes as $index => $process) { + if ($replaced && $index === 0) { + continue; + } + check( + $process->isRunning(), + 'Queue worker exited before convergence: ' . $process->getErrorOutput() + ); + } + usleep(100_000); + $run = $run->fresh(); + } + $terminal = WorkflowHistoryEvent::query()->where('workflow_run_id', $runId)->orderByDesc( + 'sequence' + )->firstOrFail(); + $payload = $terminal->payload; + check( + $run->status === ($scenario === 'preempted' ? RunStatus::Cancelled : RunStatus::Failed), + 'Wrong business status: ' . $scenario + ); + check( + $terminal->event_type === ($scenario === 'preempted' ? HistoryEventType::WorkflowCancelled : HistoryEventType::WorkflowTimedOut), + 'Wrong terminal outcome: ' . $scenario + ); + check( + ($payload['timeout_cleanup']['outcome'] ?? null) === $expected, + 'Wrong cleanup outcome: ' . $scenario + ); + check($run->execution_deadline_at->toISOString() === $hard, 'Hard deadline was renewed.'); + if ($scenario === 'legacy-active') { + check(! array_key_exists('timeout_cleanup', $payload), 'Legacy close acquired cleanup metadata.'); + } + if ($scenario === 'failed') { + check(is_string($payload['timeout_cleanup']['failure_id'] ?? null), 'Cleanup failure is not linked.'); + } + $deliveries = WorkflowHistoryEvent::query()->where('workflow_run_id', $runId) + ->where('event_type', HistoryEventType::CooperativeCancellationDelivered->value)->count(); + $expectedDeliveries = in_array( + $scenario, + ['no-worker', 'late-detection', 'preempted', 'legacy-active'], + true + ) ? 0 : 1; + check($deliveries === $expectedDeliveries, 'Cleanup delivery was missing or repeated.'); + $expectedEntries = in_array($scenario, ['legacy-active', 'late-detection'], true) ? 0 : 1; + check($entries === $expectedEntries, 'Cleanup entry was missing or repeated: ' . $scenario); + $effects = DB::table('timeout_cleanup_effects')->where('run_id', $runId)->get(); + $expectedEffects = in_array($scenario, ['completed', 'cold-replacement'], true) ? 1 : 0; + check($effects->count() === $expectedEffects, 'Wrong cleanup effect count.'); + if ($expectedEffects === 1) { + check($effects[0]->cause === 'timeout', 'Cleanup lost its portable cause.'); + } + check( + $scenario !== 'cold-replacement' || $replaced, + 'The replacement scenario did not kill its first worker.' + ); + $results[] = [ + 'scenario' => $scenario, + 'business_status' => $run->status->value, + 'terminal_event' => $terminal->event_type->value, + 'cleanup_outcome' => $payload['timeout_cleanup']['outcome'] ?? null, + 'cleanup_entries' => $entries, + 'deliveries' => $deliveries, + 'effects' => $effects->count(), + 'worker_replaced' => $replaced, + 'hard_deadline_retained' => true, + ]; + echo json_encode(end($results), JSON_THROW_ON_ERROR), "\n"; + } finally { + foreach ($processes as $process) { + if ($process->isRunning()) { + $process->stop(1, SIGKILL); + } + } + } + } + $result = [ + 'outcome' => $published ? 'pass' : 'source-prototype-pass', + 'artifact_kind' => $published ? 'published-registry-package' : 'composer-path-prototype', + 'version' => $version, + 'source' => $revision, + 'php' => PHP_VERSION, + 'framework' => app() + ->version(), + 'runtime_tree_sha256' => hash('sha256', json_encode(sourceInventory($package . '/src'), JSON_THROW_ON_ERROR)), + 'composer_lock_sha256' => hash_file('sha256', $project . '/composer.lock'), + 'started_at' => $startedAt, + 'finished_at' => gmdate('c'), + 'cases' => $results, + 'harness_sha256' => hash_file('sha256', __FILE__), + ]; + check(count($results) === count($cases), 'Qualification scenarios are incomplete.'); + $encoded = json_encode($result, JSON_THROW_ON_ERROR); + check( + file_put_contents($state . '/result.json', $encoded . PHP_EOL) !== false, + 'Cannot retain the qualification result.' + ); + echo $encoded, "\n"; +} catch (Throwable $error) { + fwrite(STDERR, $error::class . ': ' . $error->getMessage() . PHP_EOL); + exit(1); +} + +function requiredEnvironment(string $key): string +{ + $value = getenv($key); + if (! is_string($value) || $value === '') { + throw new RuntimeException('Missing qualification input: ' . $key); + } + return $value; +} + +function check(bool $condition, string $message): void +{ + if (! $condition) { + throw new RuntimeException($message); + } +} + +function worker(string $mode = 'daemon'): Process +{ + return new Process([PHP_BINARY, __FILE__, 'worker', $mode], timeout: 45); +} + +function sourceInventory(string $directory): array +{ + $files = []; + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator( + $directory, + FilesystemIterator::SKIP_DOTS + )) as $file) { + check(! $file->isLink() && $file->isFile(), 'Expected regular runtime source files.'); + $files[substr($file->getPathname(), strlen($directory) + 1)] = hash_file('sha256', $file->getPathname()); + } + ksort($files); + return $files; +} diff --git a/scripts/ci/run-php-container.sh b/scripts/ci/run-php-container.sh new file mode 100755 index 000000000..a4ea0fa5b --- /dev/null +++ b/scripts/ci/run-php-container.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +command="${0##*/}" +case "$command" in php|composer) ;; *) echo 'Invoke this launcher as php or composer.' >&2; exit 2 ;; esac +root="${WORKFLOW_PHP_CI_ROOT:?PHP CI repository root is required}" +state="${WORKFLOW_PHP_CI_STATE:?PHP CI state directory is required}" +image="${WORKFLOW_PHP_CI_IMAGE:?PHP CI image is required}" +if [[ ! "$image" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo 'PHP CI must use the inspected immutable image config ID.' >&2 + exit 1 +fi + +options=(--rm --init --interactive --name "workflow-php-ci-${command}-${BASHPID}" + --cpus 2 --memory 2g --pids-limit 512 --network host + --cap-drop ALL --security-opt no-new-privileges + --user "$(id -u):$(id -g)" --workdir "$PWD" + --mount "type=bind,source=$root,target=$root" + --mount "type=bind,source=$state,target=$state" + --mount "type=bind,source=$root/docker/php-ci/ci-defaults.ini,target=/usr/local/etc/php/conf.d/zz-workflow-php-ci.ini,readonly") + +# Composer's guard is a composite action outside the repository checkout. +if [[ -n "${GITHUB_ACTION_PATH:-}" ]]; then + options+=(--mount "type=bind,source=$GITHUB_ACTION_PATH,target=$GITHUB_ACTION_PATH,readonly") +fi +if [[ -n "${WORKFLOW_COMPOSER_TRUST_VERIFIER:-}" ]]; then + options+=(--mount "type=bind,source=$WORKFLOW_COMPOSER_TRUST_VERIFIER,target=$WORKFLOW_COMPOSER_TRUST_VERIFIER,readonly") +fi +# Preserve the original host-shared SQLite database and its WAL sidecars across +# PHP invocations without mounting the host's complete shared-memory directory. +if [[ -n "${WORKFLOW_UPGRADE_DATABASE_DIRECTORY:-}" ]]; then + database="$WORKFLOW_UPGRADE_DATABASE_DIRECTORY" + if [[ ! "$database" =~ ^/dev/shm/laravel-embedded-upgrade\.[a-zA-Z0-9]+$ && + ! "$database" =~ ^"$state"/tmp/laravel-embedded-upgrade\.[a-zA-Z0-9]+$ ]] || + [[ ! -d "$database" || -L "$database" || ! -O "$database" || "$(stat -c %a "$database")" != 700 ]]; then + echo 'Upgrade database must be its owned private temporary directory.' >&2 + exit 1 + fi + options+=(--mount "type=bind,source=$database,target=$database") +fi +# A local worktree has its Git metadata outside the source mount. +git_directory="$(git -C "$root" rev-parse --path-format=absolute --git-common-dir)" +if [[ "$git_directory" != "$root/"* ]]; then + options+=(--mount "type=bind,source=$git_directory,target=$git_directory,readonly") +fi + +declare -A mounted=() +for variable in SSL_CERT_FILE CURL_CA_BUNDLE GIT_SSL_CAINFO COMPOSER_CAFILE \ + REQUESTS_CA_BUNDLE PIP_CERT NPM_CONFIG_CAFILE CARGO_HTTP_CAINFO CARGO_HTTP_PROXY_CAINFO \ + SSL_CERT_DIR GIT_SSL_CAPATH COMPOSER_CAPATH; do + if [[ -n "${!variable:-}" ]]; then + selected="${!variable}" + if [[ -z "${mounted[$selected]:-}" ]]; then + options+=(--mount "type=bind,source=$selected,target=$selected,readonly") + mounted[$selected]=1 + fi + options+=(--env "$variable") + fi +done + +# Keep the original upgrade inputs and caller-owned dependency state. +while IFS= read -r variable; do + case "$variable" in + COMPOSER_HOME|COMPOSER_CACHE_DIR|COMPOSER_MAX_PARALLEL_*|COMPOSER_IPRESOLVE|\ + LARAVEL_VERSION|WORKFLOW_V2_VERSION|WORKFLOW_V2_PATH|KEEP_UPGRADE_FIXTURE|\ + TABLES|DATABASE_PATH|QUEUE_NAME|\ + WORKFLOW_SQLITE_TIMER_QUALIFICATION|WORKFLOW_QUEUE_QUALIFICATION_EVIDENCE|\ + DW_CI_CA_SHA256|GITHUB_ACTIONS|CI|RUNNER_TEMP|TMPDIR|TZ) + options+=(--env "$variable") ;; + *) ;; # Host credentials and unrelated configuration stay outside. + esac +done < <(compgen -e) + +# The host verifier discovers the launcher, but must inspect the real PHAR. +arguments=() +for argument in "$@"; do + if [[ "$argument" == "$state/bin/composer" ]]; then + argument=/usr/local/bin/composer + fi + arguments+=("$argument") +done +exec docker run "${options[@]}" "$image" "$command" "${arguments[@]}" diff --git a/scripts/ci/setup-php-container.sh b/scripts/ci/setup-php-container.sh new file mode 100755 index 000000000..d4a22ace3 --- /dev/null +++ b/scripts/ci/setup-php-container.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +minor="${1:?Usage: setup-php-container.sh 8.2|8.3|8.4}" +image="$(python3 -I -S - "$root/scripts/ci/php-container-images.json" "$minor" <<'PY' +import json,re,sys +images=json.load(open(sys.argv[1])) +image=images[sys.argv[2]] +assert re.fullmatch(r'php:8\.[234]\.\d+-cli-bookworm@sha256:[0-9a-f]{64}', image) +print(image) +PY +)" +[[ -f "$root/.central-apt-source/docker/apt/require-fixed-bookworm-apt.sh" ]] +[[ "$(git -C "$root/.central-apt-source" rev-parse HEAD)" = 18783be6bef737cc778e4c7282a8797e39daa61a ]] +echo '2a36a2417e8947357ea6f6cbc2f6c53a834f3c069f8013a34d6d861b7123d576 '"$root/.central-apt-source/docker/apt/require-fixed-bookworm-apt.sh" | sha256sum --check --strict + +runtime="workflow-php-ci:${minor}-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}" +dockerfile="$root/docker/php-ci/Dockerfile" +docker build --file "$dockerfile" --build-arg "PHP_IMAGE=$image" --tag "$runtime" "$root" +runtime_id="$(docker image inspect "$runtime" --format '{{.Id}}')" +docker run --rm --init --name "workflow-php-ci-probe-${minor}-${GITHUB_RUN_ID:-local}" \ + --cpus 1 --memory 512m --pids-limit 64 --network none --user "$(id -u):$(id -g)" \ + --mount "type=bind,source=$root/docker/php-ci/ci-defaults.ini,target=/usr/local/etc/php/conf.d/zz-workflow-php-ci.ini,readonly" "$runtime_id" \ + php -r ' + if (PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION !== $argv[1]) { throw new RuntimeException("The PHP matrix minor does not match the image."); } + foreach (["mbstring", "intl", "bcmath", "sockets", "zip", "curl", "pcntl", "posix", "dom", "xml", "xmlwriter", "pdo_sqlite", "sqlite3"] as $extension) { + if (!extension_loaded($extension)) { throw new RuntimeException("Missing extension: ".$extension); } + } + foreach (["pcntl_fork", "pcntl_waitpid", "pcntl_signal", "pcntl_async_signals", "posix_kill"] as $function) { + if (!function_exists($function)) { throw new RuntimeException("Missing function: ".$function); } + } + if (extension_loaded("xdebug") || extension_loaded("pcov")) { throw new RuntimeException("Coverage must remain none."); } + if (php_ini_loaded_file() !== "/usr/local/etc/php/php.ini") { throw new RuntimeException("Production INI must be loaded."); } + $defaults = ["date.timezone" => "UTC", "memory_limit" => "-1", "opcache.enable" => "1", "opcache.jit_buffer_size" => "256M", "opcache.jit" => "1235"]; + foreach ($defaults as $setting => $expected) { + if (ini_get($setting) !== $expected) { throw new RuntimeException("Existing CI default changed: ".$setting); } + } + echo json_encode(["php" => PHP_VERSION, "extensions" => get_loaded_extensions(), "ini" => php_ini_loaded_file(), "ci_defaults" => $defaults, "coverage" => "none"]), PHP_EOL; + ' "$minor" +printf 'php_ci_image=%s\n' "$runtime_id" + +state="$(mktemp -d "${RUNNER_TEMP:?RUNNER_TEMP is required}/workflow-php-ci.XXXXXX")" +mkdir -p "$state/bin" "$state/tmp" "$state/composer-home/cache" +ln -sf "$root/scripts/ci/run-php-container.sh" "$state/bin/php" +ln -sf "$root/scripts/ci/run-php-container.sh" "$state/bin/composer" +WORKFLOW_PHP_CI_TEST_IMAGE="$runtime_id" WORKFLOW_PHP_CI_ROOT="$root" WORKFLOW_PHP_CI_STATE="$state" \ + python3 -I -S -B "$root/tests/ci/test_php_container_tls.py" -v +{ + echo "WORKFLOW_PHP_CI_IMAGE=$runtime_id" + echo "WORKFLOW_PHP_CI_ROOT=$root" + echo "WORKFLOW_PHP_CI_STATE=$state" + echo "COMPOSER_HOME=$state/composer-home" + echo "COMPOSER_CACHE_DIR=$state/composer-home/cache" + echo "TMPDIR=$state/tmp" + echo "RUNNER_TEMP=$state/tmp" +} >> "${GITHUB_ENV:?GITHUB_ENV is required}" +echo "$state/bin" >> "${GITHUB_PATH:?GITHUB_PATH is required}" diff --git a/tests/ci/test_php_container_launcher.py b/tests/ci/test_php_container_launcher.py new file mode 100644 index 000000000..f06f27f9f --- /dev/null +++ b/tests/ci/test_php_container_launcher.py @@ -0,0 +1,131 @@ +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +import shutil + + +ROOT = Path(__file__).resolve().parents[2] +LAUNCHER = ROOT / "scripts/ci/run-php-container.sh" + + +class PhpContainerLauncherTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="workflow-php-launcher-") + self.addCleanup(self.temporary.cleanup) + self.state = Path(self.temporary.name) + self.binary = self.state / "bin" + self.binary.mkdir() + self.log = self.state / "docker-arguments.json" + docker = self.binary / "docker" + docker.write_text("#!/usr/bin/env python3\nimport json,os,sys\n" + "open(os.environ['WORKFLOW_PHP_CI_TEST_LOG'],'w').write(json.dumps(sys.argv[1:]))\n") + docker.chmod(0o755) + for name in ("php", "composer"): + (self.binary / name).symlink_to(LAUNCHER) + self.environment = os.environ.copy() + self.environment.update({ + "PATH": str(self.binary) + os.pathsep + self.environment["PATH"], + "WORKFLOW_PHP_CI_ROOT": str(ROOT), "WORKFLOW_PHP_CI_STATE": str(self.state), + "WORKFLOW_PHP_CI_IMAGE": "sha256:" + "a" * 64, + "WORKFLOW_PHP_CI_TEST_LOG": str(self.log), + }) + + def invoke(self, command, *arguments): + return subprocess.run([str(self.binary / command), *arguments], cwd=ROOT, + env=self.environment, capture_output=True, text=True, timeout=10) + + def arguments(self): + return json.loads(self.log.read_text()) + + def test_config_id_and_caps_are_bound_without_passing_host_credentials(self): + for name in ("GH_TOKEN", "GITHUB_TOKEN", "COMPOSER_AUTH"): + self.environment[name] = "synthetic-unforwarded-canary" + result = self.invoke("php", "-r", "echo 1;") + self.assertEqual(result.returncode, 0, result.stderr) + arguments = self.arguments() + self.assertIn("sha256:" + "a" * 64, arguments) + self.assertIn("--init", arguments) + for option, value in (("--cpus", "2"), ("--memory", "2g"), + ("--pids-limit", "512"), ("--cap-drop", "ALL"), + ("--security-opt", "no-new-privileges")): + self.assertEqual(arguments[arguments.index(option) + 1], value) + for name in ("GH_TOKEN", "GITHUB_TOKEN", "COMPOSER_AUTH"): + self.assertNotIn(name, arguments) + self.assertNotIn("synthetic-unforwarded-canary", str(arguments)) + + def test_mutable_image_selector_refuses_before_docker(self): + self.environment["WORKFLOW_PHP_CI_IMAGE"] = "php:8.4" + result = self.invoke("php", "--version") + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.log.exists()) + + def test_existing_ci_ini_defaults_are_mounted_read_only(self): + result = self.invoke("php", "-r", "echo ini_get('memory_limit');") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"type=bind,source={ROOT}/docker/php-ci/ci-defaults.ini,target=/usr/local/etc/php/conf.d/zz-workflow-php-ci.ini,readonly", + self.arguments()) + + def test_selected_ca_mounts_are_read_only_and_composer_guard_uses_real_phar(self): + bundle = self.state / "selected.pem" + bundle.write_text("public certificate fixture\n") + roots = self.state / "empty-roots" + roots.mkdir() + self.environment.update({"SSL_CERT_FILE": str(bundle), "COMPOSER_CAFILE": str(bundle), + "SSL_CERT_DIR": str(roots), "COMPOSER_CAPATH": str(roots)}) + result = self.invoke("php", "/reviewed/verify.php", str(self.binary / "composer")) + self.assertEqual(result.returncode, 0, result.stderr) + arguments = self.arguments() + for selected in (bundle, roots): + mount = f"type=bind,source={selected},target={selected},readonly" + self.assertEqual(arguments.count(mount), 1) + self.assertEqual(arguments[-3:], ["php", "/reviewed/verify.php", "/usr/local/bin/composer"]) + + def test_host_selector_ignores_workspace_and_pythonpath_modules_before_apt(self): + for location in ("workspace", "pythonpath"): + with self.subTest(location=location): + root = self.state / location + scripts = root / "scripts/ci" + scripts.mkdir(parents=True) + for name in ("setup-php-container.sh", "php-container-images.json"): + shutil.copyfile(ROOT / "scripts/ci" / name, scripts / name) + marker = root / "untrusted-import-ran" + modules = root if location == "workspace" else root / "ambient" + modules.mkdir(exist_ok=True) + (modules / "json.py").write_text(f"open({str(marker)!r},'w').write('marker only')\n") + environment = self.environment | {"PYTHONPATH": str(modules)} + result = subprocess.run(["bash", str(scripts / "setup-php-container.sh"), "8.2"], + cwd=root, env=environment, capture_output=True, timeout=10) + self.assertNotEqual(result.returncode, 0, "The deliberately absent APT helper must stop before Docker.") + self.assertFalse(marker.exists(), "Host setup must never import workspace/PYTHONPATH code.") + self.assertFalse(self.log.exists()) + + def test_original_upgrade_inputs_and_private_database_cross_the_container_boundary(self): + database = self.state / "tmp/laravel-embedded-upgrade.fixture123" + database.mkdir(parents=True, mode=0o700) + verifier = self.state / "verify.php" + verifier.write_text("