diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md new file mode 100644 index 00000000..b6d7dd93 --- /dev/null +++ b/.superpowers/sdd/task-2-report.md @@ -0,0 +1,34 @@ +# Task 2 Report: PHP Extension Metadata And Overlay Helpers + +## Status + +DONE_WITH_CONCERNS + +## Summary + +- Added `resources::php_extensions` with artifact metadata parsing, request resolution, runtime overlay writing, and runtime environment helpers. +- Exported `PhpExtensionModule`, `PhpExtensionLoadKind`, `PhpExtensionResolution`, `PHP_EXTENSION_METADATA_PATH`, and the new helper functions from `resources`. +- Added optional `php_extensions` artifact metadata parsing to `ManifestArtifact` with a public `php_extensions()` accessor. +- Added integration coverage for metadata resolution and runtime overlay generation. +- Updated manifest snapshots to include parsed PHP extension metadata and default empty metadata for older manifests. + +## TDD Notes + +- Wrote `crates/resources/tests/php_extensions.rs` and extended `manifest_foundation.rs` before implementation. +- Red check failed as expected with missing exports and missing `ManifestArtifact::php_extensions()`. +- Implemented the minimal resources-side helpers and manifest parsing needed to satisfy the tests. + +## Verification + +- PASS: `cargo nextest run -p resources -E 'test(resolves_available_and_ignored_php_extensions_from_artifact_metadata) or test(writes_runtime_overlay_for_loaded_php_extensions) or test(manifest_parses_registry_backed_resources_tracks_and_artifacts)'` +- PASS: `cargo nextest run -p resources --no-fail-fast` +- PASS: `cargo clippy -p resources --all-targets --all-features --locked -- -D warnings` +- EXPECTED FAIL: `cargo build --workspace --all-targets` fails only in known staged daemon callers using `Option::as_deref()` at `crates/daemon/src/project_env.rs:119` and `crates/daemon/src/gateway.rs:482`. + +## Scope Notes + +- `crates/resources/src/runtime.rs` was listed in the task file, but no runtime adapter change was needed. Requiring PHP extension metadata during artifact validation would violate the approved design requirement that older PHP artifacts without metadata remain valid and are treated as supporting no optional extensions. + +## Concerns + +- Workspace build remains blocked by the known Task 1 downstream `PhpConfig` API migration work outside this task's scope. diff --git a/DESIGN.md b/DESIGN.md index 3edd8f57..2aeb8c2a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -10,7 +10,7 @@ PV has laravel style commands where commmands under the same category/famility a - PHP — managed per-version, no homebrew/apt needed - Mysql, Postgresql, Redis, Composer, Mailpit, Rustfs all Ready to go -Per-project versions are supported too — add a pv.yml file with php: "8.4" in your project root. Multiple PHP versions run simultaneously, with Projects routed through workers grouped by PHP track. +Per-project versions are supported too — add a pv.yml file with php: "8.4" in your project root. Multiple PHP versions run simultaneously, with Projects routed through workers grouped by PHP runtime identity. ## High-Level Features @@ -222,9 +222,9 @@ Crash restart backoff retries a desired child process up to 3 times with increas PV resets a child process crash counter after the process stays healthy for 60 seconds. -Crash-loop failures are scoped to the affected runtime. A failed Managed Resource track degrades only Projects that need that resource track. A failed PHP-track worker affects only Projects on that PHP track. Gateway failure is system-wide because all Project routing depends on it. +Crash-loop failures are scoped to the affected runtime. A failed Managed Resource track degrades only Projects that need that resource track. A failed PHP runtime worker affects only Projects on that PHP runtime identity. Gateway failure is system-wide because all Project routing depends on it. -Backing Managed Resource failures do not remove Project routes. PV keeps serving the web app in a degraded state when the Gateway and PHP-track worker are healthy. +Backing Managed Resource failures do not remove Project routes. PV keeps serving the web app in a degraded state when the Gateway and PHP runtime worker are healthy. PV writes pid files under `~/.pv/run/` for the Gateway, Project-serving workers, and Managed Resource tracks. After daemon restart, PV may use these pid files to discover existing PV-owned child processes, but it must verify ownership before acting by checking the process command/path matches the expected PV-managed binary and config. PV never kills a process based on PID alone. @@ -254,7 +254,7 @@ If a linked Project config becomes invalid, the daemon keeps serving the last va Project config changes restart or reload only affected runtime processes. PHP version or routing changes may restart/reassign FrankenPHP serving for the affected Project. Env-only changes update `.env` without restarting the Gateway. -When a Project's PHP track changes, PV reconfigures only affected Project-serving workers. It may stop an old PHP worker if no Projects remain on that track and start or reload the new track's worker. Unrelated PHP workers are not touched. +When a Project's PHP track or optional extension set changes, PV reconfigures only affected Project-serving workers. It may stop an old PHP worker if no Projects remain on that runtime identity and start or reload the new runtime's worker. Unrelated PHP workers are not touched. `pv setup` is the friendly first-time bootstrap path and includes daemon registration. `pv daemon:*` commands remain available as lower-level lifecycle and troubleshooting commands. @@ -321,11 +321,11 @@ export COMPOSER_CACHE_DIR="/Users//.pv/composer/cache"; ## Multi-version PHP The Gateway is a Managed Resource role implemented by a PV-managed FrankenPHP/Caddy process, not an HTTP server implemented inside the PV daemon. The PV daemon provisions a Gateway that listens on high loopback ports; macOS `pf` redirects external loopback ports `80` and `443` to the Gateway. -Projects using a different PHP version are proxied to secondary FrankenPHP processes running on high ports. +Projects using a different PHP runtime are proxied to secondary FrankenPHP processes running on high ports. -The Gateway is always-on core PV infrastructure after setup. It only routes/proxies and does not serve Projects directly. Version-specific Project-serving FrankenPHP processes run only when at least one linked Project needs that PHP version. +The Gateway is always-on core PV infrastructure after setup. It only routes/proxies and does not serve Projects directly. Runtime-specific Project-serving FrankenPHP processes run only when at least one linked Project needs that PHP runtime identity. -Each Project-serving FrankenPHP worker serves all Projects assigned to one PHP track. PV does not run one worker per Project. +Each Project-serving FrankenPHP worker serves all Projects assigned to one PHP runtime identity. The runtime identity is the resolved PHP track plus the sorted available optional extension set. PV does not run one worker per Project. Project-serving FrankenPHP workers bind only to loopback high ports. They are internal to PV behind the Gateway. @@ -337,13 +337,13 @@ When proxying to Project-serving workers, the Gateway preserves the original `Ho PV generates a Gateway root config that imports per-Project generated config files. Splitting Project config keeps debugging easier and reduces config-generation blast radius. -For each PHP track, PV generates a worker root config that imports per-Project generated config files for Projects on that track. +For each PHP runtime identity, PV generates a worker root config that imports per-Project generated config files for Projects on that runtime. -When PHP-track worker config changes, PV reloads the worker where supported and restarts it only if reload fails or is unavailable. +When PHP runtime worker config changes, PV reloads the worker where supported and restarts it only if reload fails or is unavailable. -Project-serving worker logs are captured per PHP track, with Project hostname included in access logs where feasible. PV v1 does not create per-Project log files. Caddy/FrankenPHP log rotation directives should be used where practical. +Project-serving worker logs are captured per PHP runtime identity, with Project hostname included in access logs where feasible. PV v1 does not create per-Project log files. Caddy/FrankenPHP log rotation directives should be used where practical. -Project-serving worker logs are split by PHP track, such as `~/.pv/logs/workers/php-8.4.log`, because one worker serves all Projects assigned to that PHP track. +Project-serving worker logs are split by PHP runtime identity, such as `~/.pv/logs/workers/php-8.4.log` or `~/.pv/logs/workers/php-8.4+redis.log`, because one worker serves all Projects assigned to that runtime. Gateway access logs are enabled by default, stored locally under `~/.pv/logs/`, and rotated. Gateway logs are split into access and error logs, such as `~/.pv/logs/gateway/access.log` and `~/.pv/logs/gateway/error.log`, when FrankenPHP/Caddy supports that cleanly. Structured/JSON logs should be used when Caddy/FrankenPHP supports them cleanly. @@ -355,32 +355,37 @@ The Gateway does not automatically route `*.project.test` to a Project. Subdomai For unknown `.test` hostnames, the Gateway should return a simple self-contained HTML response explaining that no PV Project is linked for the hostname and suggesting `pv link` when technically feasible. -PV v1 avoids PHP extension management. PHP and FrankenPHP Managed Resource artifacts are distributed as prebuilt macOS binaries with a fixed, common extension set baked in. PV does not expose extension install, uninstall, or per-Project extension configuration in v1. +PV supports Project-level PHP extension opt-ins without named profiles, local compilation, or arbitrary user-provided shared modules. PHP and FrankenPHP Managed Resource artifacts are distributed as prebuilt macOS binaries with a common default extension set plus a curated catalog of bundled optional shared modules. Optional modules are disabled by default and loaded only through PV-generated runtime ini overlays when a Project asks for them. -PV v1 builds standalone PHP and FrankenPHP as single-binary/static-style artifacts with fixed compiled-in extensions. These artifacts must not depend on Homebrew or local package-manager libraries. PV v1 does not support dynamic PHP extension loading, `phpize`, or PECL-installed extensions. +PV builds standalone PHP and FrankenPHP as single-binary/static-style artifacts with fixed default compiled-in extensions and bundled optional shared modules. These artifacts must not depend on Homebrew or local package-manager libraries. PV does not support arbitrary dynamic PHP extension loading, `phpize`, or PECL-installed extensions. Standalone PHP artifacts include the `php` executable and runtime files needed by that build. They do not include `phpize` or `php-config` in v1 because user-built extensions are not supported. -The v1 fixed PHP extension set is Laravel-first and shared across supported PHP tracks: `bcmath`, `ctype`, `curl`, `dom`, `fileinfo`, `filter`, `hash`, `iconv`, `intl`, `json`, `libxml`, `mbstring`, `openssl`, `pcntl`, `pcre`, `pdo`, `pdo_mysql`, `pdo_pgsql`, `pdo_sqlite`, `pdo_sqlsrv`, `phar`, `posix`, `redis`, `session`, `simplexml`, `sodium`, `sqlite3`, `sqlsrv`, `tokenizer`, `xml`, `xmlreader`, `xmlwriter`, `zip`, and `zlib`. +The default loaded PHP extension set is Laravel-first and shared across supported PHP tracks: `bcmath`, `ctype`, `curl`, `dom`, `fileinfo`, `filter`, `hash`, `iconv`, `intl`, `json`, `libxml`, `mbstring`, `openssl`, `pcntl`, `pcre`, `pdo`, `pdo_mysql`, `pdo_pgsql`, `pdo_sqlite`, `phar`, `posix`, `session`, `simplexml`, `sockets`, `sodium`, `sqlite3`, `tokenizer`, `xml`, `xmlreader`, `xmlwriter`, `zip`, and `zlib`. -For a given PHP track, standalone PHP and FrankenPHP must expose the same compiled-in PHP extension set so CLI and browser execution do not drift. +The initial bundled optional extension catalog is `redis`, `sqlsrv`, `pdo_sqlsrv`, `xdebug`, `apcu`, `pcov`, `imagick`, `mongodb`, and `yaml`. Future optional extensions should be added only when users ask for them and PV can build, smoke-test, license, and support them across the intended PHP track and platform matrix. + +For a given PHP runtime identity, standalone PHP and FrankenPHP must expose the same loaded PHP extension set so CLI and browser execution do not drift. For a given PHP track, standalone PHP and FrankenPHP must use the exact same PHP patch version. For example, if the `8.4` track resolves to PHP `8.4.8`, both the standalone PHP artifact and the FrankenPHP artifact for that track use PHP `8.4.8`. -PV v1 ships one PHP build flavor per PHP track. Xdebug is not included in the default v1 PHP build. Extra-extension flavors, such as builds with `xdebug`, `imagick`, `swoole`, or `mongodb`, are out of v1 scope until PV deliberately designs multi-flavor PHP artifacts. +PV ships one PHP artifact pair per PHP track. Optional extension combinations do not create separate downloaded artifact flavors in the first implementation; they create runtime-specific ini overlays and FrankenPHP workers from the same installed track artifact. PV builds its own FrankenPHP artifacts for the PHP tracks it supports because upstream FrankenPHP releases do not provide the exact PV-required build matrix. The initial PV-managed FrankenPHP/PHP tracks are `8.3`, `8.4`, and `8.5`, with `8.5` as the manifest default track. -PV v1 does not support custom PHP ini settings in Project config. +PV does not support custom PHP ini settings in Project config. For each installed PHP track, PV seeds track-level PHP defaults under `~/.pv/resources/php//etc/php.ini` and `~/.pv/resources/php//etc/conf.d/`. The defaults are mutable track data, not artifact release payload data, so artifact updates and old-release pruning do not remove user edits. PV runs standalone PHP, Composer-through-PHP, and Project-serving FrankenPHP workers with process-level `PHPRC` and `PHP_INI_SCAN_DIR` pointing at the track defaults. PV does not pass these ini discovery paths through Caddyfile `env` and does not expand the default profile into Caddyfile `php_ini` directives. +For Project-level extension opt-ins, PV generates runtime-specific `conf.d` overlays under PV-owned config storage and appends those overlays to `PHP_INI_SCAN_DIR` for the affected standalone PHP, Composer-through-PHP, and Project-serving FrankenPHP worker processes. Generated extension ini files are PV-owned and replaced during reconciliation. Unsupported extension names in Project config are ignored at runtime and surfaced as non-blocking diagnostics rather than Project config errors. + - If there are 5 Projects and all of them use the same PHP version, PV provisions 1 Project-serving FrankenPHP process. -- If 2 Projects use PHP 8.3, 2 use PHP 8.4, and 1 uses PHP 8.5, PV provisions 3 Project-serving FrankenPHP processes. The Gateway proxies each Project hostname to the worker for that Project's PHP version. +- If 2 Projects use PHP 8.3, 2 use PHP 8.4, and 1 uses PHP 8.5, PV provisions 3 Project-serving FrankenPHP processes. The Gateway proxies each Project hostname to the worker for that Project's PHP runtime. +- If 2 Projects use PHP 8.4 with no optional extensions and 1 Project uses PHP 8.4 with `redis`, PV provisions 2 Project-serving FrankenPHP processes for PHP 8.4. User commands describe what should exist. The daemon reconciles the machine toward that desired state and records observed status when reality does not match. -PHP version resolution: Project config `php` field → global default. +PHP runtime resolution: Project config `php` field → global default. Project config may use either scalar form, such as `php: 8.4`, or object form, such as `php: { version: 8.4, extensions: [redis] }`. If the object form omits `version`, PV resolves the PHP track through the global/default flow and applies the requested extension list to that resolved track. PV does not infer PHP versions from `composer.json`. Composer constraints can be complex and are not always present, so Projects that need a specific PHP version should declare it in Project config. @@ -752,11 +757,11 @@ Reconciliation scopes are `system`, `project:`, and `resource:: PV v1 exposes generic shims only. It does not create versioned shims like `php8.4` or `mysql8.0`; exact versioned binaries remain available under `~/.pv/resources/` for advanced use. -The `php` shim is Project-aware, similar to version managers such as `fnm` or `nvm`. When run inside a linked Project, it uses that Project's resolved PHP track. Outside a linked Project, it uses the global default PHP track. +The `php` shim is Project-aware, similar to version managers such as `fnm` or `nvm`. When run inside a linked Project, it uses that Project's resolved PHP runtime identity. Outside a linked Project, it uses the global default PHP track without Project-level optional extensions. Composer is split by responsibility: the Composer PHAR and version metadata live under `~/.pv/resources/composer/`, the Composer shim lives under `~/.pv/bin/`, and `~/.pv/composer/` is the user-facing `COMPOSER_HOME` for global packages and cache. -The Composer shim invokes the Composer PHAR through PV's `php` shim so Composer inherits Project-aware PHP selection. Inside a linked Project, Composer uses that Project's PHP track; outside, it uses the global default PHP track. +The Composer shim invokes the Composer PHAR through PV's `php` shim so Composer inherits Project-aware PHP selection. Inside a linked Project, Composer uses that Project's PHP runtime identity; outside, it uses the global default PHP track without Project-level optional extensions. Composer uses the same artifact track model as other Managed Resources, but v1 exposes only one Composer track: `2`. PV installs and updates the latest non-revoked Composer artifact in the `2` track. Composer 1 compatibility is out of v1 scope. @@ -1328,7 +1333,7 @@ When `pv logs --follow` streams multiple files, PV prefixes each line with the s `pv logs --gateway` shows both Gateway access and error logs by default when split Gateway logs exist. When following both streams, PV prefixes lines with sources such as `gateway:access` and `gateway:error`. A combined v1 Gateway log remains supported and is labeled `gateway` instead of requiring a runtime log-layout redesign. -`pv logs --worker ` accepts explicit PHP tracks and `latest`. `latest` resolves to the manifest default PHP track. If the resolved track has no log file, PV prints a clear message that no logs exist for that PHP track. +`pv logs --worker ` accepts explicit PHP runtime identities, such as `8.4` or `8.4+redis`, and `latest`. `latest` resolves to the manifest default PHP track without Project-level optional extensions. If the resolved runtime has no log file, PV prints a clear message that no logs exist for that PHP runtime. `pv logs` supports Managed Resource log filtering with flags such as `--resource mysql --track 8.0`, matching the resource/track log layout. diff --git a/crates/cli/src/commands/doctor.rs b/crates/cli/src/commands/doctor.rs index 396c6b5d..a1104159 100644 --- a/crates/cli/src/commands/doctor.rs +++ b/crates/cli/src/commands/doctor.rs @@ -622,6 +622,9 @@ fn runtime_subject_label(subject: &state::RuntimeSubject) -> String { match subject { state::RuntimeSubject::Gateway => "gateway".to_string(), state::RuntimeSubject::PhpWorker { php_track } => format!("worker:{php_track}"), + state::RuntimeSubject::PhpRuntimeWorker { php_runtime_key } => { + format!("worker:{php_runtime_key}") + } state::RuntimeSubject::Resource { name, track } => format!("{name}:{track}"), } } diff --git a/crates/cli/src/commands/logs.rs b/crates/cli/src/commands/logs.rs index 43043776..08e93173 100644 --- a/crates/cli/src/commands/logs.rs +++ b/crates/cli/src/commands/logs.rs @@ -167,8 +167,14 @@ fn installed_worker_sources(paths: &PvPaths) -> Result, ExecuteEr let mut tracks = BTreeSet::new(); for state in database.runtime_observed_states()? { - if let state::RuntimeSubject::PhpWorker { php_track } = state.subject { - tracks.insert(php_track); + match state.subject { + state::RuntimeSubject::PhpWorker { php_track } => { + tracks.insert(php_track); + } + state::RuntimeSubject::PhpRuntimeWorker { php_runtime_key } => { + tracks.insert(php_runtime_key); + } + state::RuntimeSubject::Gateway | state::RuntimeSubject::Resource { .. } => {} } } diff --git a/crates/cli/src/commands/php.rs b/crates/cli/src/commands/php.rs index 7c05aff1..5be2eb8f 100644 --- a/crates/cli/src/commands/php.rs +++ b/crates/cli/src/commands/php.rs @@ -4,11 +4,11 @@ use std::io; use std::io::Write; use std::process::ExitCode; -use camino::Utf8PathBuf; +use camino::{Utf8Path, Utf8PathBuf}; use resources::{ - ArtifactManifestCache, ManagedResourceCommands, ManagedResourceUninstallOptions, - ResourceAdapter, ResourceHttpClient, ResourceName, TargetPlatform, TrackName, TrackSelector, - UreqResourceHttpClient, + ArtifactManifestCache, ConcreteTrackName, ManagedResourceCommands, + ManagedResourceUninstallOptions, ResourceAdapter, ResourceHttpClient, ResourceName, + TargetPlatform, TrackName, TrackSelector, UreqResourceHttpClient, }; use serde::Serialize; use state::{Database, ManagedResourceDesiredState, ProjectRecord, PvPaths, StateError}; @@ -259,10 +259,20 @@ pub(crate) fn shim_with_args_and_env( ) -> Result { let paths = pv_paths(environment)?; let database = Database::open(&paths)?; - let track = resolve_php_track_for_shim(&paths, &database, environment)?; - let installed = installed_php(&database, &track)?; - resources::ensure_php_track_defaults(&paths, &track)?; - env.extend(resources::php_track_exec_environment(&paths, &track)?); + let runtime = resolve_php_runtime_for_shim(&paths, &database, environment)?; + let installed = installed_php(&database, &runtime.track)?; + resources::ensure_php_track_defaults(&paths, &runtime.track)?; + let loaded_modules = resources::resolve_persisted_php_extension_modules( + installed.release_path(), + &runtime.loaded_extensions, + )?; + env.extend(resources::php_runtime_exec_environment( + &paths, + &runtime.track, + &runtime.runtime_key, + installed.release_path(), + &loaded_modules, + )?); let executable = installed.executable()?; environment @@ -270,29 +280,188 @@ pub(crate) fn shim_with_args_and_env( .map_err(ExecuteError::from) } -fn resolve_php_track_for_shim( +struct PhpShimRuntime { + track: String, + runtime_key: String, + loaded_extensions: Vec, +} + +fn resolve_php_runtime_for_shim( paths: &PvPaths, database: &Database, environment: &impl Environment, -) -> Result { +) -> Result { let current_dir = current_dir(environment)?; - if let Some(project) = database.nearest_project_for_path(¤t_dir)? - && let Some(track) = project.desired_php_track - { - return Ok(track); - } + if let Some(project) = database.nearest_project_for_path(¤t_dir)? { + let config_file = match config::ProjectConfigFile::read_from_root(&project.path) { + Ok(config_file) => config_file, + Err(error) => { + if let Some(runtime) = persisted_project_php_runtime_for_shim(&project)? { + return Ok(runtime); + } - if let Some(track) = database.global_php_default_track()? { - return Ok(track); + return Err(error.into()); + } + }; + if let Some(track) = project.php_runtime.track.clone() { + let php = config_file.config.php.as_ref(); + let requested_extensions = php + .map(|php| php.requested_extensions().to_vec()) + .unwrap_or_default(); + let config_track = if let Some(php) = php { + match php.version_selector() { + Some(selector) if selector != "latest" && selector != track => Some( + resolve_project_config_php_track_for_shim(paths, database, php)?, + ), + None if database.global_php_default_track()?.is_some() + || paths.downloads().join("manifest.json").exists() => + { + let resolved_track = + resolve_project_config_php_track_for_shim(paths, database, php)?; + if resolved_track == track { + None + } else { + Some(resolved_track) + } + } + _ => None, + } + } else { + None + }; + let current_track = config_track.as_deref().unwrap_or(&track).to_string(); + if requested_extensions.is_empty() { + return Ok(PhpShimRuntime { + runtime_key: current_track.clone(), + track: current_track, + loaded_extensions: Vec::new(), + }); + } + if config_track.is_none() + && project.php_runtime.requested_extensions == requested_extensions + { + let runtime_key = + state::php_runtime_key(&track, &project.php_runtime.loaded_extensions)?; + + return Ok(PhpShimRuntime { + track, + runtime_key, + loaded_extensions: project.php_runtime.loaded_extensions, + }); + } + if let Some(php) = php { + return resolve_project_config_php_runtime_for_shim(database, ¤t_track, php); + } + } else if let Some(php) = config_file.config.php.as_ref() { + let track = resolve_project_config_php_track_for_shim(paths, database, php)?; + + return resolve_project_config_php_runtime_for_shim(database, &track, php); + } } - let manifest = ArtifactManifestCache::new(paths.downloads()).load_cached()?; - let php = ResourceName::new("php")?; + let track = match database.global_php_default_track()? { + Some(track) => track, + None => { + let manifest = ArtifactManifestCache::new(paths.downloads()).load_cached()?; + let php = ResourceName::new("php")?; - Ok(manifest - .resolve_track(&php, TrackSelector::Latest)? - .as_str() - .to_string()) + manifest + .resolve_track(&php, TrackSelector::Latest)? + .as_str() + .to_string() + } + }; + + Ok(PhpShimRuntime { + runtime_key: track.clone(), + track, + loaded_extensions: Vec::new(), + }) +} + +fn persisted_project_php_runtime_for_shim( + project: &ProjectRecord, +) -> Result, ExecuteError> { + let Some(track) = project.php_runtime.track.clone() else { + return Ok(None); + }; + let loaded_extensions = project.php_runtime.loaded_extensions.clone(); + let runtime_key = state::php_runtime_key(&track, &loaded_extensions)?; + + Ok(Some(PhpShimRuntime { + track, + runtime_key, + loaded_extensions, + })) +} + +fn resolve_project_config_php_track_for_shim( + paths: &PvPaths, + database: &Database, + php: &config::PhpConfig, +) -> Result { + let selector = php + .version_selector() + .map(TrackSelector::parse) + .transpose()?; + let track = match selector { + Some(TrackSelector::Latest) => { + let manifest = ArtifactManifestCache::new(paths.downloads()).load_cached()?; + let php = ResourceName::new("php")?; + + manifest + .resolve_track(&php, TrackSelector::Latest)? + .as_str() + .to_string() + } + Some(TrackSelector::Track(track)) => track.as_str().to_string(), + None => match database.global_php_default_track()? { + Some(track) => track, + None => { + let manifest = ArtifactManifestCache::new(paths.downloads()).load_cached()?; + let php = ResourceName::new("php")?; + + manifest + .resolve_track(&php, TrackSelector::Latest)? + .as_str() + .to_string() + } + }, + }; + let track = ConcreteTrackName::new(track)?; + + Ok(track.as_str().to_string()) +} + +fn resolve_project_config_php_runtime_for_shim( + database: &Database, + track: &str, + php: &config::PhpConfig, +) -> Result { + let requested_extensions = php.requested_extensions().to_vec(); + if requested_extensions.is_empty() { + return Ok(PhpShimRuntime { + runtime_key: track.to_string(), + track: track.to_string(), + loaded_extensions: Vec::new(), + }); + } + + let installed = installed_php(database, track)?; + let resolution = + resources::resolve_php_extension_request(installed.release_path(), &requested_extensions)?; + let loaded_extensions = resolution + .loaded + .iter() + .map(|module| module.name.clone()) + .collect::>(); + let runtime_key = state::php_runtime_key(track, &loaded_extensions)?; + + Ok(PhpShimRuntime { + track: track.to_string(), + runtime_key, + loaded_extensions, + }) } fn effective_global_php_default_track( @@ -323,6 +492,10 @@ impl InstalledPhp { Ok(adapter.executable_path(&self.release)) } + + fn release_path(&self) -> &Utf8Path { + &self.release + } } fn installed_php(database: &Database, track: &str) -> Result { diff --git a/crates/cli/src/commands/project.rs b/crates/cli/src/commands/project.rs index d236c990..ed5e2cbd 100644 --- a/crates/cli/src/commands/project.rs +++ b/crates/cli/src/commands/project.rs @@ -28,7 +28,14 @@ pub(crate) fn link( let original_project_path = resolve_project_path(args.path.as_deref(), environment)?; let config_file = ProjectConfigFile::read_from_root(&original_project_path)?; let project_path = project_root_from_config_path(&config_file.path)?; - let desired_php_track = resolved_project_php_track(&paths, config_file.config.php.as_deref())?; + let desired_php_track = resolved_project_php_track( + &paths, + config_file + .config + .php + .as_ref() + .and_then(|php| php.version_selector()), + )?; let mut database = Database::open(&paths)?; let existing = database.project_by_path(&project_path)?; let primary_hostname = match (args.hostname, existing.as_ref()) { @@ -618,14 +625,21 @@ fn project_list_env_status( has_env_mappings: bool, observed: Option, ) -> (ProjectEnvStatus, Option) { - if !has_env_mappings { - return (ProjectEnvStatus::None, None); - } - let Some(observed) = observed else { + if !has_env_mappings { + return (ProjectEnvStatus::None, None); + } + return (ProjectEnvStatus::Pending, None); }; + if !has_env_mappings + && (observed.status != ProjectEnvObservedStatus::Warning + || !has_ignored_php_extension_warning(&observed)) + { + return (ProjectEnvStatus::None, None); + } + match observed.status { ProjectEnvObservedStatus::Failed => ( ProjectEnvStatus::Failed, @@ -641,6 +655,16 @@ fn project_list_env_status( } fn project_env_observed_warning_summary(observed: &ProjectEnvObservedStateRecord) -> String { + let ignored = observed + .warnings + .iter() + .filter(|warning| warning.kind == "ignored_php_extension") + .map(|warning| warning.message.as_str()) + .collect::>(); + if !ignored.is_empty() { + return format!("warning: {}", ignored.join("; ")); + } + match observed.warnings.as_slice() { [warning] => format!("warning: {}", warning.message), [] => observed @@ -651,3 +675,10 @@ fn project_env_observed_warning_summary(observed: &ProjectEnvObservedStateRecord warnings => format!("warning: {} warnings", warnings.len()), } } + +fn has_ignored_php_extension_warning(observed: &ProjectEnvObservedStateRecord) -> bool { + observed + .warnings + .iter() + .any(|warning| warning.kind == "ignored_php_extension") +} diff --git a/crates/cli/src/commands/status.rs b/crates/cli/src/commands/status.rs index 0b8aa313..950c8040 100644 --- a/crates/cli/src/commands/status.rs +++ b/crates/cli/src/commands/status.rs @@ -387,7 +387,9 @@ fn runtime_statuses(runtime_states: &[RuntimeObservedStateRecord]) -> Vec Some(RuntimeStatus { + RuntimeSubject::Gateway + | RuntimeSubject::PhpWorker { .. } + | RuntimeSubject::PhpRuntimeWorker { .. } => Some(RuntimeStatus { subject: runtime_subject_label(&state.subject), status: runtime_status_label(state.status), message: state.message.clone(), @@ -431,16 +433,39 @@ fn project_status( }; let env_status = project_env_status_label(observed.status); let failure = observed.status == ProjectEnvObservedStatus::Failed; + let message = if observed.status == ProjectEnvObservedStatus::Warning { + project_env_warning_message(&observed) + } else { + observed.message + }; ProjectStatus { hostname: project.primary_hostname, env_status, - message: observed.message, + message, observed_at: Some(observed.observed_at), failure, } } +fn project_env_warning_message(observed: &state::ProjectEnvObservedStateRecord) -> Option { + let ignored = observed + .warnings + .iter() + .filter(|warning| warning.kind == "ignored_php_extension") + .map(|warning| warning.message.as_str()) + .collect::>(); + if !ignored.is_empty() { + return Some(ignored.join("; ")); + } + + observed + .warnings + .first() + .map(|warning| warning.message.clone()) + .or_else(|| observed.message.clone()) +} + fn launch_agent_status(state: &LaunchAgentFileState) -> &'static str { match state { LaunchAgentFileState::Missing { .. } => "missing", @@ -535,6 +560,9 @@ fn runtime_subject_label(subject: &RuntimeSubject) -> String { match subject { RuntimeSubject::Gateway => "gateway".to_string(), RuntimeSubject::PhpWorker { php_track } => format!("worker:{php_track}"), + RuntimeSubject::PhpRuntimeWorker { php_runtime_key } => { + format!("worker:{php_runtime_key}") + } RuntimeSubject::Resource { name, track } => format!("{name}:{track}"), } } diff --git a/crates/cli/tests/composer.rs b/crates/cli/tests/composer.rs index 082045d6..1cacbd37 100644 --- a/crates/cli/tests/composer.rs +++ b/crates/cli/tests/composer.rs @@ -10,9 +10,13 @@ use std::process::ExitCode; use camino::{Utf8Path, Utf8PathBuf}; use camino_tempfile::tempdir; use cli::{Environment, run_with_environment}; +use config::ProjectConfigFile; use insta::assert_debug_snapshot; use resources::{ResourceHttpClient, ResourcesError, TargetPlatform}; -use state::{Database, ManagedResourceDesiredState, ManagedResourceTrackRecord, PvPaths}; +use state::{ + Database, LinkProjectInput, ManagedResourceDesiredState, ManagedResourceTrackRecord, + ProjectRecord, PvPaths, fs, +}; const MANIFEST_URL: &str = "https://artifacts.example.test/manifest.json"; @@ -482,6 +486,52 @@ fn composer_shim_execs_installed_phar_through_php_shim() -> anyhow::Result<()> { Ok(()) } +#[test] +fn composer_shim_inherits_project_php_extension_runtime_overlay() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let home = tempdir.path().join("home"); + let project = tempdir.path().join("acme"); + create_dir(&project)?; + write_file( + &project.join("pv.yml"), + "php:\n version: 8.4\n extensions: [redis]\n", + )?; + let project_record = register_project(&home, &project, "acme.test")?; + let php_release = record_installed_php(&home, "8.4", "8.4.8-pv1")?; + let composer_artifact = composer_fixture_artifact("2.8.1-pv1"); + let composer_release = record_installed_composer(&home, "2", &composer_artifact)?; + let composer_phar = composer_release.join("composer.phar"); + fs::write_sensitive_file( + &php_release.join("share/pv/php-extensions.json"), + r#"[{"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}]"#, + )?; + fs::write_sensitive_file(&php_release.join("lib/php/extensions/redis.so"), "")?; + { + let mut database = Database::open(&pv_paths(&home))?; + database.replace_project_php_runtime( + &project_record.id, + Some(&state::ProjectPhpRuntimeInput { + track: "8.4".to_string(), + requested_extensions: vec!["redis".to_string()], + loaded_extensions: vec!["redis".to_string()], + ignored_extensions: Vec::new(), + }), + )?; + } + let environment = TestEnvironment::new(&home, &project_record.path, ScriptedClient::new()); + + let output = run_pv(&["shim:composer", "about"], &environment)?; + let exec_calls = environment.exec_calls(); + + assert_eq!(output.exit_code, ExitCode::SUCCESS); + assert_eq!(exec_calls[0].args[0], composer_phar.to_string()); + assert!(exec_calls[0].env.iter().any(|(key, value)| { + key == "PHP_INI_SCAN_DIR" && value.contains("php-runtimes/8.4+redis/conf.d") + })); + + Ok(()) +} + #[test] fn composer_shim_sets_pv_owned_env_overlay() -> anyhow::Result<()> { let tempdir = tempdir()?; @@ -1103,6 +1153,33 @@ fn with_tempdir_filters( settings.bind(assertions) } +fn register_project( + home: &Utf8Path, + project: &Utf8Path, + primary_hostname: &str, +) -> anyhow::Result { + let config_file = ProjectConfigFile::read_from_root(project)?; + let project_path = project_root_from_config_path(&config_file.path)?; + let mut database = Database::open(&pv_paths(home))?; + let result = database.link_project(LinkProjectInput { + path: project_path, + original_path: project.to_path_buf(), + primary_hostname: primary_hostname.to_string(), + config_path: config_file.path, + desired_php_track: None, + additional_hostnames: config_file.config.hostnames, + })?; + + Ok(result.project) +} + +fn project_root_from_config_path(config_path: &Utf8Path) -> anyhow::Result { + config_path + .parent() + .map(Utf8Path::to_path_buf) + .ok_or_else(|| anyhow::anyhow!("Project config path has no parent: {config_path}")) +} + fn pv_paths(home: &Utf8Path) -> PvPaths { PvPaths::for_home(home.to_path_buf()) } diff --git a/crates/cli/tests/php.rs b/crates/cli/tests/php.rs index 548bdf65..2cb3f349 100644 --- a/crates/cli/tests/php.rs +++ b/crates/cli/tests/php.rs @@ -207,6 +207,408 @@ fn php_shim_execs_resolved_project_track() -> anyhow::Result<()> { Ok(()) } +#[test] +fn php_shim_uses_project_extension_runtime_overlay() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let home = tempdir.path().join("home"); + let project = tempdir.path().join("acme"); + create_dir(&project)?; + write_file( + &project.join("pv.yml"), + "php:\n version: 8.4\n extensions: [redis]\n", + )?; + let project_record = register_project(&home, &project, "acme.test")?; + let release = record_installed_php(&home, "8.4", "8.4.8-pv1")?; + fs::write_sensitive_file( + &release.join("share/pv/php-extensions.json"), + r#"[{"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}]"#, + )?; + fs::write_sensitive_file(&release.join("lib/php/extensions/redis.so"), "")?; + { + let mut database = Database::open(&pv_paths(&home))?; + database.replace_project_php_runtime( + &project_record.id, + Some(&state::ProjectPhpRuntimeInput { + track: "8.4".to_string(), + requested_extensions: vec!["redis".to_string()], + loaded_extensions: vec!["redis".to_string()], + ignored_extensions: Vec::new(), + }), + )?; + } + let environment = TestEnvironment::new(&home, &project_record.path, ScriptedClient::new()); + + let output = run_pv(&["shim:php", "-m"], &environment)?; + let exec_calls = environment.exec_calls(); + + assert_eq!(output.exit_code, ExitCode::SUCCESS); + assert!(exec_calls[0].env.iter().any(|(key, value)| { + key == "PHP_INI_SCAN_DIR" && value.contains("php-runtimes/8.4+redis/conf.d") + })); + + Ok(()) +} + +#[test] +fn php_shim_uses_persisted_runtime_when_project_config_is_invalid() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let home = tempdir.path().join("home"); + let project = tempdir.path().join("acme"); + create_dir(&project)?; + write_file( + &project.join("pv.yml"), + "php:\n version: 8.4\n extensions: [redis]\n", + )?; + let project_record = register_project(&home, &project, "acme.test")?; + let release = record_installed_php(&home, "8.4", "8.4.8-pv1")?; + fs::write_sensitive_file( + &release.join("share/pv/php-extensions.json"), + r#"[{"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}]"#, + )?; + fs::write_sensitive_file(&release.join("lib/php/extensions/redis.so"), "")?; + { + let mut database = Database::open(&pv_paths(&home))?; + database.replace_project_php_runtime( + &project_record.id, + Some(&state::ProjectPhpRuntimeInput { + track: "8.4".to_string(), + requested_extensions: vec!["redis".to_string()], + loaded_extensions: vec!["redis".to_string()], + ignored_extensions: Vec::new(), + }), + )?; + } + write_file(&project.join("pv.yml"), "php:\n version: [\n")?; + let environment = TestEnvironment::new(&home, &project_record.path, ScriptedClient::new()); + + let output = run_pv(&["shim:php", "-m"], &environment)?; + let exec_calls = environment.exec_calls(); + + assert_eq!(output.exit_code, ExitCode::SUCCESS); + assert_eq!( + exec_calls[0].program, + release.join("bin/php").as_std_path().to_path_buf(), + ); + assert!(exec_calls[0].env.iter().any(|(key, value)| { + key == "PHP_INI_SCAN_DIR" && value.contains("php-runtimes/8.4+redis/conf.d") + })); + + Ok(()) +} + +#[test] +fn php_shim_resolves_project_config_extensions_when_persisted_runtime_is_empty() +-> anyhow::Result<()> { + let tempdir = tempdir()?; + let home = tempdir.path().join("home"); + let project = tempdir.path().join("acme"); + create_dir(&project)?; + write_file( + &project.join("pv.yml"), + "php:\n version: 8.4\n extensions: [redis]\n", + )?; + let project_record = register_project(&home, &project, "acme.test")?; + select_project_php_track(&home, &project_record, "8.4")?; + let release = record_installed_php(&home, "8.4", "8.4.8-pv1")?; + fs::write_sensitive_file( + &release.join("share/pv/php-extensions.json"), + r#"[{"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}]"#, + )?; + fs::write_sensitive_file(&release.join("lib/php/extensions/redis.so"), "")?; + let environment = TestEnvironment::new(&home, &project_record.path, ScriptedClient::new()); + + let output = run_pv(&["shim:php", "-m"], &environment)?; + let exec_calls = environment.exec_calls(); + + assert_eq!(output.exit_code, ExitCode::SUCCESS); + assert!(exec_calls[0].env.iter().any(|(key, value)| { + key == "PHP_INI_SCAN_DIR" && value.contains("php-runtimes/8.4+redis/conf.d") + })); + + Ok(()) +} + +#[test] +fn php_shim_uses_base_runtime_when_project_config_removes_extensions() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let home = tempdir.path().join("home"); + let project = tempdir.path().join("acme"); + create_dir(&project)?; + write_file(&project.join("pv.yml"), "php:\n version: 8.4\n")?; + let project_record = register_project(&home, &project, "acme.test")?; + let release = record_installed_php(&home, "8.4", "8.4.8-pv1")?; + fs::write_sensitive_file( + &release.join("share/pv/php-extensions.json"), + r#"[{"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}]"#, + )?; + fs::write_sensitive_file(&release.join("lib/php/extensions/redis.so"), "")?; + { + let mut database = Database::open(&pv_paths(&home))?; + database.replace_project_php_runtime( + &project_record.id, + Some(&state::ProjectPhpRuntimeInput { + track: "8.4".to_string(), + requested_extensions: vec!["redis".to_string()], + loaded_extensions: vec!["redis".to_string()], + ignored_extensions: Vec::new(), + }), + )?; + } + let environment = TestEnvironment::new(&home, &project_record.path, ScriptedClient::new()); + + let output = run_pv(&["shim:php", "-m"], &environment)?; + let exec_calls = environment.exec_calls(); + + assert_eq!(output.exit_code, ExitCode::SUCCESS); + assert_eq!(exec_calls[0].env, php_exec_env(&home, "8.4")?); + + Ok(()) +} + +#[test] +fn php_shim_reuses_persisted_empty_extension_runtime() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let home = tempdir.path().join("home"); + let project = tempdir.path().join("acme"); + create_dir(&project)?; + write_file( + &project.join("pv.yml"), + "php:\n version: 8.4\n extensions: [redis]\n", + )?; + let project_record = register_project(&home, &project, "acme.test")?; + let release = record_installed_php(&home, "8.4", "8.4.8-pv1")?; + fs::write_sensitive_file( + &release.join("share/pv/php-extensions.json"), + r#"[{"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}]"#, + )?; + fs::write_sensitive_file(&release.join("lib/php/extensions/redis.so"), "")?; + { + let mut database = Database::open(&pv_paths(&home))?; + database.replace_project_php_runtime( + &project_record.id, + Some(&state::ProjectPhpRuntimeInput { + track: "8.4".to_string(), + requested_extensions: vec!["redis".to_string()], + loaded_extensions: Vec::new(), + ignored_extensions: vec!["redis".to_string()], + }), + )?; + } + let environment = TestEnvironment::new(&home, &project_record.path, ScriptedClient::new()); + + let output = run_pv(&["shim:php", "-m"], &environment)?; + let exec_calls = environment.exec_calls(); + + assert_eq!(output.exit_code, ExitCode::SUCCESS); + assert_eq!(exec_calls[0].env, php_exec_env(&home, "8.4")?); + + Ok(()) +} + +#[test] +fn php_shim_resolves_config_track_when_recomputing_extensions() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let home = tempdir.path().join("home"); + let project = tempdir.path().join("acme"); + create_dir(&project)?; + write_file( + &project.join("pv.yml"), + "php:\n version: 8.4\n extensions: [xdebug]\n", + )?; + let project_record = register_project(&home, &project, "acme.test")?; + let old_release = record_installed_php(&home, "8.3", "8.3.25-pv1")?; + fs::write_sensitive_file( + &old_release.join("share/pv/php-extensions.json"), + r#"[{"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}]"#, + )?; + fs::write_sensitive_file(&old_release.join("lib/php/extensions/redis.so"), "")?; + let new_release = record_installed_php(&home, "8.4", "8.4.8-pv1")?; + fs::write_sensitive_file( + &new_release.join("share/pv/php-extensions.json"), + r#"[{"name":"xdebug","load_kind":"zend_extension","path":"lib/php/extensions/xdebug.so"}]"#, + )?; + fs::write_sensitive_file(&new_release.join("lib/php/extensions/xdebug.so"), "")?; + { + let mut database = Database::open(&pv_paths(&home))?; + database.replace_project_php_runtime( + &project_record.id, + Some(&state::ProjectPhpRuntimeInput { + track: "8.3".to_string(), + requested_extensions: vec!["redis".to_string()], + loaded_extensions: vec!["redis".to_string()], + ignored_extensions: Vec::new(), + }), + )?; + } + let environment = TestEnvironment::new(&home, &project_record.path, ScriptedClient::new()); + + let output = run_pv(&["shim:php", "-m"], &environment)?; + let exec_calls = environment.exec_calls(); + + assert_eq!(output.exit_code, ExitCode::SUCCESS); + assert_eq!( + exec_calls[0].program, + new_release.join("bin/php").as_std_path().to_path_buf(), + ); + assert!(exec_calls[0].env.iter().any(|(key, value)| { + key == "PHP_INI_SCAN_DIR" && value.contains("php-runtimes/8.4+xdebug/conf.d") + })); + + Ok(()) +} + +#[test] +fn php_shim_recomputes_latest_extensions_against_persisted_track() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let home = tempdir.path().join("home"); + let project = tempdir.path().join("acme"); + create_dir(&project)?; + write_file( + &project.join("pv.yml"), + "php:\n version: latest\n extensions: [redis]\n", + )?; + let project_record = register_project(&home, &project, "acme.test")?; + let old_release = record_installed_php(&home, "8.4", "8.4.8-pv1")?; + fs::write_sensitive_file( + &old_release.join("share/pv/php-extensions.json"), + r#"[{"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}]"#, + )?; + fs::write_sensitive_file(&old_release.join("lib/php/extensions/redis.so"), "")?; + let new_release = record_installed_php(&home, "8.5", "8.5.0-pv1")?; + fs::write_sensitive_file( + &new_release.join("share/pv/php-extensions.json"), + r#"[{"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}]"#, + )?; + fs::write_sensitive_file(&new_release.join("lib/php/extensions/redis.so"), "")?; + let old_artifact = + runtime_fixture_artifact("php", "8.4.8-pv1", "bin/php", TargetPlatform::DarwinArm64); + let new_artifact = + runtime_fixture_artifact("php", "8.5.0-pv1", "bin/php", TargetPlatform::DarwinArm64); + cache_manifest( + &home, + &manifest_with_resources(&[manifest_resource( + "php", + "8.5", + vec![ + manifest_track("8.4", vec![&old_artifact]), + manifest_track("8.5", vec![&new_artifact]), + ], + )]), + )?; + { + let mut database = Database::open(&pv_paths(&home))?; + database.replace_project_php_runtime( + &project_record.id, + Some(&state::ProjectPhpRuntimeInput { + track: "8.4".to_string(), + requested_extensions: Vec::new(), + loaded_extensions: Vec::new(), + ignored_extensions: Vec::new(), + }), + )?; + } + let environment = TestEnvironment::new(&home, &project_record.path, ScriptedClient::new()); + + let output = run_pv(&["shim:php", "-m"], &environment)?; + let exec_calls = environment.exec_calls(); + + assert_eq!(output.exit_code, ExitCode::SUCCESS); + assert_eq!( + exec_calls[0].program, + old_release.join("bin/php").as_std_path().to_path_buf(), + ); + assert!(exec_calls[0].env.iter().any(|(key, value)| { + key == "PHP_INI_SCAN_DIR" && value.contains("php-runtimes/8.4+redis/conf.d") + })); + + Ok(()) +} + +#[test] +fn php_shim_resolves_global_default_track_for_extension_only_config() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let home = tempdir.path().join("home"); + let project = tempdir.path().join("acme"); + create_dir(&project)?; + write_file(&project.join("pv.yml"), "php:\n extensions: [redis]\n")?; + let project_record = register_project(&home, &project, "acme.test")?; + let old_release = record_installed_php(&home, "8.3", "8.3.25-pv1")?; + fs::write_sensitive_file( + &old_release.join("share/pv/php-extensions.json"), + r#"[{"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}]"#, + )?; + fs::write_sensitive_file(&old_release.join("lib/php/extensions/redis.so"), "")?; + let new_release = record_installed_php(&home, "8.4", "8.4.8-pv1")?; + fs::write_sensitive_file( + &new_release.join("share/pv/php-extensions.json"), + r#"[{"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}]"#, + )?; + fs::write_sensitive_file(&new_release.join("lib/php/extensions/redis.so"), "")?; + { + let mut database = Database::open(&pv_paths(&home))?; + database.record_global_php_default_track("8.4")?; + database.replace_project_php_runtime( + &project_record.id, + Some(&state::ProjectPhpRuntimeInput { + track: "8.3".to_string(), + requested_extensions: vec!["redis".to_string()], + loaded_extensions: vec!["redis".to_string()], + ignored_extensions: Vec::new(), + }), + )?; + } + let environment = TestEnvironment::new(&home, &project_record.path, ScriptedClient::new()); + + let output = run_pv(&["shim:php", "-m"], &environment)?; + let exec_calls = environment.exec_calls(); + + assert_eq!(output.exit_code, ExitCode::SUCCESS); + assert_eq!( + exec_calls[0].program, + new_release.join("bin/php").as_std_path().to_path_buf(), + ); + assert!(exec_calls[0].env.iter().any(|(key, value)| { + key == "PHP_INI_SCAN_DIR" && value.contains("php-runtimes/8.4+redis/conf.d") + })); + + Ok(()) +} + +#[test] +fn php_shim_fails_when_persisted_loaded_extension_metadata_is_missing() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let home = tempdir.path().join("home"); + let project = tempdir.path().join("acme"); + create_dir(&project)?; + write_file( + &project.join("pv.yml"), + "php:\n version: 8.4\n extensions: [redis]\n", + )?; + let project_record = register_project(&home, &project, "acme.test")?; + record_installed_php(&home, "8.4", "8.4.8-pv1")?; + { + let mut database = Database::open(&pv_paths(&home))?; + database.replace_project_php_runtime( + &project_record.id, + Some(&state::ProjectPhpRuntimeInput { + track: "8.4".to_string(), + requested_extensions: vec!["redis".to_string()], + loaded_extensions: vec!["redis".to_string()], + ignored_extensions: Vec::new(), + }), + )?; + } + let environment = TestEnvironment::new(&home, &project_record.path, ScriptedClient::new()); + + let output = run_pv(&["shim:php", "-m"], &environment)?; + + assert_eq!(output.exit_code, ExitCode::FAILURE); + assert!(environment.exec_calls().is_empty()); + assert!(output.stderr.contains("persisted PHP extension `redis`")); + + Ok(()) +} + #[test] fn php_shim_sets_only_php_ini_env_overlay() -> anyhow::Result<()> { let tempdir = tempdir()?; @@ -389,7 +791,14 @@ fn php_use_updates_project_config_state_and_reports_missing_daemon() -> anyhow:: assert_eq!(output.exit_code, ExitCode::SUCCESS); assert!(output.stderr.is_empty()); - assert_eq!(config_file.config.php.as_deref(), Some("8.4")); + assert_eq!( + config_file + .config + .php + .as_ref() + .and_then(|php| php.version_selector()), + Some("8.4") + ); assert_eq!(project_after.desired_php_track.as_deref(), Some("8.4")); with_tempdir_filters(tempdir.path(), || { assert_debug_snapshot!(( @@ -433,7 +842,14 @@ fn php_use_latest_preserves_alias_in_config_and_records_resolved_track() -> anyh assert_eq!(output.exit_code, ExitCode::SUCCESS); assert!(output.stderr.is_empty()); - assert_eq!(config_file.config.php.as_deref(), Some("latest")); + assert_eq!( + config_file + .config + .php + .as_ref() + .and_then(|php| php.version_selector()), + Some("latest") + ); assert_eq!(project_after.desired_php_track.as_deref(), Some("8.4")); with_tempdir_filters(tempdir.path(), || { assert_debug_snapshot!(( diff --git a/crates/cli/tests/project_env.rs b/crates/cli/tests/project_env.rs index a1d74906..6239d100 100644 --- a/crates/cli/tests/project_env.rs +++ b/crates/cli/tests/project_env.rs @@ -324,13 +324,19 @@ fn register_project( ) -> anyhow::Result { let config_file = ProjectConfigFile::read_from_root(project)?; let project_path = project_root_from_config_path(&config_file.path)?; + let desired_php_track = config_file + .config + .php + .as_ref() + .and_then(|php| php.version_selector()) + .map(str::to_owned); let mut database = Database::open(&pv_paths(home))?; let result = database.link_project(LinkProjectInput { path: project_path, original_path: project.to_path_buf(), primary_hostname: primary_hostname.to_string(), config_path: config_file.path, - desired_php_track: config_file.config.php, + desired_php_track, additional_hostnames: config_file.config.hostnames, })?; diff --git a/crates/cli/tests/snapshots/doctor__doctor_fails_when_active_pf_redirects_are_missing.snap b/crates/cli/tests/snapshots/doctor__doctor_fails_when_active_pf_redirects_are_missing.snap index 0c18aa44..a387824b 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_fails_when_active_pf_redirects_are_missing.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_fails_when_active_pf_redirects_are_missing.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 7 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] DNS config: system resolver uses port 35353\n[fail] Port redirect config: active pf redirects are not loaded\n repair: `pv ports:install`\n[pass] Local CA trust: system trust matches fingerprint \n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 9 passed, 0 warning(s), 1 failed\n", + stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 8 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] DNS config: system resolver uses port 35353\n[fail] Port redirect config: active pf redirects are not loaded\n repair: `pv ports:install`\n[pass] Local CA trust: system trust matches fingerprint \n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 9 passed, 0 warning(s), 1 failed\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/doctor__doctor_fails_when_daemon_socket_is_stale.snap b/crates/cli/tests/snapshots/doctor__doctor_fails_when_daemon_socket_is_stale.snap index 5afe0773..b52c34a4 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_fails_when_daemon_socket_is_stale.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_fails_when_daemon_socket_is_stale.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 7 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[fail] Daemon socket: daemon socket is present but daemon did not answer health check\n path: /home/.pv/run/pv.sock; error: I/O error: Socket operation on non-socket (os error 38)\n repair: `pv daemon:restart`\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: system pf config and active redirects are current\n[pass] Local CA trust: system trust matches fingerprint \n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 9 passed, 0 warning(s), 1 failed\n", + stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 8 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[fail] Daemon socket: daemon socket is present but daemon did not answer health check\n path: /home/.pv/run/pv.sock; error: I/O error: Socket operation on non-socket (os error 38)\n repair: `pv daemon:restart`\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: system pf config and active redirects are current\n[pass] Local CA trust: system trust matches fingerprint \n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 9 passed, 0 warning(s), 1 failed\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_ca_trust_is_missing.snap b/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_ca_trust_is_missing.snap index 261a5488..7f5909ce 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_ca_trust_is_missing.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_ca_trust_is_missing.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 7 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: system pf config and active redirects are current\n[fail] Local CA trust: local CA is not trusted in the System keychain\n fingerprint: \n repair: `pv ca:trust`\n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 9 passed, 0 warning(s), 1 failed\n", + stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 8 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: system pf config and active redirects are current\n[fail] Local CA trust: local CA is not trusted in the System keychain\n fingerprint: \n repair: `pv ca:trust`\n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 9 passed, 0 warning(s), 1 failed\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_resolver_is_missing.snap b/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_resolver_is_missing.snap index 217c5969..a894dd19 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_resolver_is_missing.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_resolver_is_missing.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 7 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[fail] DNS config: system resolver config is missing\n path: /home/etc/resolver/test\n repair: `pv dns:install`\n[pass] Port redirect config: system pf config and active redirects are current\n[pass] Local CA trust: system trust matches fingerprint \n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 9 passed, 0 warning(s), 1 failed\n", + stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 8 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[fail] DNS config: system resolver config is missing\n path: /home/etc/resolver/test\n repair: `pv dns:install`\n[pass] Port redirect config: system pf config and active redirects are current\n[pass] Local CA trust: system trust matches fingerprint \n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 9 passed, 0 warning(s), 1 failed\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/doctor__doctor_fails_with_repair_commands.snap b/crates/cli/tests/snapshots/doctor__doctor_fails_with_repair_commands.snap index 259665bb..70670a44 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_fails_with_repair_commands.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_fails_with_repair_commands.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 7 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[fail] Daemon socket: daemon socket is missing\n path: /home/.pv/run/pv.sock\n repair: `pv daemon:restart`\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: system pf config and active redirects are current\n[pass] Local CA trust: system trust matches fingerprint \n[fail] Recent jobs: 1 failed job(s) in recent history\n reconcile system: Gateway failed to start\n repair: `pv setup`\n[fail] Runtime states: 1 degraded or failed runtime observation(s)\n gateway Gateway failed to start\n repair: `pv daemon:restart`\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 7 passed, 0 warning(s), 3 failed\n", + stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 8 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[fail] Daemon socket: daemon socket is missing\n path: /home/.pv/run/pv.sock\n repair: `pv daemon:restart`\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: system pf config and active redirects are current\n[pass] Local CA trust: system trust matches fingerprint \n[fail] Recent jobs: 1 failed job(s) in recent history\n reconcile system: Gateway failed to start\n repair: `pv setup`\n[fail] Runtime states: 1 degraded or failed runtime observation(s)\n gateway Gateway failed to start\n repair: `pv daemon:restart`\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 7 passed, 0 warning(s), 3 failed\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/doctor__doctor_passes_when_required_checks_pass.snap b/crates/cli/tests/snapshots/doctor__doctor_passes_when_required_checks_pass.snap index 9ffedb60..8e408eac 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_passes_when_required_checks_pass.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_passes_when_required_checks_pass.snap @@ -8,6 +8,6 @@ RunOutput { 0, ), ), - stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 7 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: system pf config and active redirects are current\n[pass] Local CA trust: system trust matches fingerprint \n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 10 passed, 0 warning(s), 0 failed\n", + stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 8 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: system pf config and active redirects are current\n[pass] Local CA trust: system trust matches fingerprint \n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 10 passed, 0 warning(s), 0 failed\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/doctor__doctor_warnings_do_not_fail.snap b/crates/cli/tests/snapshots/doctor__doctor_warnings_do_not_fail.snap index ea742f9c..3c2ae918 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_warnings_do_not_fail.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_warnings_do_not_fail.snap @@ -8,6 +8,6 @@ RunOutput { 0, ), ), - stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 7 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: system pf config and active redirects are current\n[pass] Local CA trust: system trust matches fingerprint \n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[warn] Artifact manifest cache: cached artifact manifest is missing\n path: /home/.pv/downloads/manifest.json\n repair: `pv setup`\nSummary: 9 passed, 1 warning(s), 0 failed\n", + stdout: "PV doctor\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 8 migrations applied\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: system pf config and active redirects are current\n[pass] Local CA trust: system trust matches fingerprint \n[pass] Recent jobs: no failed jobs in recent history\n[pass] Runtime states: no degraded or failed runtime observations\n[warn] Artifact manifest cache: cached artifact manifest is missing\n path: /home/.pv/downloads/manifest.json\n repair: `pv setup`\nSummary: 9 passed, 1 warning(s), 0 failed\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/php__php_use_latest_preserves_alias_in_config_and_records_resolved_track.snap b/crates/cli/tests/snapshots/php__php_use_latest_preserves_alias_in_config_and_records_resolved_track.snap index 13f420a6..f8c491a7 100644 --- a/crates/cli/tests/snapshots/php__php_use_latest_preserves_alias_in_config_and_records_resolved_track.snap +++ b/crates/cli/tests/snapshots/php__php_use_latest_preserves_alias_in_config_and_records_resolved_track.snap @@ -15,7 +15,12 @@ expression: "(output, config_after, config_file.config,\nproject_snapshot(&proje "php: latest\nhostnames:\n- api.acme.test\n", ProjectConfig { php: Some( - "latest", + PhpConfig { + version: Some( + "latest", + ), + extensions: [], + }, ), document_root: None, hostnames: [ diff --git a/crates/cli/tests/snapshots/php__php_use_updates_project_config_state_and_reports_missing_daemon.snap b/crates/cli/tests/snapshots/php__php_use_updates_project_config_state_and_reports_missing_daemon.snap index 35690eca..fd90e686 100644 --- a/crates/cli/tests/snapshots/php__php_use_updates_project_config_state_and_reports_missing_daemon.snap +++ b/crates/cli/tests/snapshots/php__php_use_updates_project_config_state_and_reports_missing_daemon.snap @@ -14,7 +14,12 @@ expression: "(output, config_file.config,\nproject_snapshot(&project_after, temp }, ProjectConfig { php: Some( - "8.4", + PhpConfig { + version: Some( + "8.4", + ), + extensions: [], + }, ), document_root: None, hostnames: [ diff --git a/crates/cli/tests/snapshots/status__status_prefers_ignored_php_extension_over_other_project_env_warnings.snap b/crates/cli/tests/snapshots/status__status_prefers_ignored_php_extension_over_other_project_env_warnings.snap new file mode 100644 index 00000000..1283f716 --- /dev/null +++ b/crates/cli/tests/snapshots/status__status_prefers_ignored_php_extension_over_other_project_env_warnings.snap @@ -0,0 +1,25 @@ +--- +source: crates/cli/tests/status.rs +assertion_line: 603 +expression: snapshot +--- +( + RunOutput { + exit_code: ExitCode( + unix_exit_status( + 0, + ), + ), + stdout: "PV status\nOverall: ok\nDaemon: disabled\n LaunchAgent: missing\n Socket: missing\nIntegrations:\n DNS: missing\n Ports: missing\n CA: missing\nLogs: /home/.pv/logs\nManaged Resources:\n none\nProjects:\n app.test env=warning ignored unsupported PHP extension `missing`; ignored unsupported PHP extension `typo`\nRecent errors:\n none\n", + stderr: "", + }, + RunOutput { + exit_code: ExitCode( + unix_exit_status( + 0, + ), + ), + stdout: "{\"overall\":\"ok\",\"daemon\":{\"state\":\"disabled\",\"launch_agent\":\"missing\",\"socket\":\"missing\",\"failure\":false},\"integrations\":{\"dns\":\"missing\",\"ports\":\"missing\",\"ca\":\"missing\"},\"managed_resources\":[],\"runtimes\":[],\"projects\":[{\"hostname\":\"app.test\",\"env_status\":\"warning\",\"message\":\"ignored unsupported PHP extension `missing`; ignored unsupported PHP extension `typo`\",\"observed_at\":\"\"}],\"recent_errors\":[],\"log_directory\":\"/home/.pv/logs\"}\n", + stderr: "", + }, +) diff --git a/crates/cli/tests/snapshots/status__status_reports_warning_project_env_as_success.snap b/crates/cli/tests/snapshots/status__status_reports_warning_project_env_as_success.snap new file mode 100644 index 00000000..f2d3e039 --- /dev/null +++ b/crates/cli/tests/snapshots/status__status_reports_warning_project_env_as_success.snap @@ -0,0 +1,24 @@ +--- +source: crates/cli/tests/status.rs +expression: snapshot +--- +( + RunOutput { + exit_code: ExitCode( + unix_exit_status( + 0, + ), + ), + stdout: "PV status\nOverall: ok\nDaemon: disabled\n LaunchAgent: missing\n Socket: missing\nIntegrations:\n DNS: missing\n Ports: missing\n CA: missing\nLogs: /home/.pv/logs\nManaged Resources:\n none\nProjects:\n app.test env=warning ignored unsupported PHP extension `missing`\nRecent errors:\n none\n", + stderr: "", + }, + RunOutput { + exit_code: ExitCode( + unix_exit_status( + 0, + ), + ), + stdout: "{\"overall\":\"ok\",\"daemon\":{\"state\":\"disabled\",\"launch_agent\":\"missing\",\"socket\":\"missing\",\"failure\":false},\"integrations\":{\"dns\":\"missing\",\"ports\":\"missing\",\"ca\":\"missing\"},\"managed_resources\":[],\"runtimes\":[],\"projects\":[{\"hostname\":\"app.test\",\"env_status\":\"warning\",\"message\":\"ignored unsupported PHP extension `missing`\",\"observed_at\":\"\"}],\"recent_errors\":[],\"log_directory\":\"/home/.pv/logs\"}\n", + stderr: "", + }, +) diff --git a/crates/cli/tests/status.rs b/crates/cli/tests/status.rs index ea93c9e9..1c1adc5d 100644 --- a/crates/cli/tests/status.rs +++ b/crates/cli/tests/status.rs @@ -12,7 +12,7 @@ use platform::{KeychainCertificate, PfConfReference, PfRedirectConfig, ResolverC use platform::{KeychainTrustResult, LaunchAgentConfig}; use state::{ Database, LinkProjectInput, ManagedResourceTrackInstallInput, ProjectEnvObservedStatus, - PvPaths, RuntimeObservedStatus, RuntimeSubject, + ProjectEnvObservedWarningInput, PvPaths, RuntimeObservedStatus, RuntimeSubject, }; #[derive(Debug)] @@ -280,6 +280,104 @@ fn status_reports_pending_project_env_as_success() -> anyhow::Result<()> { Ok(()) } +#[test] +fn status_reports_warning_project_env_as_success() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let home = tempdir.path().join("home"); + let project_path = tempdir.path().join("project"); + let paths = PvPaths::for_home(home.clone()); + let environment = TestEnvironment::new(&home); + let mut database = Database::open(&paths)?; + let project = database + .link_project(LinkProjectInput { + path: project_path.clone(), + original_path: project_path.clone(), + primary_hostname: "app.test".to_string(), + config_path: project_path.join("pv.toml"), + desired_php_track: Some("8.4".to_string()), + additional_hostnames: Vec::new(), + })? + .project; + database.record_project_env_observed_snapshot( + &project.id, + ProjectEnvObservedStatus::Warning, + Some("Project runtime has warnings"), + &[ProjectEnvObservedWarningInput { + kind: "ignored_php_extension".to_string(), + message: "ignored unsupported PHP extension `missing`".to_string(), + }], + )?; + + let plain = run_pv(&["status"], &environment)?; + let json = run_pv(&["status", "--json"], &environment)?; + + assert_eq!(plain.exit_code, ExitCode::SUCCESS); + assert!(plain.stderr.is_empty()); + assert_eq!(json.exit_code, ExitCode::SUCCESS); + assert!(json.stderr.is_empty()); + assert_status_snapshot( + "status_reports_warning_project_env_as_success", + tempdir.path(), + (plain, json), + ); + + Ok(()) +} + +#[test] +fn status_prefers_ignored_php_extension_over_other_project_env_warnings() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let home = tempdir.path().join("home"); + let project_path = tempdir.path().join("project"); + let paths = PvPaths::for_home(home.clone()); + let environment = TestEnvironment::new(&home); + let mut database = Database::open(&paths)?; + let project = database + .link_project(LinkProjectInput { + path: project_path.clone(), + original_path: project_path.clone(), + primary_hostname: "app.test".to_string(), + config_path: project_path.join("pv.toml"), + desired_php_track: Some("8.4".to_string()), + additional_hostnames: Vec::new(), + })? + .project; + database.record_project_env_observed_snapshot( + &project.id, + ProjectEnvObservedStatus::Warning, + Some("Project runtime has warnings"), + &[ + ProjectEnvObservedWarningInput { + kind: "duplicate_key".to_string(), + message: "APP_URL already exists outside the PV block".to_string(), + }, + ProjectEnvObservedWarningInput { + kind: "ignored_php_extension".to_string(), + message: "ignored unsupported PHP extension `missing`".to_string(), + }, + ProjectEnvObservedWarningInput { + kind: "ignored_php_extension".to_string(), + message: "ignored unsupported PHP extension `typo`".to_string(), + }, + ], + )?; + + let plain = run_pv(&["status"], &environment)?; + let json = run_pv(&["status", "--json"], &environment)?; + + assert_eq!(plain.exit_code, ExitCode::SUCCESS); + assert!(plain.stderr.is_empty()); + assert_eq!(json.exit_code, ExitCode::SUCCESS); + assert!(json.stderr.is_empty()); + assert_status_snapshot( + "status_prefers_ignored_php_extension_over_other_project_env_warnings", + tempdir.path(), + (plain, json), + ); + + Ok(()) +} + #[test] fn status_reports_runtime_and_resource_states() -> anyhow::Result<()> { let tempdir = tempdir()?; diff --git a/crates/config/src/error.rs b/crates/config/src/error.rs index ad32f4b5..94fc5032 100644 --- a/crates/config/src/error.rs +++ b/crates/config/src/error.rs @@ -39,6 +39,9 @@ pub enum ConfigError { #[error("unknown Project config key `{key}`")] UnknownTopLevelKey { key: String }, + #[error("unknown Project config key `php.{key}`")] + UnknownPhpKey { key: String }, + #[error("unknown Project config key `{key}` under resource `{resource}`")] UnknownResourceKey { resource: String, key: String }, diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index db0c414f..7e6a52b3 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -18,5 +18,5 @@ pub use error::ConfigError; pub use hostname::{ hostname_from_project_path, normalize_additional_hostname, normalize_primary_hostname, }; -pub use model::{AllocationConfig, ProjectConfig, ProjectConfigFile, ResourceConfig}; +pub use model::{AllocationConfig, PhpConfig, ProjectConfig, ProjectConfigFile, ResourceConfig}; pub use writer::write_project_php_track; diff --git a/crates/config/src/model.rs b/crates/config/src/model.rs index 4fdfc153..7d402ece 100644 --- a/crates/config/src/model.rs +++ b/crates/config/src/model.rs @@ -1,12 +1,13 @@ use std::collections::BTreeMap; use camino::Utf8PathBuf; +use serde::ser::SerializeMap; use serde::{Serialize, Serializer}; #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] pub struct ProjectConfig { #[serde(skip_serializing_if = "Option::is_none")] - pub php: Option, + pub php: Option, #[serde( skip_serializing_if = "Option::is_none", serialize_with = "serialize_optional_path" @@ -27,6 +28,53 @@ pub struct ProjectConfigFile { pub config: ProjectConfig, } +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PhpConfig { + pub version: Option, + pub extensions: Vec, +} + +impl PhpConfig { + pub fn version(version: impl Into) -> Self { + Self { + version: Some(version.into()), + extensions: Vec::new(), + } + } + + pub fn version_selector(&self) -> Option<&str> { + self.version.as_deref() + } + + pub fn requested_extensions(&self) -> &[String] { + &self.extensions + } +} + +impl Serialize for PhpConfig { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if self.extensions.is_empty() + && let Some(version) = &self.version + { + return version.serialize(serializer); + } + + let field_count = + usize::from(self.version.is_some()) + usize::from(!self.extensions.is_empty()); + let mut map = serializer.serialize_map(Some(field_count))?; + if let Some(version) = &self.version { + map.serialize_entry("version", version)?; + } + if !self.extensions.is_empty() { + map.serialize_entry("extensions", &self.extensions)?; + } + map.end() + } +} + #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] pub struct ResourceConfig { #[serde(rename = "version", skip_serializing_if = "Option::is_none")] diff --git a/crates/config/src/parser.rs b/crates/config/src/parser.rs index 34ab9d36..41447b93 100644 --- a/crates/config/src/parser.rs +++ b/crates/config/src/parser.rs @@ -8,7 +8,7 @@ use resources::{ use yaml_serde::{Mapping, Number, Value}; use crate::hostname::normalize_additional_hostname; -use crate::{AllocationConfig, ConfigError, ProjectConfig, ResourceConfig}; +use crate::{AllocationConfig, ConfigError, PhpConfig, ProjectConfig, ResourceConfig}; const PROJECT_ENV_PLACEHOLDERS: &[&str] = &["project_url"]; @@ -52,7 +52,7 @@ fn parse_project_mapping(mapping: Mapping) -> Result let key = string_key(key)?; match key.as_str() { "php" => { - config.php = Some(php_track(&value)?); + config.php = Some(php_config(&value)?); } "document_root" => { let document_root = non_empty_string("document_root", &value)?; @@ -295,8 +295,40 @@ fn non_empty_string_or_number(field: &str, value: &Value) -> Result Result { + match value { + Value::Mapping(mapping) => php_config_mapping(mapping), + value => php_track(value).map(PhpConfig::version), + } +} + +fn php_config_mapping(mapping: &Mapping) -> Result { + let mut config = PhpConfig::default(); + + for (key, value) in mapping { + let key = string_key_ref(key)?; + match key.as_str() { + "version" => { + config.version = Some(php_track_field("php.version", value)?); + } + "extensions" => { + config.extensions = php_extensions(value)?; + } + _ => { + return Err(ConfigError::UnknownPhpKey { key }); + } + } + } + + Ok(config) +} + fn php_track(value: &Value) -> Result { - let track = non_empty_string_or_number("php", value)?; + php_track_field("php", value) +} + +fn php_track_field(field: &str, value: &Value) -> Result { + let track = non_empty_string_or_number(field, value)?; TrackSelector::parse(track.clone()).map_err(|source| ConfigError::InvalidPhpTrack { track: track.clone(), reason: source.to_string(), @@ -305,6 +337,24 @@ fn php_track(value: &Value) -> Result { Ok(track) } +fn php_extensions(value: &Value) -> Result, ConfigError> { + let sequence = match value { + Value::Sequence(sequence) => sequence, + value => { + return Err(ConfigError::InvalidFieldType { + field: "php.extensions".to_string(), + expected: "a sequence", + found: value_type(value), + }); + } + }; + + sequence + .iter() + .map(|value| non_empty_string("php.extensions", value)) + .collect() +} + fn resource_track(resource: &str, value: &Value) -> Result { let field = format!("{resource}.version"); let track = non_empty_string_or_number(&field, value)?; diff --git a/crates/config/src/writer.rs b/crates/config/src/writer.rs index b2e17bd4..adee0601 100644 --- a/crates/config/src/writer.rs +++ b/crates/config/src/writer.rs @@ -1,7 +1,7 @@ use camino::Utf8Path; use crate::filesystem::{canonicalize_utf8, file_mode, write_string_atomically_with_mode}; -use crate::{ConfigError, ProjectConfig, ProjectConfigFile}; +use crate::{ConfigError, PhpConfig, ProjectConfig, ProjectConfigFile}; const PROJECT_CONFIG_FILE_MODE: u32 = 0o644; @@ -10,7 +10,11 @@ pub fn write_project_php_track( track: &str, ) -> Result { let mut config_file = ProjectConfigFile::read_from_root(project_root)?; - config_file.config.php = Some(track.to_string()); + let php = config_file + .config + .php + .get_or_insert_with(PhpConfig::default); + php.version = Some(track.to_string()); let content = yaml_serde::to_string(&config_file.config) .map_err(|source| ConfigError::Parse { source })?; diff --git a/crates/config/tests/project_config.rs b/crates/config/tests/project_config.rs index 8e4fa268..a314be28 100644 --- a/crates/config/tests/project_config.rs +++ b/crates/config/tests/project_config.rs @@ -1,7 +1,7 @@ use std::io; use std::os::unix::fs::PermissionsExt; -use anyhow::Result; +use anyhow::{Result, anyhow}; use camino::Utf8Path; use camino_tempfile::tempdir; use config::{ConfigError, ProjectConfig, ProjectConfigFile, write_project_php_track}; @@ -81,10 +81,77 @@ fn project_config_rejects_invalid_scalar_shapes() -> Result<()> { Ok(()) } +#[test] +fn project_config_accepts_php_object_with_version_and_extensions() -> Result<()> { + let config = ProjectConfig::parse( + r#" +php: + version: 8.4 + extensions: + - redis + - xdebug +"#, + )?; + + let php = config + .php + .as_ref() + .ok_or_else(|| anyhow!("missing php config"))?; + assert_eq!(php.version_selector(), Some("8.4")); + assert_eq!(php.requested_extensions(), ["redis", "xdebug"]); + + Ok(()) +} + +#[test] +fn project_config_accepts_php_object_with_extensions_only() -> Result<()> { + let config = ProjectConfig::parse( + r#" +php: + extensions: + - xdebug +"#, + )?; + + let php = config + .php + .as_ref() + .ok_or_else(|| anyhow!("missing php config"))?; + assert_eq!(php.version_selector(), None); + assert_eq!(php.requested_extensions(), ["xdebug"]); + + Ok(()) +} + +#[test] +fn project_config_rejects_invalid_php_extensions_shape() -> Result<()> { + assert!(matches!( + ProjectConfig::parse("php:\n extensions: redis\n"), + Err(ConfigError::InvalidFieldType { field, .. }) if field == "php.extensions" + )); + assert!(matches!( + ProjectConfig::parse("php:\n extensions: null\n"), + Err(ConfigError::InvalidFieldType { field, .. }) if field == "php.extensions" + )); + assert!(matches!( + ProjectConfig::parse("php:\n extensions:\n"), + Err(ConfigError::InvalidFieldType { field, .. }) if field == "php.extensions" + )); + assert!(matches!( + ProjectConfig::parse("php:\n extensions:\n - true\n"), + Err(ConfigError::InvalidFieldType { field, .. }) if field == "php.extensions" + )); + + Ok(()) +} + #[test] fn project_config_validates_php_and_resource_tracks() -> Result<()> { assert_eq!( - ProjectConfig::parse("php: latest\n")?.php.as_deref(), + ProjectConfig::parse("php: latest\n")? + .php + .as_ref() + .and_then(|php| php.version_selector()), Some("latest") ); assert_eq!( @@ -491,6 +558,44 @@ postgres: Ok(()) } +#[test] +fn project_config_writer_preserves_php_extensions_when_updating_track() -> Result<()> { + let tempdir = tempdir()?; + let project = tempdir.path().join("acme"); + create_dir(&project)?; + write_file( + &project.join("pv.yml"), + r#" +php: + version: 8.2 + extensions: + - redis + - xdebug +"#, + )?; + + let updated = write_project_php_track(&project, "8.4")?; + let reloaded = ProjectConfigFile::read_from_root(&project)?; + let updated_php = updated + .config + .php + .as_ref() + .ok_or_else(|| anyhow!("missing updated php config"))?; + let reloaded_php = reloaded + .config + .php + .as_ref() + .ok_or_else(|| anyhow!("missing reloaded php config"))?; + + assert_eq!(updated_php.version_selector(), Some("8.4")); + assert_eq!(updated_php.requested_extensions(), ["redis", "xdebug"]); + assert_eq!(reloaded_php.version_selector(), Some("8.4")); + assert_eq!(reloaded_php.requested_extensions(), ["redis", "xdebug"]); + assert_snapshot!(read_file(&project.join("pv.yml"))?); + + Ok(()) +} + #[test] fn project_config_writer_updates_php_in_alternate_file() -> Result<()> { let tempdir = tempdir()?; diff --git a/crates/config/tests/snapshots/project_config__project_config_parses_strict_resource_env_shape.snap b/crates/config/tests/snapshots/project_config__project_config_parses_strict_resource_env_shape.snap index e3741fef..6a214c12 100644 --- a/crates/config/tests/snapshots/project_config__project_config_parses_strict_resource_env_shape.snap +++ b/crates/config/tests/snapshots/project_config__project_config_parses_strict_resource_env_shape.snap @@ -1,11 +1,15 @@ --- source: crates/config/tests/project_config.rs -assertion_line: 30 expression: config --- ProjectConfig { php: Some( - "8.4", + PhpConfig { + version: Some( + "8.4", + ), + extensions: [], + }, ), document_root: Some( "public", diff --git a/crates/config/tests/snapshots/project_config__project_config_writer_creates_preferred_file_when_missing.snap b/crates/config/tests/snapshots/project_config__project_config_writer_creates_preferred_file_when_missing.snap index 28413bc8..1ab675c3 100644 --- a/crates/config/tests/snapshots/project_config__project_config_writer_creates_preferred_file_when_missing.snap +++ b/crates/config/tests/snapshots/project_config__project_config_writer_creates_preferred_file_when_missing.snap @@ -9,7 +9,12 @@ expression: "(updated.path.file_name(), updated.exists, updated.config)" true, ProjectConfig { php: Some( - "8.3", + PhpConfig { + version: Some( + "8.3", + ), + extensions: [], + }, ), document_root: None, hostnames: [], diff --git a/crates/config/tests/snapshots/project_config__project_config_writer_preserves_existing_config_file_mode.snap b/crates/config/tests/snapshots/project_config__project_config_writer_preserves_existing_config_file_mode.snap index ebbc72e1..738bb3b9 100644 --- a/crates/config/tests/snapshots/project_config__project_config_writer_preserves_existing_config_file_mode.snap +++ b/crates/config/tests/snapshots/project_config__project_config_writer_preserves_existing_config_file_mode.snap @@ -9,7 +9,12 @@ expression: "(updated.path.file_name(), updated.exists, updated.config)" true, ProjectConfig { php: Some( - "8.4", + PhpConfig { + version: Some( + "8.4", + ), + extensions: [], + }, ), document_root: None, hostnames: [], diff --git a/crates/config/tests/snapshots/project_config__project_config_writer_preserves_php_extensions_when_updating_track.snap b/crates/config/tests/snapshots/project_config__project_config_writer_preserves_php_extensions_when_updating_track.snap new file mode 100644 index 00000000..285b96c2 --- /dev/null +++ b/crates/config/tests/snapshots/project_config__project_config_writer_preserves_php_extensions_when_updating_track.snap @@ -0,0 +1,9 @@ +--- +source: crates/config/tests/project_config.rs +expression: "read_file(&project.join(\"pv.yml\"))?" +--- +php: + version: '8.4' + extensions: + - redis + - xdebug diff --git a/crates/config/tests/snapshots/project_config__project_config_writer_updates_php_in_alternate_file.snap b/crates/config/tests/snapshots/project_config__project_config_writer_updates_php_in_alternate_file.snap index eed7b914..8808a8eb 100644 --- a/crates/config/tests/snapshots/project_config__project_config_writer_updates_php_in_alternate_file.snap +++ b/crates/config/tests/snapshots/project_config__project_config_writer_updates_php_in_alternate_file.snap @@ -9,7 +9,12 @@ expression: "(updated.path.file_name(), updated.exists, updated.config)" true, ProjectConfig { php: Some( - "latest", + PhpConfig { + version: Some( + "latest", + ), + extensions: [], + }, ), document_root: None, hostnames: [], diff --git a/crates/config/tests/snapshots/project_config__project_config_writer_updates_php_in_discovered_file.snap b/crates/config/tests/snapshots/project_config__project_config_writer_updates_php_in_discovered_file.snap index ecd358bd..4cb7663b 100644 --- a/crates/config/tests/snapshots/project_config__project_config_writer_updates_php_in_discovered_file.snap +++ b/crates/config/tests/snapshots/project_config__project_config_writer_updates_php_in_discovered_file.snap @@ -9,7 +9,12 @@ expression: "(updated.path.file_name(), updated.exists, updated.config, reloaded true, ProjectConfig { php: Some( - "8.4", + PhpConfig { + version: Some( + "8.4", + ), + extensions: [], + }, ), document_root: Some( "public", @@ -47,7 +52,12 @@ expression: "(updated.path.file_name(), updated.exists, updated.config, reloaded }, ProjectConfig { php: Some( - "8.4", + PhpConfig { + version: Some( + "8.4", + ), + extensions: [], + }, ), document_root: Some( "public", diff --git a/crates/config/tests/snapshots/project_config__project_config_writer_updates_symlinked_config_target.snap b/crates/config/tests/snapshots/project_config__project_config_writer_updates_symlinked_config_target.snap index 8ecd0e66..4647f9ca 100644 --- a/crates/config/tests/snapshots/project_config__project_config_writer_updates_symlinked_config_target.snap +++ b/crates/config/tests/snapshots/project_config__project_config_writer_updates_symlinked_config_target.snap @@ -9,7 +9,12 @@ expression: "(updated.path.file_name(), updated.exists, updated.config)" true, ProjectConfig { php: Some( - "8.4", + PhpConfig { + version: Some( + "8.4", + ), + extensions: [], + }, ), document_root: None, hostnames: [], diff --git a/crates/daemon/src/gateway.rs b/crates/daemon/src/gateway.rs index 61cf7bd9..3e7f9e83 100644 --- a/crates/daemon/src/gateway.rs +++ b/crates/daemon/src/gateway.rs @@ -24,7 +24,9 @@ use crate::gateway_config::{ render_gateway_config, render_gateway_project_config, render_php_worker_config, render_php_worker_project_config, }; -use crate::project_env::{resolve_project_php_track, validate_project_config_for_gateway}; +use crate::project_env::{ + ResolvedPhpRuntime, resolve_project_php_runtime, validate_project_config_for_gateway, +}; use crate::structured_log; use crate::supervisor::{ManagedProcess, probe_readiness_once}; use crate::{DaemonError, ProcessSpec, ProcessSupervisor, ReadinessCheck, wait_for_readiness}; @@ -89,6 +91,8 @@ pub struct GatewayRuntimePlan { #[derive(Clone, Debug, Eq, PartialEq)] pub struct PhpWorkerRuntimePlan { pub php_track: String, + pub runtime_key: String, + pub loaded_modules: Vec, pub port: u16, pub projects: Vec, } @@ -103,6 +107,12 @@ pub struct RuntimeProject { pub document_root: Utf8PathBuf, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct InstalledFrankenphpRuntime { + command: FrankenphpCommand, + artifact_root: Utf8PathBuf, +} + pub fn promote_validated_config_for_test( path: &Utf8Path, content: &str, @@ -143,12 +153,10 @@ pub async fn reconcile_gateway_runtimes_with_readiness_timeout( let mut worker_commands = Vec::new(); for worker in &plan.workers { - let subject = RuntimeSubject::PhpWorker { - php_track: worker.php_track.clone(), - }; - let worker_command = match installed_frankenphp_command_for_track(paths, &worker.php_track) + let subject = worker_runtime_subject(worker); + let worker_runtime = match installed_frankenphp_runtime_for_track(paths, &worker.php_track) { - Ok(Some(command)) => command, + Ok(Some(runtime)) => runtime, Ok(None) => { let error = DaemonError::UnexpectedProtocolResponse { reason: format!( @@ -166,14 +174,17 @@ pub async fn reconcile_gateway_runtimes_with_readiness_timeout( return Err(error); } }; - worker_commands.push((worker, worker_command)); + worker_commands.push((worker, worker_runtime)); } - for (worker, worker_command) in worker_commands { - let subject = RuntimeSubject::PhpWorker { - php_track: worker.php_track.clone(), - }; - let process_spec = match worker_process_spec(paths, &worker.php_track, &worker_command) { + for (worker, worker_runtime) in worker_commands { + let subject = worker_runtime_subject(worker); + let process_spec = match worker_process_spec( + paths, + worker, + &worker_runtime.command, + &worker_runtime.artifact_root, + ) { Ok(process_spec) => process_spec, Err(error) => { record_runtime_error(paths, subject.clone(), &error)?; @@ -181,7 +192,13 @@ pub async fn reconcile_gateway_runtimes_with_readiness_timeout( return Err(error); } }; - let promoted_config = reconcile_worker_config(paths, &worker_command, worker).await?; + let promoted_config = reconcile_worker_config( + paths, + &worker_runtime.command, + &worker_runtime.artifact_root, + worker, + ) + .await?; start_or_adopt_promoted_runtime( paths, &supervisor, @@ -414,27 +431,28 @@ pub fn gateway_process_spec(paths: &PvPaths, command: &FrankenphpCommand) -> Pro pub fn worker_process_spec( paths: &PvPaths, - php_track: &str, + worker: &PhpWorkerRuntimePlan, command: &FrankenphpCommand, + artifact_root: &Utf8Path, ) -> Result { Ok(ProcessSpec { - name: format!("php-worker-{php_track}"), + name: format!("php-worker-{}", worker.runtime_key), command: command.executable.clone(), - arguments: command.run_arguments(&paths.worker_root_config(php_track)), - private_environment: frankenphp_worker_environment(paths, php_track)?, - config_path: paths.worker_root_config(php_track), - log_path: paths.worker_log(php_track), - pid_path: paths.worker_pid(php_track), - metadata_path: paths.worker_runtime_metadata(php_track), + arguments: command.run_arguments(&paths.worker_root_config(&worker.runtime_key)), + private_environment: frankenphp_worker_environment(paths, worker, artifact_root)?, + config_path: paths.worker_root_config(&worker.runtime_key), + log_path: paths.worker_log(&worker.runtime_key), + pid_path: paths.worker_pid(&worker.runtime_key), + metadata_path: paths.worker_runtime_metadata(&worker.runtime_key), resource_name: "php-worker".to_owned(), - track: php_track.to_owned(), + track: worker.runtime_key.clone(), }) } pub fn build_runtime_plan(paths: &PvPaths) -> Result { let mut database = Database::open(paths)?; let gateway_ports = database.assign_gateway_ports(local_loopback_port_available)?; - let mut projects_by_php_track: BTreeMap = BTreeMap::new(); + let mut projects_by_runtime_key: BTreeMap = BTreeMap::new(); for project in database.projects()? { let config_file = match ProjectConfigFile::read_from_root(&project.path) { @@ -447,9 +465,8 @@ pub fn build_runtime_plan(paths: &PvPaths) -> Result { &[], )?; append_persisted_runtime_project( - paths, &mut database, - &mut projects_by_php_track, + &mut projects_by_runtime_key, project, )?; continue; @@ -468,9 +485,8 @@ pub fn build_runtime_plan(paths: &PvPaths) -> Result { &[], )?; append_persisted_runtime_project( - paths, &mut database, - &mut projects_by_php_track, + &mut projects_by_runtime_key, project, )?; continue; @@ -479,13 +495,12 @@ pub fn build_runtime_plan(paths: &PvPaths) -> Result { } None => None, }; - let config_php = config.as_ref().and_then(|config| config.php.as_deref()); - let stored_php_track = if config_php.is_some() { - project.desired_php_track.as_deref() - } else { - None - }; - let php_track = resolve_project_php_track(paths, config_php, stored_php_track)?; + let runtime = resolve_project_php_runtime( + paths, + &database, + &project, + config.as_ref().and_then(|config| config.php.as_ref()), + )?; let document_root = resolve_project_document_root(&project.path, config.as_ref())?; let runtime_project = RuntimeProject { id: project.id, @@ -505,13 +520,13 @@ pub fn build_runtime_plan(paths: &PvPaths) -> Result { append_runtime_project( &mut database, - &mut projects_by_php_track, - php_track, + &mut projects_by_runtime_key, + runtime, runtime_project, )?; } - let workers = projects_by_php_track + let workers = projects_by_runtime_key .into_values() .map(|mut worker| { worker @@ -565,12 +580,30 @@ fn gateway_storage_path(paths: &PvPaths) -> Result { } fn append_persisted_runtime_project( - paths: &PvPaths, database: &mut Database, - projects_by_php_track: &mut BTreeMap, + projects_by_runtime_key: &mut BTreeMap, project: state::ProjectRecord, ) -> Result<(), DaemonError> { - let php_track = resolve_project_php_track(paths, None, project.desired_php_track.as_deref())?; + let runtime = match persisted_project_php_runtime(database, &project) { + Ok(Some(runtime)) => runtime, + Ok(None) => return Ok(()), + Err( + error @ DaemonError::Resources(resources::ResourcesError::InvalidArtifactLayout { + .. + }), + ) => { + let message = error.to_string(); + database.record_project_env_observed_snapshot( + &project.id, + ProjectEnvObservedStatus::Failed, + Some(&message), + &[], + )?; + + return Ok(()); + } + Err(error) => return Err(error), + }; let runtime_project = RuntimeProject { id: project.id, render_config: false, @@ -584,23 +617,23 @@ fn append_persisted_runtime_project( document_root: project.path, }; - append_runtime_project(database, projects_by_php_track, php_track, runtime_project) + append_runtime_project(database, projects_by_runtime_key, runtime, runtime_project) } fn append_runtime_project( database: &mut Database, - projects_by_php_track: &mut BTreeMap, - php_track: String, + projects_by_runtime_key: &mut BTreeMap, + runtime: ResolvedPhpRuntime, runtime_project: RuntimeProject, ) -> Result<(), DaemonError> { - match projects_by_php_track.entry(php_track.clone()) { + match projects_by_runtime_key.entry(runtime.runtime_key.clone()) { btree_map::Entry::Occupied(mut entry) => { entry.get_mut().projects.push(runtime_project); } btree_map::Entry::Vacant(entry) => { let assignment = database.assign_port( PortRequest::php_worker( - &php_track, + &runtime.runtime_key, RUNTIME_PORT_FALLBACK_START, RUNTIME_PORT_FALLBACK_START, RUNTIME_PORT_FALLBACK_END, @@ -609,7 +642,9 @@ fn append_runtime_project( )?; entry.insert(PhpWorkerRuntimePlan { - php_track, + php_track: runtime.track, + runtime_key: runtime.runtime_key, + loaded_modules: runtime.loaded_modules, port: assignment.port, projects: vec![runtime_project], }); @@ -619,6 +654,76 @@ fn append_runtime_project( Ok(()) } +fn persisted_project_php_runtime( + database: &Database, + project: &state::ProjectRecord, +) -> Result, DaemonError> { + let Some(track) = &project.php_runtime.track else { + return Ok(None); + }; + let loaded_modules = + loaded_php_extension_modules(database, track, &project.php_runtime.loaded_extensions)?; + let runtime_key = state::php_runtime_key(track, &project.php_runtime.loaded_extensions)?; + + Ok(Some(ResolvedPhpRuntime { + track: track.clone(), + runtime_key, + requested_extensions: project.php_runtime.requested_extensions.clone(), + loaded_extensions: project.php_runtime.loaded_extensions.clone(), + ignored_extensions: project.php_runtime.ignored_extensions.clone(), + loaded_modules, + })) +} + +fn loaded_php_extension_modules( + database: &Database, + track: &str, + loaded_extensions: &[String], +) -> Result, DaemonError> { + let Some(release) = installed_php_release(database, track)? else { + if !loaded_extensions.is_empty() { + return Err(DaemonError::Resources( + resources::ResourcesError::InvalidArtifactLayout { + resource: "php".to_string(), + reason: format!( + "persisted PHP extension `{}` cannot be reconstructed because PHP track `{track}` is not installed", + loaded_extensions.join(", ") + ), + }, + )); + } + + return Ok(Vec::new()); + }; + + Ok(resources::resolve_persisted_php_extension_modules( + &release, + loaded_extensions, + )?) +} + +fn installed_php_release( + database: &Database, + track: &str, +) -> Result, DaemonError> { + let release = database + .managed_resource_tracks()? + .into_iter() + .find_map(|record| { + if record.resource_name == "php" + && record.track == track + && record.desired_state == ManagedResourceDesiredState::Installed + && record.installed_version.is_some() + { + return record.current_artifact_path; + } + + None + }); + + Ok(release) +} + async fn reconcile_gateway_config( paths: &PvPaths, command: &FrankenphpCommand, @@ -697,12 +802,12 @@ struct GatewayConfigReconciliation { async fn reconcile_worker_config( paths: &PvPaths, command: &FrankenphpCommand, + artifact_root: &Utf8Path, worker: &PhpWorkerRuntimePlan, ) -> Result { - let subject = RuntimeSubject::PhpWorker { - php_track: worker.php_track.clone(), - }; - let private_environment = match worker_config_private_environment(paths, &worker.php_track) { + let subject = worker_runtime_subject(worker); + let private_environment = match worker_config_private_environment(paths, worker, artifact_root) + { Ok(private_environment) => private_environment, Err(error) => { record_runtime_error(paths, subject.clone(), &error)?; @@ -710,7 +815,7 @@ async fn reconcile_worker_config( return Err(error); } }; - let active_dir = paths.worker_projects_config_dir(&worker.php_track); + let active_dir = paths.worker_projects_config_dir(&worker.runtime_key); let candidate_dir = candidate_config_dir_for(&active_dir); let fragments = worker_project_config_fragments(paths, worker)?; let fragment_project_ids = fragments @@ -758,7 +863,7 @@ async fn reconcile_worker_config( Ok(()) => { let promotion = RuntimeConfigTreePromotion { subject, - config_path: paths.worker_root_config(&worker.php_track), + config_path: paths.worker_root_config(&worker.runtime_key), candidate_content: &candidate_content, active_content: &active_content, private_environment, @@ -1034,7 +1139,7 @@ fn worker_project_config_fragments( paths: &PvPaths, worker: &PhpWorkerRuntimePlan, ) -> Result, DaemonError> { - let active_dir = paths.worker_projects_config_dir(&worker.php_track); + let active_dir = paths.worker_projects_config_dir(&worker.runtime_key); let mut fragments = Vec::new(); for project in &worker.projects { @@ -1090,23 +1195,21 @@ async fn stop_stale_worker_runtimes( supervisor: &ProcessSupervisor, plan: &RuntimePlan, ) -> Result<(), DaemonError> { - let desired_tracks = plan + let desired_runtime_keys = plan .workers .iter() - .map(|worker| worker.php_track.as_str()) + .map(|worker| worker.runtime_key.as_str()) .collect::>(); - for php_track in runtime_worker_tracks(paths)? { - if desired_tracks.contains(php_track.as_str()) { + for runtime_key in runtime_worker_tracks(paths)? { + if desired_runtime_keys.contains(runtime_key.as_str()) { continue; } - let subject = RuntimeSubject::PhpWorker { - php_track: php_track.clone(), - }; + let subject = php_runtime_subject(&runtime_key); if let Some(adopted) = supervisor.adopt_recorded( - &paths.worker_pid(&php_track), - &paths.worker_runtime_metadata(&php_track), + &paths.worker_pid(&runtime_key), + &paths.worker_runtime_metadata(&runtime_key), )? { adopted.stop(Duration::from_secs(1)).await?; } @@ -1116,26 +1219,50 @@ async fn stop_stale_worker_runtimes( RuntimeObservedStatus::Stopped, Some("PHP worker stopped; no Projects remain on this track"), )?; - cleanup_stale_worker_runtime(paths, &php_track)?; + cleanup_stale_worker_runtime(paths, &runtime_key)?; } Ok(()) } -fn cleanup_stale_worker_runtime(paths: &PvPaths, php_track: &str) -> Result<(), DaemonError> { - delete_optional_file(&paths.worker_pid(php_track))?; - delete_optional_file(&paths.worker_runtime_metadata(php_track))?; - delete_optional_file(&paths.worker_root_config(php_track))?; - delete_optional_dir(&paths.worker_projects_config_dir(php_track))?; +fn cleanup_stale_worker_runtime(paths: &PvPaths, runtime_key: &str) -> Result<(), DaemonError> { + delete_optional_file(&paths.worker_pid(runtime_key))?; + delete_optional_file(&paths.worker_runtime_metadata(runtime_key))?; + delete_optional_file(&paths.worker_root_config(runtime_key))?; + delete_optional_dir(&paths.worker_projects_config_dir(runtime_key))?; let mut database = Database::open(paths)?; database.release_port(PortOwner::PhpWorker { - php_track: php_track.to_owned(), + php_runtime_key: runtime_key.to_owned(), })?; Ok(()) } +fn worker_runtime_subject(worker: &PhpWorkerRuntimePlan) -> RuntimeSubject { + if worker.runtime_key == worker.php_track { + RuntimeSubject::PhpWorker { + php_track: worker.php_track.clone(), + } + } else { + RuntimeSubject::PhpRuntimeWorker { + php_runtime_key: worker.runtime_key.clone(), + } + } +} + +fn php_runtime_subject(runtime_key: &str) -> RuntimeSubject { + if runtime_key.contains('+') { + RuntimeSubject::PhpRuntimeWorker { + php_runtime_key: runtime_key.to_owned(), + } + } else { + RuntimeSubject::PhpWorker { + php_track: runtime_key.to_owned(), + } + } +} + fn project_config_file_name(project_id: &str) -> String { format!("{project_id}.Caddyfile") } @@ -1253,18 +1380,18 @@ fn first_installed_frankenphp_command( Ok(Some(frankenphp_command_from_record(record)?)) } -fn installed_frankenphp_command_for_track( +fn installed_frankenphp_runtime_for_track( paths: &PvPaths, php_track: &str, -) -> Result, DaemonError> { +) -> Result, DaemonError> { let database = Database::open(paths)?; - let command = installed_frankenphp_tracks(&database)? + let runtime = installed_frankenphp_tracks(&database)? .into_iter() .find(|record| record.track == php_track) - .map(frankenphp_command_from_record) + .map(frankenphp_runtime_from_record) .transpose()?; - Ok(command) + Ok(runtime) } fn installed_frankenphp_tracks( @@ -1285,6 +1412,12 @@ fn installed_frankenphp_tracks( fn frankenphp_command_from_record( record: ManagedResourceTrackRecord, ) -> Result { + Ok(frankenphp_runtime_from_record(record)?.command) +} + +fn frankenphp_runtime_from_record( + record: ManagedResourceTrackRecord, +) -> Result { let Some(artifact_path) = record.current_artifact_path else { return Err(DaemonError::UnexpectedProtocolResponse { reason: format!( @@ -1297,9 +1430,10 @@ fn frankenphp_command_from_record( adapter.validate_installation(&artifact_path)?; - Ok(FrankenphpCommand::new( - adapter.executable_path(&artifact_path), - )) + Ok(InstalledFrankenphpRuntime { + command: FrankenphpCommand::new(adapter.executable_path(&artifact_path)), + artifact_root: artifact_path, + }) } fn record_runtime_error( @@ -1372,21 +1506,29 @@ fn frankenphp_xdg_environment(paths: &PvPaths) -> BTreeMap { fn frankenphp_worker_environment( paths: &PvPaths, - php_track: &str, -) -> Result, StateError> { + worker: &PhpWorkerRuntimePlan, + artifact_root: &Utf8Path, +) -> Result, DaemonError> { let mut environment = frankenphp_xdg_environment(paths); - environment.extend(resources::php_track_environment(paths, php_track)?); + environment.extend(resources::php_runtime_environment( + paths, + &worker.php_track, + &worker.runtime_key, + artifact_root, + &worker.loaded_modules, + )?); Ok(environment) } fn worker_config_private_environment( paths: &PvPaths, - php_track: &str, + worker: &PhpWorkerRuntimePlan, + artifact_root: &Utf8Path, ) -> Result, DaemonError> { - resources::ensure_php_track_defaults(paths, php_track)?; + resources::ensure_php_track_defaults(paths, &worker.php_track)?; - Ok(frankenphp_worker_environment(paths, php_track)?) + frankenphp_worker_environment(paths, worker, artifact_root) } #[cfg(test)] diff --git a/crates/daemon/src/jobs.rs b/crates/daemon/src/jobs.rs index 2e27e9ca..52e86648 100644 --- a/crates/daemon/src/jobs.rs +++ b/crates/daemon/src/jobs.rs @@ -11,7 +11,7 @@ use crate::project_env::{reconcile_project_env, reconcile_project_env_with_runti use crate::reconciliation::{EnqueueResult, ReconciliationQueue, ReconciliationScope}; use crate::structured_log; use protocol::{DaemonEvent, DaemonResponse, DaemonTransport, write_line}; -use state::{Database, JobStatus, ProjectRecord, PvPaths, StateError}; +use state::{Database, JobStatus, ManagedResourceDesiredState, ProjectRecord, PvPaths, StateError}; use tokio::io::AsyncWrite; use tokio::time::{Duration, Instant, MissedTickBehavior, interval_at, timeout}; @@ -501,8 +501,7 @@ async fn complete_update_job_inner( return Ok(unchanged_update_summary(&report)); } - let project_report = reconcile_system_projects(paths, runtime_catalog).await?; - reconcile_system_resources_with_runtime_catalog(paths, runtime_catalog).await?; + let project_report = reconcile_system_projects_and_resources(paths, runtime_catalog).await?; let gateway_summary = reconcile_gateway_runtimes(paths).await?; let reconciliation_summary = system_reconciliation_summary(&project_report, &gateway_summary); @@ -594,8 +593,7 @@ async fn complete_system_reconciliation( job_id: &str, runtime_catalog: Option<&ManagedResourceRuntimeCatalog>, ) -> Result { - let project_report = reconcile_system_projects(paths, runtime_catalog).await?; - reconcile_system_resources_with_runtime_catalog(paths, runtime_catalog).await?; + let project_report = reconcile_system_projects_and_resources(paths, runtime_catalog).await?; let gateway_summary = reconcile_gateway_runtimes(paths).await?; let summary = system_reconciliation_summary(&project_report, &gateway_summary); let mut database = Database::open(paths)?; @@ -612,7 +610,7 @@ async fn complete_project_reconciliation( runtime_catalog: Option<&ManagedResourceRuntimeCatalog>, ) -> Result { let project_env_summary = - reconcile_project_env_for_runtime_catalog(paths, id.as_str(), runtime_catalog).await?; + reconcile_project_env_and_missing_resources(paths, id.as_str(), runtime_catalog).await?; let gateway_summary = reconcile_gateway_runtimes(paths).await?; let summary = if gateway_summary == FRANKENPHP_NOT_INSTALLED { project_env_summary.as_str().to_string() @@ -626,6 +624,33 @@ async fn complete_project_reconciliation( Ok(summary) } +async fn reconcile_project_env_and_missing_resources( + paths: &PvPaths, + project_id: &str, + runtime_catalog: Option<&ManagedResourceRuntimeCatalog>, +) -> Result { + let summary = + reconcile_project_env_for_runtime_catalog(paths, project_id, runtime_catalog).await?; + if !summary.requested_php_extensions() || !missing_gateway_runtime_resource(paths)? { + return Ok(summary); + } + + reconcile_system_resources_with_runtime_catalog(paths, runtime_catalog).await?; + reconcile_project_env_for_runtime_catalog(paths, project_id, runtime_catalog).await +} + +fn missing_gateway_runtime_resource(paths: &PvPaths) -> Result { + let database = Database::open(paths)?; + Ok(database + .managed_resource_tracks()? + .into_iter() + .any(|record| { + gateway_runtime_resource(&record.resource_name) + && record.desired_state == ManagedResourceDesiredState::Installed + && record.current_artifact_path.is_none() + })) +} + #[derive(Clone, Debug, Default, Eq, PartialEq)] struct SystemProjectReconciliationReport { total: usize, @@ -661,6 +686,15 @@ async fn reconcile_system_projects( Ok(report) } +async fn reconcile_system_projects_and_resources( + paths: &PvPaths, + runtime_catalog: Option<&ManagedResourceRuntimeCatalog>, +) -> Result { + reconcile_system_projects(paths, runtime_catalog).await?; + reconcile_system_resources_with_runtime_catalog(paths, runtime_catalog).await?; + reconcile_system_projects(paths, runtime_catalog).await +} + async fn reconcile_project_env_for_runtime_catalog( paths: &PvPaths, project_id: &str, @@ -958,15 +992,18 @@ async fn run_started_job( #[cfg(test)] mod tests { + use std::fs; use std::io; + use std::os::unix::fs::PermissionsExt; use std::pin::Pin; + use std::process; use std::task::{Context, Poll}; use camino::Utf8Path; use camino_tempfile::tempdir; use futures_util::StreamExt; use serde_json::json; - use state::{Database, JobStatus, PvPaths, StateError, UpdateLock}; + use state::{Database, JobStatus, LinkProjectInput, PvPaths, StateError, UpdateLock}; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf, duplex}; use tokio::sync::oneshot; use tokio::time::{Duration, timeout}; @@ -974,12 +1011,108 @@ mod tests { use super::{ complete_or_fail_background_reconciliation, complete_streamed_job_with_heartbeat, enqueue_reconciliation_job, foreground_reconciliation_result, + reconcile_project_env_and_missing_resources, reconcile_system_projects_and_resources, record_background_reconciliation_error, run_background_reconciliation_job, start_reconciliation_job, stream_started_reconciliation_job, write_coalesced_update_response, }; use crate::reconciliation::{EnqueueResult, ReconciliationQueue, ReconciliationScope}; + const OFFLINE_TEST_MANIFEST_URL: &str = "https://127.0.0.1:9/manifest.json"; + const PHP_TEST_TRACK: &str = "8.5"; + const PHP_TEST_ARTIFACT_VERSION: &str = "8.5.0-pv1"; + const PHP_TEST_ARCHIVE_FILE_NAME: &str = "php-8.5.0-pv1-any.tar.gz"; + const FRANKENPHP_TEST_ARCHIVE_FILE_NAME: &str = "frankenphp-8.5.0-pv1-any.tar.gz"; + + #[tokio::test] + async fn system_reconciliation_refreshes_php_extensions_after_missing_php_install() + -> anyhow::Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let project_path = tempdir.path().join("project"); + let config_path = project_path.join("pv.yml"); + + state::fs::write_sensitive_file( + &config_path, + "php:\n version: \"8.5\"\n extensions: [redis]\n", + )?; + seed_cached_php_pair(&paths, tempdir.path())?; + let mut database = Database::open(&paths)?; + database.link_project(LinkProjectInput { + path: project_path.clone(), + original_path: project_path, + primary_hostname: "project.test".to_owned(), + config_path, + desired_php_track: None, + additional_hostnames: Vec::new(), + })?; + drop(database); + let catalog = + crate::managed_resources::ManagedResourceRuntimeCatalog::without_adapters_with_manifest_url( + OFFLINE_TEST_MANIFEST_URL, + ); + + reconcile_system_projects_and_resources(&paths, Some(&catalog)).await?; + + let database = Database::open(&paths)?; + let project = database + .projects()? + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("expected linked project"))?; + + assert_eq!(project.php_runtime.track.as_deref(), Some(PHP_TEST_TRACK)); + assert_eq!(project.php_runtime.requested_extensions, ["redis"]); + assert_eq!(project.php_runtime.loaded_extensions, ["redis"]); + assert!(project.php_runtime.ignored_extensions.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn project_reconciliation_refreshes_php_extensions_after_missing_php_install() + -> anyhow::Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let project_path = tempdir.path().join("project"); + let config_path = project_path.join("pv.yml"); + + state::fs::write_sensitive_file( + &config_path, + "php:\n version: \"8.5\"\n extensions: [redis]\n", + )?; + seed_cached_php_pair(&paths, tempdir.path())?; + let mut database = Database::open(&paths)?; + let linked = database.link_project(LinkProjectInput { + path: project_path.clone(), + original_path: project_path, + primary_hostname: "project.test".to_owned(), + config_path, + desired_php_track: None, + additional_hostnames: Vec::new(), + })?; + drop(database); + let catalog = + crate::managed_resources::ManagedResourceRuntimeCatalog::without_adapters_with_manifest_url( + OFFLINE_TEST_MANIFEST_URL, + ); + + reconcile_project_env_and_missing_resources(&paths, &linked.project.id, Some(&catalog)) + .await?; + + let database = Database::open(&paths)?; + let project = database + .project_by_id(&linked.project.id)? + .ok_or_else(|| anyhow::anyhow!("expected linked project"))?; + + assert_eq!(project.php_runtime.track.as_deref(), Some(PHP_TEST_TRACK)); + assert_eq!(project.php_runtime.requested_extensions, ["redis"]); + assert_eq!(project.php_runtime.loaded_extensions, ["redis"]); + assert!(project.php_runtime.ignored_extensions.is_empty()); + + Ok(()) + } + #[tokio::test] async fn stream_write_error_is_returned_after_job_completion_is_persisted() -> anyhow::Result<()> { @@ -1431,6 +1564,242 @@ mod tests { } } + fn seed_cached_php_pair(paths: &PvPaths, tempdir: &Utf8Path) -> anyhow::Result<()> { + let php = CachedArtifact::new( + "php", + PHP_TEST_ARCHIVE_FILE_NAME, + PHP_TEST_ARTIFACT_VERSION, + seed_php_archive, + ); + let frankenphp = CachedArtifact::new( + "frankenphp", + FRANKENPHP_TEST_ARCHIVE_FILE_NAME, + PHP_TEST_ARTIFACT_VERSION, + seed_frankenphp_archive, + ); + let php = cache_artifact(paths, tempdir, php)?; + let frankenphp = cache_artifact(paths, tempdir, frankenphp)?; + let manifest = php_pair_manifest(&[php, frankenphp]); + + state::fs::write_sensitive_file(&paths.downloads().join("manifest.json"), &manifest)?; + + Ok(()) + } + + #[derive(Clone, Copy)] + struct CachedArtifact { + resource_name: &'static str, + archive_file_name: &'static str, + artifact_version: &'static str, + seed_archive: fn(&Utf8Path, &Utf8Path) -> anyhow::Result<()>, + } + + impl CachedArtifact { + fn new( + resource_name: &'static str, + archive_file_name: &'static str, + artifact_version: &'static str, + seed_archive: fn(&Utf8Path, &Utf8Path) -> anyhow::Result<()>, + ) -> Self { + Self { + resource_name, + archive_file_name, + artifact_version, + seed_archive, + } + } + } + + struct CachedManifestArtifact { + artifact: CachedArtifact, + sha256: String, + size: u64, + } + + fn cache_artifact( + paths: &PvPaths, + tempdir: &Utf8Path, + artifact: CachedArtifact, + ) -> anyhow::Result { + let archive_path = tempdir.join(artifact.archive_file_name); + + (artifact.seed_archive)(tempdir, &archive_path)?; + let sha256 = sha256_file(&archive_path)?; + let cache_path = paths + .downloads() + .join(format!("{sha256}-{}", artifact.archive_file_name)); + + copy_file(&archive_path, &cache_path)?; + + Ok(CachedManifestArtifact { + artifact, + sha256, + size: file_size(&cache_path)?, + }) + } + + fn seed_php_archive(tempdir: &Utf8Path, archive_path: &Utf8Path) -> anyhow::Result<()> { + let archive_parent = tempdir.join("php-archive"); + let root_name = format!("php-{PHP_TEST_ARTIFACT_VERSION}"); + let root = archive_parent.join(&root_name); + let executable = root.join("bin/php"); + + state::fs::write_sensitive_file(&executable, "#!/bin/sh\nexit 0\n")?; + set_executable(&executable)?; + state::fs::write_sensitive_file( + &root.join("share/pv/php-extensions.json"), + r#"[{"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}]"#, + )?; + state::fs::write_sensitive_file(&root.join("lib/php/extensions/redis.so"), "")?; + create_archive(&archive_parent, archive_path, &root_name) + } + + fn seed_frankenphp_archive(tempdir: &Utf8Path, archive_path: &Utf8Path) -> anyhow::Result<()> { + let archive_parent = tempdir.join("frankenphp-archive"); + let root_name = format!("frankenphp-{PHP_TEST_ARTIFACT_VERSION}"); + let root = archive_parent.join(&root_name); + let executable = root.join("bin/frankenphp"); + + state::fs::write_sensitive_file(&executable, "#!/bin/sh\nexit 0\n")?; + set_executable(&executable)?; + create_archive(&archive_parent, archive_path, &root_name) + } + + fn php_pair_manifest(artifacts: &[CachedManifestArtifact]) -> String { + let resources = artifacts + .iter() + .map(php_pair_manifest_resource) + .collect::>() + .join(",\n"); + + format!( + r#"{{ + "schema_version": 1, + "minimum_pv_version": "0.1.0", + "resources": [ +{resources} + ] +}} +"# + ) + } + + fn php_pair_manifest_resource(cached: &CachedManifestArtifact) -> String { + let artifact = cached.artifact; + + format!( + r#" {{ + "name": "{resource_name}", + "default_track": "{track}", + "tracks": [ + {{ + "name": "{track}", + "artifacts": [ + {{ + "artifact_version": "{artifact_version}", + "upstream_version": "{track}", + "pv_build_revision": "1", + "platform": "any", + "url": "https://artifacts.example.test/{archive_file_name}", + "sha256": "{sha256}", + "size": {size}, + "published_at": "2026-06-08T00:00:00Z" + }} + ] + }} + ] + }}"#, + resource_name = artifact.resource_name, + track = PHP_TEST_TRACK, + artifact_version = artifact.artifact_version, + archive_file_name = artifact.archive_file_name, + sha256 = cached.sha256, + size = cached.size, + ) + } + + fn create_archive( + archive_parent: &Utf8Path, + archive_path: &Utf8Path, + root_name: &str, + ) -> anyhow::Result<()> { + run_fixture_command( + "tar", + &[ + "-czf", + archive_path.as_str(), + "-C", + archive_parent.as_str(), + root_name, + ], + )?; + + Ok(()) + } + + fn sha256_file(path: &Utf8Path) -> anyhow::Result { + let output = run_fixture_command("shasum", &["-a", "256", path.as_str()]) + .or_else(|_error| run_fixture_command("sha256sum", &[path.as_str()]))?; + let text = String::from_utf8(output)?; + let Some(sha256) = text.split_whitespace().next() else { + anyhow::bail!("shasum output did not include a sha256 digest"); + }; + + Ok(sha256.to_string()) + } + + #[expect( + clippy::disallowed_types, + reason = "daemon jobs tests shell out to build archive fixtures without extra dev-dependencies" + )] + fn run_fixture_command(program: &str, args: &[&str]) -> anyhow::Result> { + let output = process::Command::new(program) + .env("COPYFILE_DISABLE", "1") + .args(args) + .output()?; + if !output.status.success() { + anyhow::bail!( + "fixture command `{program}` failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(output.stdout) + } + + #[expect( + clippy::disallowed_methods, + reason = "daemon jobs tests seed cached artifact fixtures directly" + )] + fn copy_file(from: &Utf8Path, to: &Utf8Path) -> anyhow::Result<()> { + if let Some(parent) = to.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(from, to)?; + + Ok(()) + } + + #[expect( + clippy::disallowed_methods, + reason = "daemon jobs tests read fixture archive metadata for manifest size" + )] + fn file_size(path: &Utf8Path) -> anyhow::Result { + Ok(fs::metadata(path)?.len()) + } + + #[expect( + clippy::disallowed_methods, + reason = "daemon jobs tests set fixture executable bits directly" + )] + fn set_executable(path: &Utf8Path) -> anyhow::Result<()> { + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions)?; + + Ok(()) + } + #[expect( clippy::disallowed_methods, reason = "daemon jobs tests create fixture directories" diff --git a/crates/daemon/src/project_env.rs b/crates/daemon/src/project_env.rs index d65d44a9..0b37de01 100644 --- a/crates/daemon/src/project_env.rs +++ b/crates/daemon/src/project_env.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; use std::io; +use camino::Utf8PathBuf; use config::{ AllocationEnvContext, ProjectConfig, ProjectConfigFile, ProjectEnvContext, ProjectEnvWarning, ResourceEnvContext, @@ -10,9 +11,10 @@ use resources::{ generated_allocation_name, }; use state::{ - Database, ProjectEnvObservedStatus, ProjectEnvObservedWarningInput, - ProjectManagedResourceInput, ProjectRecord, PvPaths, ResourceAllocationInput, - ResourceAllocationRecord, ResourceAllocationStatus, StateError, + Database, ManagedResourceDesiredState, ProjectEnvObservedStatus, + ProjectEnvObservedWarningInput, ProjectManagedResourceInput, ProjectPhpRuntimeInput, + ProjectRecord, PvPaths, ResourceAllocationInput, ResourceAllocationRecord, + ResourceAllocationStatus, StateError, }; use crate::DaemonError; @@ -21,6 +23,7 @@ use crate::managed_resources::ManagedResourceRuntimeCatalog; #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct ProjectEnvReconciliationSummary { message: &'static str, + requested_php_extensions: bool, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -34,10 +37,24 @@ pub(crate) struct ProjectResourceAllocationPlan { pub(crate) allocations: Vec, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ResolvedPhpRuntime { + pub(crate) track: String, + pub(crate) runtime_key: String, + pub(crate) requested_extensions: Vec, + pub(crate) loaded_extensions: Vec, + pub(crate) ignored_extensions: Vec, + pub(crate) loaded_modules: Vec, +} + impl ProjectEnvReconciliationSummary { pub(crate) fn as_str(&self) -> &'static str { self.message } + + pub(crate) fn requested_php_extensions(&self) -> bool { + self.requested_php_extensions + } } pub(crate) async fn reconcile_project_env( @@ -115,10 +132,17 @@ async fn reconcile_loaded_project( ) -> Result { let config_file = ProjectConfigFile::read_from_root(&project.path)?; let plan = validate_project_config_and_plan(paths, database, project, &config_file)?; - let resolved_php_track = - resolved_project_php_track_for_state(paths, project, config_file.config.php.as_deref())?; + let resolved_php_runtime = maybe_resolve_project_php_runtime( + paths, + database, + project, + config_file.config.php.as_ref(), + )?; let has_env_mappings = config_file.config.has_env_mappings(); + if let Some(runtime) = &resolved_php_runtime { + record_project_php_runtime_resource_requirements(database, runtime)?; + } apply_project_resource_plan(database, &project.id, &plan)?; if let Some(catalog) = catalog { crate::managed_resources::reconcile_project_resources_with_catalog( @@ -129,28 +153,68 @@ async fn reconcile_loaded_project( crate::managed_resources::reconcile_project_resources(paths, database, project, &plan) .await?; } - if project.desired_php_track.as_deref() != resolved_php_track.as_deref() { - database.replace_project_desired_php_track(&project.id, resolved_php_track.as_deref())?; + if let Some(runtime) = &resolved_php_runtime { + database.replace_project_php_runtime( + &project.id, + Some(&ProjectPhpRuntimeInput { + track: runtime.track.clone(), + requested_extensions: runtime.requested_extensions.clone(), + loaded_extensions: runtime.loaded_extensions.clone(), + ignored_extensions: runtime.ignored_extensions.clone(), + }), + )?; + } else if project.desired_php_track.is_some() { + database.replace_project_php_runtime(&project.id, None)?; } database.replace_project_additional_hostnames(&project.id, &config_file.config.hostnames)?; + let runtime_warnings = resolved_php_runtime + .as_ref() + .map(ignored_php_extension_warnings) + .unwrap_or_default(); + let requested_php_extensions = config_file + .config + .php + .as_ref() + .is_some_and(|php| !php.requested_extensions().is_empty()); if !has_env_mappings { + let status = if runtime_warnings.is_empty() { + ProjectEnvObservedStatus::Rendered + } else { + ProjectEnvObservedStatus::Warning + }; + let message = if runtime_warnings.is_empty() { + "no Project env mappings configured" + } else { + "Project runtime has warnings" + }; database.record_project_env_observed_snapshot( &project.id, - ProjectEnvObservedStatus::Rendered, - Some("no Project env mappings configured"), - &[], + status, + Some(message), + &runtime_warnings, )?; - return Ok(ProjectEnvReconciliationSummary { - message: "Project env unchanged; no mappings configured", - }); + let summary = if runtime_warnings.is_empty() { + ProjectEnvReconciliationSummary { + message: "Project env unchanged; no mappings configured", + requested_php_extensions, + } + } else { + ProjectEnvReconciliationSummary { + message: "Project env unchanged with warnings", + requested_php_extensions, + } + }; + + return Ok(summary); } let context = project_env_context_for_plan(database, project, &plan)?; let rendered = config::render_project_env(&config_file.config, &context)?; let transform = config::write_project_env_file(&project.path.join(".env"), &rendered)?; - let warnings = observed_warnings(&transform.warnings); + let mut warnings = observed_warnings(&transform.warnings); + warnings.extend(runtime_warnings); let status = if warnings.is_empty() { ProjectEnvObservedStatus::Rendered } else { @@ -167,31 +231,119 @@ async fn reconcile_loaded_project( let summary = if warnings.is_empty() { ProjectEnvReconciliationSummary { message: "Project env rendered", + requested_php_extensions, } } else { ProjectEnvReconciliationSummary { message: "Project env rendered with warnings", + requested_php_extensions, } }; Ok(summary) } -fn resolved_project_php_track_for_state( +fn maybe_resolve_project_php_runtime( paths: &PvPaths, + database: &Database, project: &ProjectRecord, - config_selector: Option<&str>, -) -> Result, DaemonError> { - match config_selector { - Some(selector) => { - resolve_project_php_track(paths, Some(selector), project.desired_php_track.as_deref()) - .map(Some) - } - None if paths.downloads().join("manifest.json").exists() => { - resolve_project_php_track(paths, None, None).map(Some) - } - None => Ok(project.desired_php_track.clone()), + php: Option<&config::PhpConfig>, +) -> Result, DaemonError> { + if php.is_none() + && project.desired_php_track.is_none() + && !paths.downloads().join("manifest.json").exists() + && database.global_php_default_track()?.is_none() + { + return Ok(None); } + + resolve_project_php_runtime(paths, database, project, php).map(Some) +} + +pub(crate) fn resolve_project_php_runtime( + paths: &PvPaths, + database: &Database, + project: &ProjectRecord, + php: Option<&config::PhpConfig>, +) -> Result { + let selector = php.and_then(config::PhpConfig::version_selector); + let global_selector = database.global_php_default_track()?; + let stored_selector = if selector.is_some() + || (!paths.downloads().join("manifest.json").exists() && global_selector.is_none()) + { + project.desired_php_track.as_deref() + } else { + None + }; + let track = + resolve_project_php_track(paths, selector, stored_selector, global_selector.as_deref())?; + let requested_extensions = php + .map(|php| php.requested_extensions().to_vec()) + .unwrap_or_default(); + let release = installed_php_release(database, &track)?; + let resolution = match release { + Some(release) => resources::resolve_php_extension_request(&release, &requested_extensions)?, + None => resources::PhpExtensionResolution { + requested: requested_extensions.clone(), + loaded: Vec::new(), + ignored: requested_extensions.clone(), + }, + }; + let loaded_extensions = resolution + .loaded + .iter() + .map(|module| module.name.clone()) + .collect::>(); + let runtime_key = state::php_runtime_key(&track, &loaded_extensions)?; + + Ok(ResolvedPhpRuntime { + track, + runtime_key, + requested_extensions: resolution.requested, + loaded_extensions, + ignored_extensions: resolution.ignored, + loaded_modules: resolution.loaded, + }) +} + +fn installed_php_release( + database: &Database, + track: &str, +) -> Result, DaemonError> { + let release = database + .managed_resource_tracks()? + .into_iter() + .find_map(|record| { + if record.resource_name == "php" + && record.track == track + && record.desired_state == ManagedResourceDesiredState::Installed + && record.installed_version.is_some() + { + return record.current_artifact_path; + } + + None + }); + + Ok(release) +} + +fn record_project_php_runtime_resource_requirements( + database: &mut Database, + runtime: &ResolvedPhpRuntime, +) -> Result<(), DaemonError> { + database.record_managed_resource_track_desired( + "php", + &runtime.track, + ManagedResourceDesiredState::Installed, + )?; + database.record_managed_resource_track_desired( + "frankenphp", + &runtime.track, + ManagedResourceDesiredState::Installed, + )?; + + Ok(()) } fn validate_project_config_and_plan( @@ -318,6 +470,7 @@ pub(crate) fn resolve_project_php_track( paths: &PvPaths, config_selector: Option<&str>, stored_selector: Option<&str>, + global_selector: Option<&str>, ) -> Result { let selector = config_selector.map(TrackSelector::parse).transpose()?; let track = match selector { @@ -328,7 +481,10 @@ pub(crate) fn resolve_project_php_track( Some(TrackSelector::Track(track)) => track.as_str().to_owned(), None => match stored_selector { Some(track) => track.to_string(), - None => default_project_php_track(paths)?, + None => match global_selector { + Some(track) => track.to_string(), + None => default_project_php_track(paths)?, + }, }, }; let track = ConcreteTrackName::new(track)?; @@ -492,6 +648,19 @@ fn observed_warnings(warnings: &[ProjectEnvWarning]) -> Vec Vec { + runtime + .ignored_extensions + .iter() + .map(|extension| ProjectEnvObservedWarningInput { + kind: "ignored_php_extension".to_string(), + message: format!("ignored unsupported PHP extension `{extension}`"), + }) + .collect() +} + fn record_project_env_failure( database: &mut Database, project_id: &str, diff --git a/crates/daemon/tests/daemon_foundation.rs b/crates/daemon/tests/daemon_foundation.rs index cdf27731..3fbd6d78 100644 --- a/crates/daemon/tests/daemon_foundation.rs +++ b/crates/daemon/tests/daemon_foundation.rs @@ -943,10 +943,15 @@ async fn dns_resolver_answers_tcp_queries() -> Result<()> { async fn dns_resolver_falls_back_when_preferred_port_is_unavailable() -> Result<()> { let tempdir = tempdir()?; let paths = PvPaths::for_home(tempdir.path().join("home")); - let (_tcp_blocker, _udp_blocker) = bind_preferred_dns_port_pair().await?; + let preferred_dns_port_blockers = bind_preferred_dns_port_pair().await?; let daemon = daemon::RunningDaemon::start(paths.clone()).await?; let port = dns_port(&paths)?; + if preferred_dns_port_blockers.is_none() && port == DNS_PREFERRED_PORT { + daemon.shutdown().await?; + return Ok(()); + } + assert_ne!(port, DNS_PREFERRED_PORT); assert!((RUNTIME_PORT_FALLBACK_START..=RUNTIME_PORT_FALLBACK_END).contains(&port)); @@ -1190,10 +1195,10 @@ fn dns_address(port: u16) -> SocketAddr { SocketAddr::from((Ipv4Addr::LOCALHOST, port)) } -async fn bind_preferred_dns_port_pair() -> Result<(StdTcpListener, StdUdpSocket)> { +async fn bind_preferred_dns_port_pair() -> Result> { for _attempt in 0..100 { match bind_loopback_tcp_udp_at(DNS_PREFERRED_PORT) { - Ok(blockers) => return Ok(blockers), + Ok(blockers) => return Ok(Some(blockers)), Err(error) if error.kind() == ErrorKind::AddrInUse => { sleep(Duration::from_millis(10)).await; } @@ -1201,6 +1206,10 @@ async fn bind_preferred_dns_port_pair() -> Result<(StdTcpListener, StdUdpSocket) } } + if !daemon::dns_port_available(DNS_PREFERRED_PORT) { + return Ok(None); + } + Err(anyhow!( "could not bind preferred DNS port {DNS_PREFERRED_PORT} after waiting for parallel tests" )) diff --git a/crates/daemon/tests/gateway_reconciliation.rs b/crates/daemon/tests/gateway_reconciliation.rs index d4c4a165..3bd86730 100644 --- a/crates/daemon/tests/gateway_reconciliation.rs +++ b/crates/daemon/tests/gateway_reconciliation.rs @@ -558,7 +558,7 @@ document_root: public let database = Database::open(&paths)?; assert!(!database.assigned_ports()?.iter().any(|port| matches!( &port.owner, - PortOwner::PhpWorker { php_track } if php_track == "8.4" + PortOwner::PhpWorker { php_runtime_key } if php_runtime_key == "8.4" ))); stop_runtime_from_pid_file(&paths.gateway_pid()).await?; @@ -832,6 +832,56 @@ document_root: public Ok(()) } +#[test] +fn gateway_runtime_plan_skips_invalid_config_fallback_when_persisted_loaded_extension_metadata_is_missing() +-> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let project_root = tempdir.path().join("acme"); + + create_project(&project_root, "php: [\n")?; + let mut database = Database::open(&paths)?; + let project = database.link_project(LinkProjectInput { + path: project_root.clone(), + original_path: project_root, + primary_hostname: "acme.test".to_owned(), + config_path: tempdir.path().join("acme/pv.yml"), + desired_php_track: Some("8.4".to_owned()), + additional_hostnames: Vec::new(), + })?; + database.replace_project_php_runtime( + &project.project.id, + Some(&state::ProjectPhpRuntimeInput { + track: "8.4".to_owned(), + requested_extensions: vec!["redis".to_owned()], + loaded_extensions: vec!["redis".to_owned()], + ignored_extensions: Vec::new(), + }), + )?; + drop(database); + seed_installed_php_with_extensions(&paths, "8.4", &[])?; + + let plan = build_runtime_plan(&paths)?; + let database = Database::open(&paths)?; + let observed = database + .project_env_observed_state(&project.project.id)? + .ok_or_else(|| anyhow::anyhow!("expected Project env observed failure"))?; + + assert!(plan.workers.is_empty()); + assert!(matches!( + observed.status, + state::ProjectEnvObservedStatus::Failed + )); + assert!( + observed + .message + .as_deref() + .is_some_and(|message| { message.contains("persisted PHP extension `redis`") }) + ); + + Ok(()) +} + #[tokio::test] async fn gateway_reconciliation_preserves_fragments_for_parseable_invalid_project_config() -> Result<()> { @@ -1143,6 +1193,37 @@ document_root: public Ok(()) } +#[test] +fn gateway_runtime_plan_groups_projects_by_php_track_and_extensions() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let acme = create_project_with_config( + tempdir.path(), + "acme", + "php:\n version: 8.4\n extensions: [redis]\n", + )?; + let api = create_project_with_config( + tempdir.path(), + "api", + "php:\n version: 8.4\n extensions: [xdebug, redis]\n", + )?; + let release = seed_installed_php_with_extensions(&paths, "8.4", &["redis", "xdebug"])?; + seed_installed_frankenphp_with_extensions(&paths, "8.4", &release, &["redis", "xdebug"])?; + link_project_record(&paths, &acme, "acme.test", Some("8.4"))?; + link_project_record(&paths, &api, "api.test", Some("8.4"))?; + + let plan = daemon::gateway::build_runtime_plan(&paths)?; + let runtime_keys = plan + .workers + .iter() + .map(|worker| worker.runtime_key.as_str()) + .collect::>(); + + assert_eq!(runtime_keys, ["8.4+redis", "8.4+redis+xdebug"]); + + Ok(()) +} + #[test] fn runtime_plan_resolves_latest_php_track_from_cached_manifest() -> Result<()> { let tempdir = tempdir()?; @@ -1345,7 +1426,8 @@ fn frankenphp_command_and_process_specs_are_stable() -> Result<()> { let paths = PvPaths::for_home(tempdir.path().join("home")); let command = FrankenphpCommand::new(tempdir.path().join("frankenphp")); let gateway = gateway_process_spec(&paths, &command); - let worker = worker_process_spec(&paths, "8.4", &command)?; + let worker_plan = php_worker_plan("8.4"); + let worker = worker_process_spec(&paths, &worker_plan, &command, tempdir.path())?; assert_eq!( gateway @@ -1564,7 +1646,9 @@ exit 0 let paths = PvPaths::for_home(root.join("home")); let expected_phprc = paths.resources().join("php/8.4/etc").to_string(); let expected_scan_dir = paths.resources().join("php/8.4/etc/conf.d").to_string(); - let private_environment = worker_process_spec(&paths, "8.4", &command)?.private_environment; + let worker_plan = php_worker_plan("8.4"); + let private_environment = + worker_process_spec(&paths, &worker_plan, &command, root)?.private_environment; validate_config(&command, &config_path, &private_environment).await?; @@ -1587,6 +1671,18 @@ fn create_project(project_root: &Utf8Path, config_source: &str) -> Result<()> { Ok(()) } +fn create_project_with_config( + workspace_root: &Utf8Path, + project_name: &str, + config_source: &str, +) -> Result { + let project_root = workspace_root.join(project_name); + + create_project(&project_root, config_source)?; + + Ok(project_root) +} + fn create_project_without_config(project_root: &Utf8Path, public_directory: bool) -> Result<()> { let index_path = if public_directory { project_root.join("public/index.php") @@ -1598,6 +1694,16 @@ fn create_project_without_config(project_root: &Utf8Path, public_directory: bool Ok(()) } +fn php_worker_plan(runtime_key: &str) -> daemon::gateway::PhpWorkerRuntimePlan { + daemon::gateway::PhpWorkerRuntimePlan { + php_track: "8.4".to_owned(), + runtime_key: runtime_key.to_owned(), + loaded_modules: Vec::new(), + port: RUNTIME_PORT_FALLBACK_START, + projects: Vec::new(), + } +} + fn write_failing_validator(path: &Utf8Path) -> Result { fs::write_sensitive_file( path, @@ -1834,6 +1940,88 @@ fn seed_stable_runtime_plan_ports(database: &mut Database, php_tracks: &[&str]) Ok(()) } +fn link_project_record( + paths: &PvPaths, + project_root: &Utf8Path, + primary_hostname: &str, + desired_php_track: Option<&str>, +) -> Result<()> { + let mut database = Database::open(paths)?; + + database.link_project(LinkProjectInput { + path: project_root.to_path_buf(), + original_path: project_root.to_path_buf(), + primary_hostname: primary_hostname.to_owned(), + config_path: project_root.join("pv.yml"), + desired_php_track: desired_php_track.map(str::to_owned), + additional_hostnames: Vec::new(), + })?; + + Ok(()) +} + +fn seed_installed_php_with_extensions( + paths: &PvPaths, + track: &str, + extensions: &[&str], +) -> Result { + let release = paths + .home() + .join(format!("{track}-php-release")) + .to_path_buf(); + let metadata = extension_metadata(extensions)?; + let mut database = Database::open(paths)?; + + fs::write_sensitive_file(&release.join("bin/php"), "#!/bin/sh\n")?; + fs::write_sensitive_file(&release.join("share/pv/php-extensions.json"), &metadata)?; + for extension in extensions { + fs::write_sensitive_file( + &release.join(format!("lib/php/extensions/{extension}.so")), + "", + )?; + } + database.record_managed_resource_track_installed("php", track, "8.4.8-pv1", &release)?; + + Ok(release) +} + +fn seed_installed_frankenphp_with_extensions( + paths: &PvPaths, + track: &str, + release: &Utf8Path, + extensions: &[&str], +) -> Result<()> { + let metadata = extension_metadata(extensions)?; + let mut database = Database::open(paths)?; + + fs::write_sensitive_file(&release.join("bin/frankenphp"), "#!/bin/sh\n")?; + fs::write_sensitive_file(&release.join("share/pv/php-extensions.json"), &metadata)?; + for extension in extensions { + fs::write_sensitive_file( + &release.join(format!("lib/php/extensions/{extension}.so")), + "", + )?; + } + database.record_managed_resource_track_installed("frankenphp", track, "8.4.8-pv1", release)?; + + Ok(()) +} + +fn extension_metadata(extensions: &[&str]) -> Result { + let modules = extensions + .iter() + .map(|extension| { + json!({ + "name": extension, + "load_kind": if *extension == "xdebug" { "zend_extension" } else { "extension" }, + "path": format!("lib/php/extensions/{extension}.so"), + }) + }) + .collect::>(); + + Ok(serde_json::to_string(&modules)?) +} + fn seed_runtime_ports( paths: &PvPaths, database: &mut Database, diff --git a/crates/daemon/tests/project_env_reconciliation.rs b/crates/daemon/tests/project_env_reconciliation.rs index 04fdc51b..d6522018 100644 --- a/crates/daemon/tests/project_env_reconciliation.rs +++ b/crates/daemon/tests/project_env_reconciliation.rs @@ -8,9 +8,11 @@ use camino::Utf8Path; use camino_tempfile::tempdir; use insta::{Settings, assert_debug_snapshot}; use serde_json::{Value, json}; +use state::fs::write_sensitive_file; use state::{ Database, EnvContextValues, JobRecord, LinkProjectInput, PortOwner, PortRequest, - ProjectManagedResourceInput, ProjectRecord, PvPaths, ResourceAllocationInput, StateError, + ProjectEnvObservedStatus, ProjectManagedResourceInput, ProjectRecord, PvPaths, + ResourceAllocationInput, StateError, }; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; @@ -50,15 +52,15 @@ async fn project_env_reconciliation_uses_project_root_not_config_path_for_dotenv let original_path = tempdir.path().join("typed-project-path"); let stored_config_path = tempdir.path().join("stale-config-location/pv.yml"); - state::fs::write_sensitive_file( + write_sensitive_file( &project_root.join("pv.yml"), "env:\n APP_URL: \"${project_url}\"\n APP_NAME: canonical\n", )?; - state::fs::write_sensitive_file( + write_sensitive_file( &original_path.join(".env"), "ORIGINAL_PATH_VALUE=must-not-change\n", )?; - state::fs::write_sensitive_file(&stored_config_path, "env:\n APP_NAME: stale-config-path\n")?; + write_sensitive_file(&stored_config_path, "env:\n APP_NAME: stale-config-path\n")?; let mut database = Database::open(&paths)?; let project = database.link_project(LinkProjectInput { @@ -138,6 +140,103 @@ async fn project_env_reconciliation_persists_latest_php_as_concrete_track() -> R Ok(()) } +#[tokio::test] +async fn project_env_reconciliation_persists_php_extension_runtime() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let project = link_project( + &paths, + &tempdir.path().join("project"), + "acme.test", + "php:\n version: \"8.4\"\n extensions: [redis, missing]\n", + )?; + let release = tempdir.path().join("php-release"); + write_sensitive_file(&release.join("bin/php"), "#!/bin/sh\n")?; + write_sensitive_file( + &release.join("share/pv/php-extensions.json"), + r#"[{"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}]"#, + )?; + write_sensitive_file(&release.join("lib/php/extensions/redis.so"), "")?; + { + let mut database = Database::open(&paths)?; + database.record_managed_resource_track_installed("php", "8.4", "8.4.8-pv1", &release)?; + } + + run_project_reconciliation(&paths, &project).await?; + + let database = Database::open(&paths)?; + let project = database + .project_by_id(&project.id)? + .ok_or_else(|| anyhow!("expected linked project"))?; + let observed = database + .project_env_observed_state(&project.id)? + .ok_or_else(|| anyhow!("expected observed project env state"))?; + + assert_eq!(project.php_runtime.track.as_deref(), Some("8.4")); + assert_eq!( + project.php_runtime.requested_extensions, + ["redis", "missing"] + ); + assert_eq!(project.php_runtime.loaded_extensions, ["redis"]); + assert_eq!(project.php_runtime.ignored_extensions, ["missing"]); + assert_eq!(observed.status, ProjectEnvObservedStatus::Warning); + assert_eq!(observed.warnings[0].kind, "ignored_php_extension"); + + Ok(()) +} + +#[tokio::test] +async fn project_env_reconciliation_persists_non_identity_ignored_php_extension() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let project = link_project( + &paths, + &tempdir.path().join("project"), + "acme.test", + "php:\n version: \"8.4\"\n extensions: [\"not-supported-yet\"]\n", + )?; + let release = tempdir.path().join("php-release"); + write_sensitive_file(&release.join("bin/php"), "#!/bin/sh\n")?; + write_sensitive_file( + &release.join("share/pv/php-extensions.json"), + r#"[{"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}]"#, + )?; + write_sensitive_file(&release.join("lib/php/extensions/redis.so"), "")?; + { + let mut database = Database::open(&paths)?; + database.record_managed_resource_track_installed("php", "8.4", "8.4.8-pv1", &release)?; + } + + run_project_reconciliation(&paths, &project).await?; + + let database = Database::open(&paths)?; + let project = database + .project_by_id(&project.id)? + .ok_or_else(|| anyhow!("expected linked project"))?; + let observed = database + .project_env_observed_state(&project.id)? + .ok_or_else(|| anyhow!("expected observed project env state"))?; + + assert_eq!(project.php_runtime.track.as_deref(), Some("8.4")); + assert_eq!( + project.php_runtime.requested_extensions, + ["not-supported-yet"] + ); + assert!(project.php_runtime.loaded_extensions.is_empty()); + assert_eq!( + project.php_runtime.ignored_extensions, + ["not-supported-yet"] + ); + assert_eq!(observed.status, ProjectEnvObservedStatus::Warning); + assert_eq!(observed.warnings[0].kind, "ignored_php_extension"); + assert_eq!( + observed.warnings[0].message, + "ignored unsupported PHP extension `not-supported-yet`" + ); + + Ok(()) +} + #[tokio::test] async fn project_env_reconciliation_reuses_concrete_track_for_latest_php() -> Result<()> { let tempdir = tempdir()?; @@ -281,7 +380,7 @@ async fn missing_context_leaves_dotenv_unchanged_and_records_failure() -> Result "acme.test", "postgres:\n version: \"8.0\"\n env:\n DB_HOST: \"${host}\"\n", )?; - state::fs::write_sensitive_file(&project.path.join(".env"), "USER_VALUE=kept\n")?; + write_sensitive_file(&project.path.join(".env"), "USER_VALUE=kept\n")?; let lines = run_project_reconciliation(&paths, &project).await?; let database = Database::open(&paths)?; @@ -309,7 +408,7 @@ async fn root_env_with_resource_waits_for_resource_context_before_dotenv() -> Re "acme.test", "env:\n APP_URL: \"${project_url}\"\npostgres:\n version: \"8.0\"\n", )?; - state::fs::write_sensitive_file(&project.path.join(".env"), "USER_VALUE=kept\n")?; + write_sensitive_file(&project.path.join(".env"), "USER_VALUE=kept\n")?; let lines = run_project_reconciliation(&paths, &project).await?; let database = Database::open(&paths)?; @@ -374,7 +473,7 @@ async fn malformed_pv_block_leaves_dotenv_unchanged_and_records_failure() -> Res "acme.test", "env:\n APP_URL: \"${project_url}\"\n", )?; - state::fs::write_sensitive_file( + write_sensitive_file( &project.path.join(".env"), "USER_VALUE=kept\n# >>> PV MANAGED\nAPP_URL=https://old.test\n", )?; @@ -411,7 +510,7 @@ postgres: DB_HOST: "${host}" "#, )?; - state::fs::write_sensitive_file( + write_sensitive_file( &project.path.join(".env"), "USER_VALUE=kept\n# >>> PV MANAGED\nAPP_URL=https://old.test\n", )?; @@ -448,7 +547,7 @@ async fn duplicate_user_owned_key_writes_block_and_records_warning() -> Result<( "acme.test", "env:\n APP_URL: \"${project_url}\"\n", )?; - state::fs::write_sensitive_file(&project.path.join(".env"), "APP_URL=https://user.test\n")?; + write_sensitive_file(&project.path.join(".env"), "APP_URL=https://user.test\n")?; let lines = run_project_reconciliation(&paths, &project).await?; let database = Database::open(&paths)?; @@ -722,7 +821,7 @@ async fn no_mappings_do_not_touch_existing_dotenv_and_record_noop_success() -> R "acme.test", "php: \"8.4\"\n", )?; - state::fs::write_sensitive_file(&project.path.join(".env"), "USER_VALUE=kept\n")?; + write_sensitive_file(&project.path.join(".env"), "USER_VALUE=kept\n")?; let lines = run_project_reconciliation(&paths, &project).await?; let database = Database::open(&paths)?; @@ -814,7 +913,7 @@ async fn multiple_managed_dotenv_blocks_fold_to_one_and_preserve_permissions() - "env:\n APP_URL: \"${project_url}\"\n", )?; let dotenv_path = project.path.join(".env"); - state::fs::write_sensitive_file( + write_sensitive_file( &dotenv_path, r#"BEFORE=1 # >>> PV MANAGED @@ -846,6 +945,43 @@ AFTER=1 Ok(()) } +#[tokio::test] +async fn project_env_reconciliation_uses_global_php_default_for_extension_only_config() -> Result<()> +{ + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let project = link_project( + &paths, + &tempdir.path().join("project"), + "acme.test", + "php:\n extensions: [redis]\n", + )?; + seed_manifest(&paths, "8.5")?; + { + let mut database = Database::open(&paths)?; + database.record_global_php_default_track("8.3")?; + } + + run_project_reconciliation(&paths, &project).await?; + + let database = Database::open(&paths)?; + let project = database + .project_by_id(&project.id)? + .ok_or_else(|| anyhow!("expected linked project"))?; + + assert_eq!(project.desired_php_track.as_deref(), Some("8.3")); + assert_eq!( + project.php_runtime.requested_extensions, + vec!["redis".to_string()] + ); + assert_eq!( + project.php_runtime.ignored_extensions, + vec!["redis".to_string()] + ); + + Ok(()) +} + #[tokio::test] async fn config_declared_hostnames_are_persisted_during_reconciliation() -> Result<()> { let tempdir = tempdir()?; @@ -1131,7 +1267,7 @@ fn link_project( ) -> Result { let config_path = project_path.join("pv.yml"); - state::fs::write_sensitive_file(&config_path, config_source)?; + write_sensitive_file(&config_path, config_source)?; let mut database = Database::open(paths)?; let result = database.link_project(LinkProjectInput { @@ -1147,7 +1283,7 @@ fn link_project( } fn write_project_config(project: &ProjectRecord, config_source: &str) -> Result<()> { - state::fs::write_sensitive_file(&project.config_path, config_source)?; + write_sensitive_file(&project.config_path, config_source)?; Ok(()) } @@ -1238,7 +1374,7 @@ fn seed_postgres_resource_context_for_track_in_database( } fn seed_manifest(paths: &PvPaths, default_track: &str) -> Result<()> { - state::fs::write_sensitive_file( + write_sensitive_file( &paths.downloads().join("manifest.json"), &test_manifest(default_track), )?; diff --git a/crates/daemon/tests/real_artifact_gateway_e2e.rs b/crates/daemon/tests/real_artifact_gateway_e2e.rs index fa8cb465..85e4daa8 100644 --- a/crates/daemon/tests/real_artifact_gateway_e2e.rs +++ b/crates/daemon/tests/real_artifact_gateway_e2e.rs @@ -4,7 +4,9 @@ use anyhow::{Result, anyhow, bail}; use camino::Utf8Path; use camino_tempfile::tempdir; use daemon::ProcessSupervisor; -use daemon::gateway::{FrankenphpCommand, gateway_process_spec, worker_process_spec}; +use daemon::gateway::{ + FrankenphpCommand, PhpWorkerRuntimePlan, gateway_process_spec, worker_process_spec, +}; use resources::{ ManagedResourceCommands, TargetPlatform, TrackSelector, frankenphp_adapter, php_adapter, }; @@ -166,13 +168,42 @@ async fn stop_gateway_runtimes( if let Some(gateway) = supervisor.adopt(&gateway_process_spec(paths, command))? { gateway.stop(Duration::from_secs(1)).await?; } - if let Some(worker) = supervisor.adopt(&worker_process_spec(paths, php_track, command)?)? { + let worker_plan = default_worker_plan(php_track); + let artifact_root = frankenphp_artifact_root(command)?; + if let Some(worker) = supervisor.adopt(&worker_process_spec( + paths, + &worker_plan, + command, + artifact_root, + )?)? { worker.stop(Duration::from_secs(1)).await?; } Ok(()) } +fn default_worker_plan(php_track: &str) -> PhpWorkerRuntimePlan { + PhpWorkerRuntimePlan { + php_track: php_track.to_owned(), + runtime_key: php_track.to_owned(), + loaded_modules: Vec::new(), + port: 0, + projects: Vec::new(), + } +} + +fn frankenphp_artifact_root(command: &FrankenphpCommand) -> Result<&Utf8Path> { + let bin_dir = command + .executable() + .parent() + .ok_or_else(|| anyhow!("FrankenPHP command is missing a bin directory"))?; + let artifact_root = bin_dir + .parent() + .ok_or_else(|| anyhow!("FrankenPHP command is missing an artifact root"))?; + + Ok(artifact_root) +} + #[expect( clippy::disallowed_types, reason = "ignored real-artifact E2E shells out to curl to verify TLS with PV's CA" diff --git a/crates/daemon/tests/snapshots/gateway_reconciliation__runtime_plan_defaults_document_root_to_project_root_without_public_directory.snap b/crates/daemon/tests/snapshots/gateway_reconciliation__runtime_plan_defaults_document_root_to_project_root_without_public_directory.snap index 36def795..6e8e531c 100644 --- a/crates/daemon/tests/snapshots/gateway_reconciliation__runtime_plan_defaults_document_root_to_project_root_without_public_directory.snap +++ b/crates/daemon/tests/snapshots/gateway_reconciliation__runtime_plan_defaults_document_root_to_project_root_without_public_directory.snap @@ -13,6 +13,8 @@ RuntimePlan { workers: [ PhpWorkerRuntimePlan { php_track: "8.4", + runtime_key: "8.4", + loaded_modules: [], port: , projects: [ RuntimeProject { diff --git a/crates/daemon/tests/snapshots/gateway_reconciliation__runtime_plan_defaults_document_root_to_public_directory_without_config.snap b/crates/daemon/tests/snapshots/gateway_reconciliation__runtime_plan_defaults_document_root_to_public_directory_without_config.snap index 2bd07c99..66739130 100644 --- a/crates/daemon/tests/snapshots/gateway_reconciliation__runtime_plan_defaults_document_root_to_public_directory_without_config.snap +++ b/crates/daemon/tests/snapshots/gateway_reconciliation__runtime_plan_defaults_document_root_to_public_directory_without_config.snap @@ -13,6 +13,8 @@ RuntimePlan { workers: [ PhpWorkerRuntimePlan { php_track: "8.4", + runtime_key: "8.4", + loaded_modules: [], port: , projects: [ RuntimeProject { diff --git a/crates/daemon/tests/snapshots/gateway_reconciliation__runtime_plan_groups_linked_projects_by_php_track.snap b/crates/daemon/tests/snapshots/gateway_reconciliation__runtime_plan_groups_linked_projects_by_php_track.snap index a27f4ad9..b7e0bfc0 100644 --- a/crates/daemon/tests/snapshots/gateway_reconciliation__runtime_plan_groups_linked_projects_by_php_track.snap +++ b/crates/daemon/tests/snapshots/gateway_reconciliation__runtime_plan_groups_linked_projects_by_php_track.snap @@ -13,6 +13,8 @@ RuntimePlan { workers: [ PhpWorkerRuntimePlan { php_track: "8.3", + runtime_key: "8.3", + loaded_modules: [], port: , projects: [ RuntimeProject { @@ -27,6 +29,8 @@ RuntimePlan { }, PhpWorkerRuntimePlan { php_track: "8.4", + runtime_key: "8.4", + loaded_modules: [], port: , projects: [ RuntimeProject { diff --git a/crates/daemon/tests/snapshots/gateway_reconciliation__runtime_plan_resolves_latest_php_track_from_cached_manifest.snap b/crates/daemon/tests/snapshots/gateway_reconciliation__runtime_plan_resolves_latest_php_track_from_cached_manifest.snap index 92437b47..b83e9eab 100644 --- a/crates/daemon/tests/snapshots/gateway_reconciliation__runtime_plan_resolves_latest_php_track_from_cached_manifest.snap +++ b/crates/daemon/tests/snapshots/gateway_reconciliation__runtime_plan_resolves_latest_php_track_from_cached_manifest.snap @@ -13,6 +13,8 @@ RuntimePlan { workers: [ PhpWorkerRuntimePlan { php_track: "8.4", + runtime_key: "8.4", + loaded_modules: [], port: , projects: [ RuntimeProject { diff --git a/crates/daemon/tests/snapshots/gateway_reconciliation__runtime_plan_uses_project_root_not_original_or_config_path.snap b/crates/daemon/tests/snapshots/gateway_reconciliation__runtime_plan_uses_project_root_not_original_or_config_path.snap index e95711f9..f62b7353 100644 --- a/crates/daemon/tests/snapshots/gateway_reconciliation__runtime_plan_uses_project_root_not_original_or_config_path.snap +++ b/crates/daemon/tests/snapshots/gateway_reconciliation__runtime_plan_uses_project_root_not_original_or_config_path.snap @@ -13,6 +13,8 @@ RuntimePlan { workers: [ PhpWorkerRuntimePlan { php_track: "8.4", + runtime_key: "8.4", + loaded_modules: [], port: , projects: [ RuntimeProject { diff --git a/crates/pv-release/src/archive.rs b/crates/pv-release/src/archive.rs index fa55b34d..74fb0182 100644 --- a/crates/pv-release/src/archive.rs +++ b/crates/pv-release/src/archive.rs @@ -15,6 +15,7 @@ pub struct ArchiveValidation { size: u64, root: String, entries: Vec, + regular_file_entries: BTreeSet, } struct ExtractedArchive { @@ -42,6 +43,46 @@ impl ArchiveValidation { pub fn entries(&self) -> &[String] { &self.entries } + + pub fn has_regular_file(&self, path: &str) -> bool { + self.regular_file_entries.contains(path) + } + + pub fn read_regular_file_to_string(&self, path: &str) -> crate::Result { + if !self.has_regular_file(path) { + return Err(invalid_archive( + &self.archive_path, + format!("missing regular file `{path}`"), + )); + } + + let file = open_file(&self.archive_path)?; + let decoder = GzDecoder::new(file); + let mut archive = Archive::new(decoder); + + for entry in archive + .entries() + .map_err(|error| invalid_archive(&self.archive_path, error))? + { + let mut entry = entry.map_err(|error| invalid_archive(&self.archive_path, error))?; + let entry_path = archive_entry_path(&self.archive_path, &entry)?; + archive_path_components(&self.archive_path, &entry_path)?; + if entry_path.trim_end_matches('/') != path { + continue; + } + + let mut contents = String::new(); + entry + .read_to_string(&mut contents) + .map_err(|error| invalid_archive(&self.archive_path, error))?; + return Ok(contents); + } + + Err(invalid_archive( + &self.archive_path, + format!("missing regular file `{path}`"), + )) + } } impl ExtractedArchive { @@ -125,6 +166,7 @@ pub fn validate_archive( size, root, entries, + regular_file_entries, }) } diff --git a/crates/pv-release/src/cli.rs b/crates/pv-release/src/cli.rs index fb8a31c8..56b5256e 100644 --- a/crates/pv-release/src/cli.rs +++ b/crates/pv-release/src/cli.rs @@ -8,7 +8,9 @@ use crate::app::WriteAppReleaseRecordRequest; use crate::app_publication::AppPublicationRequest; use crate::publication::PublicationRequest; use crate::recipe::BackingRecipeKind; -use crate::record_writer::{SourceInputRequest, WriteReleaseRecordRequest}; +use crate::record_writer::{ + PhpExtensionRecordRequest, SourceInputRequest, WriteReleaseRecordRequest, +}; #[derive(Debug, Parser)] #[command(name = "pv-release")] @@ -157,6 +159,8 @@ enum Command { license_files: Vec, #[arg(long = "notice-file")] notice_files: Vec, + #[arg(long = "php-extension", num_args = 3, value_names = ["NAME", "LOAD_KIND", "PATH"])] + php_extensions: Vec, #[arg(long = "source-input", num_args = 3, value_names = ["NAME", "URL", "SHA256"])] source_inputs: Vec, }, @@ -331,9 +335,11 @@ pub fn run() -> anyhow::Result<()> { published_at, license_files, notice_files, + php_extensions, source_inputs, } => { let context = format!("failed to write release record `{record}`"); + let php_extensions = parse_php_extensions(&php_extensions)?; let source_inputs = parse_source_inputs(&source_inputs)?; let license_files = default_legal_files(license_files, "LICENSE"); let notice_files = default_legal_files(notice_files, "NOTICE"); @@ -355,6 +361,7 @@ pub fn run() -> anyhow::Result<()> { published_at, license_files, notice_files, + php_extensions, source_inputs, }) .context(context) @@ -450,6 +457,24 @@ fn parse_source_inputs(values: &[String]) -> anyhow::Result anyhow::Result> { + let mut chunks = values.chunks_exact(3); + let php_extensions = chunks + .by_ref() + .map(|chunk| PhpExtensionRecordRequest { + name: chunk[0].clone(), + load_kind: chunk[1].clone(), + path: chunk[2].clone(), + }) + .collect::>(); + + if !chunks.remainder().is_empty() { + anyhow::bail!("each --php-extension requires NAME LOAD_KIND PATH"); + } + + Ok(php_extensions) +} + fn default_legal_files(values: Vec, default: &str) -> Vec { if values.is_empty() { vec![default.to_string()] @@ -1017,6 +1042,14 @@ mod tests { "NOTICE", "--notice-file", "THIRD-PARTY-NOTICES", + "--php-extension", + "redis", + "extension", + "lib/php/extensions/redis.so", + "--php-extension", + "xdebug", + "zend_extension", + "lib/php/extensions/xdebug.so", "--source-input", "frankenphp", "https://github.com/php/frankenphp/archive/refs/tags/v1.12.3.tar.gz", @@ -1046,6 +1079,7 @@ mod tests { published_at, license_files, notice_files, + php_extensions, source_inputs, } => { assert_eq!(record, Utf8PathBuf::from("record.json")); @@ -1074,6 +1108,17 @@ mod tests { assert_eq!(published_at, "2026-06-08T12:00:00Z"); assert_eq!(license_files, vec!["LICENSE"]); assert_eq!(notice_files, vec!["NOTICE", "THIRD-PARTY-NOTICES"]); + assert_eq!( + php_extensions, + vec![ + "redis", + "extension", + "lib/php/extensions/redis.so", + "xdebug", + "zend_extension", + "lib/php/extensions/xdebug.so", + ] + ); assert_eq!( source_inputs, vec![ diff --git a/crates/pv-release/src/manifest.rs b/crates/pv-release/src/manifest.rs index 32e7f9eb..932cb862 100644 --- a/crates/pv-release/src/manifest.rs +++ b/crates/pv-release/src/manifest.rs @@ -5,8 +5,8 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::defaults::ManifestDefaults; use crate::record::{ - Provenance, ReleaseRecord, RevocationRecord, SourceInput, load_release_records, - load_revocation_records, + PhpExtensionRecord, Provenance, ReleaseRecord, RevocationRecord, SourceInput, + load_release_records, load_revocation_records, }; pub fn generate_manifest_file( @@ -169,6 +169,8 @@ struct ManifestArtifactJson { sha256: String, size: u64, published_at: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + php_extensions: Vec, provenance: ManifestProvenanceJson, #[serde(skip_serializing_if = "is_false")] revoked: bool, @@ -191,6 +193,13 @@ struct ManifestProvenanceJson { build_run_id: String, } +#[derive(Serialize)] +struct ManifestPhpExtensionJson { + name: String, + load_kind: String, + path: String, +} + #[derive(Serialize)] struct ManifestSourceInputJson { name: String, @@ -237,6 +246,11 @@ impl ManifestArtifactJson { sha256: record.sha256().as_str().to_string(), size: record.size(), published_at: record.published_at_raw().to_string(), + php_extensions: record + .php_extensions() + .iter() + .map(ManifestPhpExtensionJson::from_record) + .collect(), provenance: ManifestProvenanceJson::from_provenance(record.provenance()), revoked: revocation.is_some(), revocation_reason: revocation.map(|revocation| revocation.reason().to_string()), @@ -265,6 +279,16 @@ impl ManifestProvenanceJson { } } +impl ManifestPhpExtensionJson { + fn from_record(record: &PhpExtensionRecord) -> Self { + Self { + name: record.name().to_string(), + load_kind: record.load_kind().to_string(), + path: record.path().to_string(), + } + } +} + impl ManifestSourceInputJson { fn from_source_input(source_input: &SourceInput) -> Self { Self { diff --git a/crates/pv-release/src/recipe.rs b/crates/pv-release/src/recipe.rs index 5c14c9d9..80ffe59f 100644 --- a/crates/pv-release/src/recipe.rs +++ b/crates/pv-release/src/recipe.rs @@ -26,15 +26,13 @@ const REQUIRED_PHP_EXTENSIONS: &[&str] = &[ "pdo_mysql", "pdo_pgsql", "pdo_sqlite", - "pdo_sqlsrv", "phar", "posix", - "redis", "session", "simplexml", + "sockets", "sodium", "sqlite3", - "sqlsrv", "tokenizer", "xml", "xmlreader", @@ -106,7 +104,8 @@ pub struct RecipeHeader { #[derive(Clone, Debug)] pub struct PhpSettings { deployment_target: String, - build_extensions: Vec, + default_extensions: Vec, + optional_extensions: Vec, expected_extensions: Vec, } @@ -191,7 +190,8 @@ enum LegalFilePolicy { #[serde(deny_unknown_fields)] struct RawPhpSettings { deployment_target: String, - build_extensions: Vec, + default_extensions: Vec, + optional_extensions: Vec, expected_extensions: Vec, } @@ -509,8 +509,12 @@ impl PhpRecipe { &self.php.deployment_target } - pub fn build_extensions(&self) -> &[String] { - &self.php.build_extensions + pub fn default_extensions(&self) -> &[String] { + &self.php.default_extensions + } + + pub fn optional_extensions(&self) -> &[String] { + &self.php.optional_extensions } pub fn expected_extensions(&self) -> &[String] { @@ -606,11 +610,21 @@ impl PhpSettings { fn from_raw(path: &Utf8Path, raw: RawPhpSettings) -> crate::Result { validate_deployment_target(path, &raw.deployment_target)?; validate_expected_extensions(path, &raw.expected_extensions)?; - validate_build_extensions(path, &raw.build_extensions, &raw.expected_extensions)?; + validate_extension_list(path, "php.default_extensions", &raw.default_extensions)?; + validate_build_extensions(path, &raw.default_extensions, &raw.expected_extensions)?; + validate_extension_list(path, "php.optional_extensions", &raw.optional_extensions)?; + validate_extension_lists_do_not_overlap( + path, + "php.default_extensions", + &raw.default_extensions, + "php.optional_extensions", + &raw.optional_extensions, + )?; Ok(Self { deployment_target: raw.deployment_target, - build_extensions: raw.build_extensions, + default_extensions: raw.default_extensions, + optional_extensions: raw.optional_extensions, expected_extensions: raw.expected_extensions, }) } @@ -897,7 +911,13 @@ pub fn php_recipe_env( let pv_build_revision = recipe.pv_build_revision(); let artifact_version = format!("{upstream_version}-{pv_build_revision}"); - let build_extensions = recipe.build_extensions().join(","); + let default_extensions = recipe.default_extensions().join(","); + let optional_extensions = recipe.optional_extensions().join(","); + let build_extensions = if optional_extensions.is_empty() { + default_extensions.clone() + } else { + format!("{default_extensions},{optional_extensions}") + }; let expected_extensions = recipe.expected_extensions().join(","); let minimum_pv_version = recipe.minimum_pv_version().as_str(); let deployment_target = recipe.deployment_target(); @@ -937,6 +957,16 @@ pub fn php_recipe_env( "deployment_target", deployment_target, ), + ( + "PV_DEFAULT_EXTENSIONS", + "default_extensions", + default_extensions.as_str(), + ), + ( + "PV_OPTIONAL_EXTENSIONS", + "optional_extensions", + optional_extensions.as_str(), + ), ( "PV_BUILD_EXTENSIONS", "build_extensions", @@ -1465,7 +1495,7 @@ fn validate_build_extensions( expected_extensions: &[String], ) -> crate::Result<()> { if build_extensions.is_empty() { - return Err(invalid(path, "build_extensions must not be empty")); + return Err(invalid(path, "php.default_extensions must not be empty")); } let expected: BTreeSet<&str> = expected_extensions.iter().map(String::as_str).collect(); @@ -1474,7 +1504,7 @@ fn validate_build_extensions( return Err(invalid( path, format!( - "build_extensions contains extension `{extension}` outside expected_extensions" + "php.default_extensions contains extension `{extension}` outside expected_extensions" ), )); } @@ -1483,6 +1513,54 @@ fn validate_build_extensions( Ok(()) } +fn validate_extension_list( + path: &Utf8Path, + field: &str, + extensions: &[String], +) -> crate::Result<()> { + let mut seen = BTreeSet::new(); + for extension in extensions { + let valid = !extension.is_empty() + && extension + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'); + if !valid { + return Err(invalid( + path, + format!("{field} contains invalid extension `{extension}`"), + )); + } + if !seen.insert(extension.as_str()) { + return Err(invalid( + path, + format!("{field} contains duplicate extension `{extension}`"), + )); + } + } + + Ok(()) +} + +fn validate_extension_lists_do_not_overlap( + path: &Utf8Path, + left_field: &str, + left: &[String], + right_field: &str, + right: &[String], +) -> crate::Result<()> { + let left = left.iter().map(String::as_str).collect::>(); + for extension in right { + if left.contains(extension.as_str()) { + return Err(invalid( + path, + format!("{left_field} and {right_field} both contain extension `{extension}`"), + )); + } + } + + Ok(()) +} + fn parse_https_url(path: &Utf8Path, field: &str, value: String) -> crate::Result { let value = require_non_empty(path, field, &value)?.to_string(); if value.contains('\\') { diff --git a/crates/pv-release/src/record.rs b/crates/pv-release/src/record.rs index a534bd4e..532b9587 100644 --- a/crates/pv-release/src/record.rs +++ b/crates/pv-release/src/record.rs @@ -1,7 +1,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use resources::{ - ArtifactPlatform, ArtifactVersion, PublishedAt, PvVersion, ResourceName, ResourcesError, - Sha256Digest, TrackName, + ArtifactPlatform, ArtifactVersion, PHP_EXTENSION_METADATA_PATH, PublishedAt, PvVersion, + ResourceName, ResourcesError, Sha256Digest, TrackName, }; use serde::Deserialize; use std::collections::btree_map::Entry; @@ -35,9 +35,18 @@ pub struct ReleaseRecord { minimum_pv_version: PvVersion, license_files: Vec, notice_files: Vec, + php_extensions: Vec, provenance: Provenance, } +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct PhpExtensionRecord { + name: String, + load_kind: String, + path: String, +} + #[derive(Clone)] pub struct RevocationRecord { path: Utf8PathBuf, @@ -75,6 +84,8 @@ struct RawReleaseRecord { license_files: Vec, #[serde(default)] notice_files: Vec, + #[serde(default)] + php_extensions: Vec, provenance: Provenance, } @@ -172,11 +183,13 @@ impl ReleaseRecord { validate_relative_file_list(path, "notice_files", &raw.notice_files)?; validate_relative_path(path, "object_key", &raw.object_key)?; validate_object_key_layout(path, &raw)?; + let resource = ResourceName::new(raw.resource.clone()) + .map_err(|error| invalid_release_identity(path, "resource", error))?; + validate_php_extensions(path, &resource, &raw.php_extensions)?; raw.provenance.validate(path)?; let identity = ArtifactIdentity { - resource: ResourceName::new(raw.resource) - .map_err(|error| invalid_release_identity(path, "resource", error))?, + resource, track: TrackName::new(raw.track) .map_err(|error| invalid_release_identity(path, "track", error))?, upstream_version: require_non_empty_release( @@ -211,6 +224,7 @@ impl ReleaseRecord { .map_err(|error| invalid_release_identity(path, "minimum_pv_version", error))?, license_files: raw.license_files, notice_files: raw.notice_files, + php_extensions: raw.php_extensions, provenance: raw.provenance, }) } @@ -275,6 +289,10 @@ impl ReleaseRecord { &self.notice_files } + pub fn php_extensions(&self) -> &[PhpExtensionRecord] { + &self.php_extensions + } + pub fn verify_archive( &self, validation: &crate::archive::ArchiveValidation, @@ -295,10 +313,77 @@ impl ReleaseRecord { }); } + for extension in &self.php_extensions { + let expected = format!("{}/{}", validation.root(), extension.path); + if !validation.has_regular_file(&expected) { + return Err(crate::ReleaseError::InvalidArchive { + path: validation.archive_path().to_string(), + reason: format!( + "missing advertised PHP extension module `{}`", + extension.path + ), + }); + } + } + self.verify_archive_php_extension_metadata(validation)?; + + Ok(()) + } + + fn verify_archive_php_extension_metadata( + &self, + validation: &crate::archive::ArchiveValidation, + ) -> crate::Result<()> { + if self.php_extensions.is_empty() { + return Ok(()); + } + + let metadata_path = format!("{}/{}", validation.root(), PHP_EXTENSION_METADATA_PATH); + if !validation.has_regular_file(&metadata_path) { + return Err(crate::ReleaseError::InvalidArchive { + path: validation.archive_path().to_string(), + reason: format!( + "missing PHP extension metadata `{PHP_EXTENSION_METADATA_PATH}` for advertised PHP extensions" + ), + }); + } + + let metadata = validation.read_regular_file_to_string(&metadata_path)?; + let archive_extensions = serde_json::from_str::>(&metadata) + .map_err(|error| crate::ReleaseError::InvalidArchive { + path: validation.archive_path().to_string(), + reason: format!( + "invalid PHP extension metadata `{PHP_EXTENSION_METADATA_PATH}`: {error}" + ), + })?; + if php_extension_catalog(&archive_extensions) != php_extension_catalog(&self.php_extensions) + { + return Err(crate::ReleaseError::InvalidArchive { + path: validation.archive_path().to_string(), + reason: format!( + "PHP extension metadata `{PHP_EXTENSION_METADATA_PATH}` does not match release record php_extensions" + ), + }); + } + Ok(()) } } +impl PhpExtensionRecord { + pub fn name(&self) -> &str { + &self.name + } + + pub fn load_kind(&self) -> &str { + &self.load_kind + } + + pub fn path(&self) -> &str { + &self.path + } +} + impl RevocationRecord { pub fn from_json(path: &Utf8Path, json: &str) -> crate::Result { let raw: RawRevocationRecord = serde_json::from_str(json) @@ -565,6 +650,82 @@ fn validate_relative_path(path: &Utf8Path, field: &str, value: &str) -> crate::R } } +fn validate_php_extensions( + path: &Utf8Path, + resource: &ResourceName, + extensions: &[PhpExtensionRecord], +) -> crate::Result<()> { + if !extensions.is_empty() && !resource_supports_php_extensions(resource) { + return Err(invalid_release( + path, + "php_extensions are only supported on php or frankenphp artifacts", + )); + } + + let mut names = BTreeSet::new(); + for extension in extensions { + validate_php_extension_name(path, &extension.name)?; + validate_php_extension_load_kind(path, &extension.load_kind)?; + validate_relative_path(path, "php_extensions.path", &extension.path)?; + if !names.insert(extension.name.as_str()) { + return Err(invalid_release( + path, + format!( + "php_extensions contains duplicate extension `{}`", + extension.name + ), + )); + } + } + + Ok(()) +} + +fn resource_supports_php_extensions(resource: &ResourceName) -> bool { + matches!(resource.as_str(), "php" | "frankenphp") +} + +fn php_extension_catalog(extensions: &[PhpExtensionRecord]) -> Vec<(String, String, String)> { + let mut catalog = extensions + .iter() + .map(|extension| { + ( + extension.name.clone(), + extension.load_kind.clone(), + extension.path.clone(), + ) + }) + .collect::>(); + catalog.sort(); + catalog +} + +fn validate_php_extension_name(path: &Utf8Path, name: &str) -> crate::Result<()> { + let valid = !name.is_empty() + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'); + if valid { + return Ok(()); + } + + Err(invalid_release( + path, + format!("php_extensions contains invalid extension `{name}`"), + )) +} + +fn validate_php_extension_load_kind(path: &Utf8Path, load_kind: &str) -> crate::Result<()> { + if matches!(load_kind, "extension" | "zend_extension") { + return Ok(()); + } + + Err(invalid_release( + path, + format!("php_extensions contains invalid load kind `{load_kind}`"), + )) +} + fn validate_object_key_layout(path: &Utf8Path, raw: &RawReleaseRecord) -> crate::Result<()> { let expected = format!( "resources/{}/{}/{}/{}/{}-{}-{}.tar.gz", diff --git a/crates/pv-release/src/record_writer.rs b/crates/pv-release/src/record_writer.rs index 51e6f46e..3dcd3a11 100644 --- a/crates/pv-release/src/record_writer.rs +++ b/crates/pv-release/src/record_writer.rs @@ -23,9 +23,17 @@ pub struct WriteReleaseRecordRequest { pub published_at: String, pub license_files: Vec, pub notice_files: Vec, + pub php_extensions: Vec, pub source_inputs: Vec, } +#[derive(Clone, Debug)] +pub struct PhpExtensionRecordRequest { + pub name: String, + pub load_kind: String, + pub path: String, +} + #[derive(Clone, Debug)] pub struct SourceInputRequest { pub name: String, @@ -48,9 +56,18 @@ struct ReleaseRecordJson<'a> { minimum_pv_version: &'a str, license_files: &'a [String], notice_files: &'a [String], + #[serde(skip_serializing_if = "Vec::is_empty")] + php_extensions: Vec>, provenance: ProvenanceJson<'a>, } +#[derive(Serialize)] +struct PhpExtensionRecordJson<'a> { + name: &'a str, + load_kind: &'a str, + path: &'a str, +} + #[derive(Serialize)] struct ProvenanceJson<'a> { source_url: &'a str, @@ -71,6 +88,15 @@ struct SourceInputJson<'a> { pub fn write_release_record(request: &WriteReleaseRecordRequest) -> crate::Result<()> { let (sha256, size) = digest_and_size(&request.archive)?; + let php_extensions = request + .php_extensions + .iter() + .map(|php_extension| PhpExtensionRecordJson { + name: &php_extension.name, + load_kind: &php_extension.load_kind, + path: &php_extension.path, + }) + .collect::>(); let source_inputs = request .source_inputs .iter() @@ -94,6 +120,7 @@ pub fn write_release_record(request: &WriteReleaseRecordRequest) -> crate::Resul minimum_pv_version: &request.minimum_pv_version, license_files: &request.license_files, notice_files: &request.notice_files, + php_extensions, provenance: ProvenanceJson { source_url: &request.source_url, source_sha256: &request.source_sha256, diff --git a/crates/pv-release/tests/archive_validation.rs b/crates/pv-release/tests/archive_validation.rs index bc1546b5..4d5b23e2 100644 --- a/crates/pv-release/tests/archive_validation.rs +++ b/crates/pv-release/tests/archive_validation.rs @@ -158,6 +158,138 @@ fn archive_validation_does_not_scan_archive_file_contents() -> Result<()> { Ok(()) } +#[test] +fn archive_validation_rejects_advertised_php_extension_modules_that_are_not_files() -> Result<()> { + let tempdir = tempdir()?; + let missing_module = tempdir.path().join("missing-module.tar.gz"); + write_archive( + &missing_module, + &[ + ("php-8.4.20-pv1/LICENSE", b"license" as &[u8]), + ("php-8.4.20-pv1/NOTICE", b"notice" as &[u8]), + ("php-8.4.20-pv1/bin/php", b"php" as &[u8]), + ], + )?; + let (missing_sha256, missing_size) = archive_digest_and_size(&missing_module)?; + let missing_record = tempdir.path().join("missing-module.json"); + write_record( + &missing_record, + &release_record_json_with_php_extensions(&missing_sha256, missing_size), + )?; + + let directory_module = tempdir.path().join("directory-module.tar.gz"); + write_mixed_archive( + &directory_module, + &[ + ("php-8.4.20-pv1/LICENSE", b"license" as &[u8]), + ("php-8.4.20-pv1/NOTICE", b"notice" as &[u8]), + ("php-8.4.20-pv1/bin/php", b"php" as &[u8]), + ], + &["php-8.4.20-pv1/lib/php/extensions/redis.so/"], + )?; + let (directory_sha256, directory_size) = archive_digest_and_size(&directory_module)?; + let directory_record = tempdir.path().join("directory-module.json"); + write_record( + &directory_record, + &release_record_json_with_php_extensions(&directory_sha256, directory_size), + )?; + + assert_debug_snapshot!(( + unit_validation_outcome( + validate_archive_for_record_file(&missing_module, &missing_record), + tempdir.path(), + ), + unit_validation_outcome( + validate_archive_for_record_file(&directory_module, &directory_record), + tempdir.path(), + ), + )); + + Ok(()) +} + +#[test] +fn archive_validation_rejects_php_extension_records_without_matching_artifact_metadata() +-> Result<()> { + let tempdir = tempdir()?; + let missing_metadata = tempdir.path().join("missing-extension-metadata.tar.gz"); + write_archive( + &missing_metadata, + &[ + ("php-8.4.20-pv1/LICENSE", b"license" as &[u8]), + ("php-8.4.20-pv1/NOTICE", b"notice" as &[u8]), + ("php-8.4.20-pv1/bin/php", b"php" as &[u8]), + ( + "php-8.4.20-pv1/lib/php/extensions/redis.so", + b"redis" as &[u8], + ), + ], + )?; + let (missing_sha256, missing_size) = archive_digest_and_size(&missing_metadata)?; + let missing_record = tempdir.path().join("missing-extension-metadata.json"); + write_record( + &missing_record, + &release_record_json_with_php_extensions(&missing_sha256, missing_size), + )?; + let mismatched_metadata = tempdir.path().join("mismatched-extension-metadata.tar.gz"); + write_archive( + &mismatched_metadata, + &[ + ("php-8.4.20-pv1/LICENSE", b"license" as &[u8]), + ("php-8.4.20-pv1/NOTICE", b"notice" as &[u8]), + ("php-8.4.20-pv1/bin/php", b"php" as &[u8]), + ( + "php-8.4.20-pv1/lib/php/extensions/redis.so", + b"redis" as &[u8], + ), + ( + "php-8.4.20-pv1/share/pv/php-extensions.json", + br#"[{"name":"xdebug","load_kind":"zend_extension","path":"lib/php/extensions/xdebug.so"}]"# + as &[u8], + ), + ], + )?; + let (mismatched_sha256, mismatched_size) = archive_digest_and_size(&mismatched_metadata)?; + let mismatched_record = tempdir.path().join("mismatched-extension-metadata.json"); + write_record( + &mismatched_record, + &release_record_json_with_php_extensions(&mismatched_sha256, mismatched_size), + )?; + + assert_debug_snapshot!( + ( + unit_validation_outcome( + validate_archive_for_record_file(&missing_metadata, &missing_record), + tempdir.path(), + ), + unit_validation_outcome( + validate_archive_for_record_file(&mismatched_metadata, &mismatched_record), + tempdir.path(), + ), + ), + @r###" + ( + Err( + ( + "InvalidArchive", + "missing-extension-metadata.tar.gz", + "missing PHP extension metadata `share/pv/php-extensions.json` for advertised PHP extensions", + ), + ), + Err( + ( + "InvalidArchive", + "mismatched-extension-metadata.tar.gz", + "PHP extension metadata `share/pv/php-extensions.json` does not match release record php_extensions", + ), + ), + ) + "### + ); + + Ok(()) +} + #[test] fn archive_validation_runs_smoke_hook_against_extracted_archive_root() -> Result<()> { let tempdir = tempdir()?; @@ -304,6 +436,41 @@ fn write_directory_archive(path: &Utf8Path, entries: &[&str]) -> Result<()> { Ok(()) } +#[expect( + clippy::disallowed_types, + reason = "release tooling tests create mixed fixture archives directly" +)] +fn write_mixed_archive( + path: &Utf8Path, + files: &[(&str, &[u8])], + directories: &[&str], +) -> Result<()> { + let file = std::fs::File::create(path)?; + let encoder = GzEncoder::new(file, Compression::default()); + let mut builder = Builder::new(encoder); + + for directory in directories { + let mut header = Header::new_gnu(); + header.set_size(0); + header.set_mode(0o755); + header.set_entry_type(EntryType::Directory); + header.set_cksum(); + builder.append_data(&mut header, directory, &[] as &[u8])?; + } + + for (path, content) in files { + let mut header = Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append_data(&mut header, path, *content)?; + } + + let encoder = builder.into_inner()?; + encoder.finish()?; + Ok(()) +} + #[expect( clippy::disallowed_types, reason = "release tooling tests create malformed fixture archives directly" @@ -420,3 +587,38 @@ fn release_record_json(sha256: &str, size: u64) -> String { }}"#, ) } + +fn release_record_json_with_php_extensions(sha256: &str, size: u64) -> String { + php_release_record_json(sha256, size).replacen( + " \"provenance\": {", + " \"php_extensions\": [\n {\n \"name\": \"redis\",\n \"load_kind\": \"extension\",\n \"path\": \"lib/php/extensions/redis.so\"\n }\n ],\n \"provenance\": {", + 1, + ) +} + +fn php_release_record_json(sha256: &str, size: u64) -> String { + format!( + r#"{{ + "resource": "php", + "track": "8.4", + "upstream_version": "8.4.20", + "pv_build_revision": "pv1", + "artifact_version": "8.4.20-pv1", + "platform": "darwin-arm64", + "object_key": "resources/php/8.4/8.4.20-pv1/darwin-arm64/php-8.4.20-pv1-darwin-arm64.tar.gz", + "sha256": "{sha256}", + "size": {size}, + "published_at": "2026-06-06T12:00:00Z", + "minimum_pv_version": "0.1.0", + "license_files": ["LICENSE"], + "notice_files": ["NOTICE"], + "provenance": {{ + "source_url": "https://www.php.net/distributions/php-8.4.20.tar.gz", + "source_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "recipe": "release/artifacts/recipes/php/build.sh", + "pv_commit": "0123456789abcdef0123456789abcdef01234567", + "build_run_id": "local-test" + }} +}}"#, + ) +} diff --git a/crates/pv-release/tests/manifest_generation.rs b/crates/pv-release/tests/manifest_generation.rs index c1a5a6fd..e986f353 100644 --- a/crates/pv-release/tests/manifest_generation.rs +++ b/crates/pv-release/tests/manifest_generation.rs @@ -72,6 +72,55 @@ fn manifest_generator_writes_versioned_manifest_file_locally() -> Result<()> { Ok(()) } +#[test] +fn manifest_generator_includes_php_extension_metadata() -> Result<()> { + let tempdir = tempdir()?; + let records_dir = tempdir.path().join("records"); + let revocations_dir = tempdir.path().join("revocations"); + let output = tempdir.path().join("dist/manifest.json"); + let php_with_extensions = PHP_8_4_20_ARM64.replacen( + "\"provenance\": {", + "\"php_extensions\": [\n {\n \"name\": \"redis\",\n \"load_kind\": \"extension\",\n \"path\": \"lib/php/extensions/redis.so\"\n },\n {\n \"name\": \"xdebug\",\n \"load_kind\": \"zend_extension\",\n \"path\": \"lib/php/extensions/xdebug.so\"\n }\n ],\n \"provenance\": {", + 1, + ); + + create_dir_all(&records_dir)?; + create_dir_all(&revocations_dir)?; + write_file( + &records_dir.join("php-8.4.20-pv1-darwin-arm64.json"), + &php_with_extensions, + )?; + + generate_manifest_file( + &records_dir, + &revocations_dir, + &output, + "https://artifacts.example.test", + )?; + let manifest_json = read_file(&output)?; + ArtifactManifest::parse(&manifest_json)?; + let manifest: Value = serde_json::from_str(&manifest_json)?; + let artifact = artifact_by_version(&manifest, "8.4.20-pv1")?; + + assert_eq!( + artifact.get("php_extensions"), + Some(&serde_json::json!([ + { + "name": "redis", + "load_kind": "extension", + "path": "lib/php/extensions/redis.so" + }, + { + "name": "xdebug", + "load_kind": "zend_extension", + "path": "lib/php/extensions/xdebug.so" + } + ])) + ); + + Ok(()) +} + #[test] fn manifest_generator_rejects_revocation_for_missing_artifact() -> Result<()> { let tempdir = tempdir()?; @@ -345,6 +394,29 @@ const REDIS_7_2_5_ARM64: &str = r#"{ } }"#; +const PHP_8_4_20_ARM64: &str = r#"{ + "resource": "php", + "track": "8.4", + "upstream_version": "8.4.20", + "pv_build_revision": "pv1", + "artifact_version": "8.4.20-pv1", + "platform": "darwin-arm64", + "object_key": "resources/php/8.4/8.4.20-pv1/darwin-arm64/php-8.4.20-pv1-darwin-arm64.tar.gz", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 42, + "published_at": "2026-06-06T12:00:00Z", + "minimum_pv_version": "0.1.0", + "license_files": ["LICENSE"], + "notice_files": ["NOTICE"], + "provenance": { + "source_url": "https://www.php.net/distributions/php-8.4.20.tar.gz", + "source_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "recipe": "release/artifacts/recipes/php/build.sh", + "pv_commit": "0123456789abcdef0123456789abcdef01234567", + "build_run_id": "local-test" + } +}"#; + const REDIS_7_2_6_ARM64: &str = r#"{ "resource": "redis", "track": "7.2", diff --git a/crates/pv-release/tests/recipe_metadata.rs b/crates/pv-release/tests/recipe_metadata.rs index 00d194eb..05e5c3cd 100644 --- a/crates/pv-release/tests/recipe_metadata.rs +++ b/crates/pv-release/tests/recipe_metadata.rs @@ -179,6 +179,22 @@ fn committed_recipe_metadata_parses() -> Result<()> { Ok(()) } +#[test] +fn php_recipe_splits_default_and_optional_extensions() -> Result<()> { + let tempdir = tempdir()?; + let php = write_php_recipe(&tempdir)?; + let env = php_recipe_env(&php, "php", "8.4", "darwin-arm64")?; + + assert!(env.contains("PV_DEFAULT_EXTENSIONS='bcmath,curl,intl,mbstring,openssl,pcntl,pdo_mysql,pdo_pgsql,pdo_sqlite,sockets,sodium,zip'")); + assert!(env.contains( + "PV_OPTIONAL_EXTENSIONS='redis,sqlsrv,pdo_sqlsrv,xdebug,apcu,pcov,imagick,mongodb,yaml'" + )); + assert!(env.contains("PV_EXPECTED_EXTENSIONS='bcmath,ctype,curl")); + assert!(!env.contains("PV_BUILD_EXTENSIONS=''")); + + Ok(()) +} + #[test] fn committed_redis_recipe_collects_current_notice_inputs() -> Result<()> { let workspace_root = Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); @@ -447,23 +463,35 @@ fn recipe_metadata_rejects_invalid_shapes() -> Result<()> { #[test] fn recipe_metadata_rejects_strict_php_metadata() -> Result<()> { - let invalid_deployment_target = VALID_PHP_TOML.replace( + let invalid_deployment_target = VALID_PHP_TOML.replacen( "deployment_target = \"13.0\"", "deployment_target = \"14.0\"", + 1, ); let php_version_without_patch = - VALID_PHP_TOML.replace("php_version = \"8.4.20\"", "php_version = \"8.4\""); + VALID_PHP_TOML.replacen("php_version = \"8.4.20\"", "php_version = \"8.4\"", 1); let unexpected_expected_extension = - VALID_PHP_TOML.replace("\"zlib\"]", "\"zlib\", \"xdebug\"]"); + VALID_PHP_TOML.replacen("\"zlib\"]", "\"zlib\", \"xdebug\"]", 1); + let duplicate_default_extension = VALID_PHP_TOML.replacen( + "\"bcmath\", \"curl\"", + "\"bcmath\", \"bcmath\", \"curl\"", + 1, + ); + let invalid_default_extension = + VALID_PHP_TOML.replacen("\"bcmath\", \"curl\"", "\"bad-extension\", \"curl\"", 1); + let overlapping_optional_extension = + VALID_PHP_TOML.replacen("\"redis\", \"sqlsrv\"", "\"bcmath\", \"sqlsrv\"", 1); let empty_license_files = - VALID_PHP_TOML.replace("license_files = [\"LICENSE\"]", "license_files = []"); - let unsafe_license_file = VALID_PHP_TOML.replace( + VALID_PHP_TOML.replacen("license_files = [\"LICENSE\"]", "license_files = []", 1); + let unsafe_license_file = VALID_PHP_TOML.replacen( "license_files = [\"LICENSE\"]", "license_files = [\"../LICENSE\"]", + 1, ); - let unsafe_notice_file = VALID_PHP_TOML.replace( + let unsafe_notice_file = VALID_PHP_TOML.replacen( "notice_files = [\"NOTICE\"]", "notice_files = [\"../NOTICE\"]", + 1, ); assert_debug_snapshot!(( @@ -479,6 +507,18 @@ fn recipe_metadata_rejects_strict_php_metadata() -> Result<()> { Utf8Path::new("unexpected-expected-extension.toml"), &unexpected_expected_extension, ), + PhpRecipe::from_toml( + Utf8Path::new("duplicate-default-extension.toml"), + &duplicate_default_extension, + ), + PhpRecipe::from_toml( + Utf8Path::new("invalid-default-extension.toml"), + &invalid_default_extension, + ), + PhpRecipe::from_toml( + Utf8Path::new("overlapping-optional-extension.toml"), + &overlapping_optional_extension, + ), PhpRecipe::from_toml( Utf8Path::new("empty-license-files.toml"), &empty_license_files @@ -750,8 +790,9 @@ fn assert_default_track( fn assert_php_staticphp_build_extensions(php: &PhpRecipe) { let actual = php - .build_extensions() + .default_extensions() .iter() + .chain(php.optional_extensions()) .map(String::as_str) .collect::>(); let required = [ @@ -777,6 +818,7 @@ fn assert_php_staticphp_build_extensions(php: &PhpRecipe) { "redis", "session", "simplexml", + "sockets", "sodium", "sqlite3", "sqlsrv", @@ -838,6 +880,12 @@ fn read_file(path: &Utf8Path) -> Result { Ok(std::fs::read_to_string(path)?) } +fn write_php_recipe(tempdir: &camino_tempfile::Utf8TempDir) -> Result { + let php = tempdir.path().join("tracks.toml"); + write_file(&php, VALID_PHP_TOML)?; + Ok(php) +} + const VALID_PHP_TOML: &str = r#" [recipe] resources = ["php", "frankenphp"] @@ -850,8 +898,9 @@ notice_files = ["NOTICE"] [php] deployment_target = "13.0" -build_extensions = ["bcmath", "curl", "intl", "mbstring", "openssl", "pcntl", "pdo_mysql", "pdo_pgsql", "pdo_sqlite", "pdo_sqlsrv", "redis", "sodium", "sqlsrv", "zip"] -expected_extensions = ["bcmath", "ctype", "curl", "dom", "fileinfo", "filter", "hash", "iconv", "intl", "json", "libxml", "mbstring", "openssl", "pcntl", "pcre", "pdo", "pdo_mysql", "pdo_pgsql", "pdo_sqlite", "pdo_sqlsrv", "phar", "posix", "redis", "session", "simplexml", "sodium", "sqlite3", "sqlsrv", "tokenizer", "xml", "xmlreader", "xmlwriter", "zip", "zlib"] +default_extensions = ["bcmath", "curl", "intl", "mbstring", "openssl", "pcntl", "pdo_mysql", "pdo_pgsql", "pdo_sqlite", "sockets", "sodium", "zip"] +optional_extensions = ["redis", "sqlsrv", "pdo_sqlsrv", "xdebug", "apcu", "pcov", "imagick", "mongodb", "yaml"] +expected_extensions = ["bcmath", "ctype", "curl", "dom", "fileinfo", "filter", "hash", "iconv", "intl", "json", "libxml", "mbstring", "openssl", "pcntl", "pcre", "pdo", "pdo_mysql", "pdo_pgsql", "pdo_sqlite", "phar", "posix", "session", "simplexml", "sockets", "sodium", "sqlite3", "tokenizer", "xml", "xmlreader", "xmlwriter", "zip", "zlib"] [frankenphp] version = "1.12.3" diff --git a/crates/pv-release/tests/record_writer.rs b/crates/pv-release/tests/record_writer.rs index 2fce16d7..fd49fd6c 100644 --- a/crates/pv-release/tests/record_writer.rs +++ b/crates/pv-release/tests/record_writer.rs @@ -4,7 +4,7 @@ use camino_tempfile::tempdir; use insta::assert_snapshot; use pv_release::record::ReleaseRecord; use pv_release::record_writer::{ - SourceInputRequest, WriteReleaseRecordRequest, write_release_record, + PhpExtensionRecordRequest, SourceInputRequest, WriteReleaseRecordRequest, write_release_record, }; use std::fs; @@ -39,6 +39,7 @@ fn release_record_writer_serializes_metadata_and_source_inputs() -> Result<()> { published_at: "2026-06-08T12:00:00Z".to_string(), license_files: vec!["LICENSE".to_string()], notice_files: vec!["NOTICE".to_string()], + php_extensions: Vec::new(), source_inputs: vec![SourceInputRequest { name: "composer".to_string(), source_url: @@ -58,6 +59,58 @@ fn release_record_writer_serializes_metadata_and_source_inputs() -> Result<()> { Ok(()) } +#[test] +fn release_record_writer_serializes_php_extension_metadata() -> Result<()> { + let tempdir = tempdir()?; + let archive = tempdir.path().join("php-8.4.20-pv1-darwin-arm64.tar.gz"); + let record = tempdir + .path() + .join("records/php/8.4/8.4.20-pv1/darwin-arm64/php-8.4.20-pv1-darwin-arm64.json"); + write_file(&archive, b"artifact bytes")?; + + write_release_record(&WriteReleaseRecordRequest { + record: record.clone(), + archive, + resource: "php".to_string(), + track: "8.4".to_string(), + upstream_version: "8.4.20".to_string(), + pv_build_revision: "pv1".to_string(), + platform: "darwin-arm64".to_string(), + object_key: "resources/php/8.4/8.4.20-pv1/darwin-arm64/php-8.4.20-pv1-darwin-arm64.tar.gz" + .to_string(), + source_url: "https://www.php.net/distributions/php-8.4.20.tar.gz".to_string(), + source_sha256: "a2def5d534d57c6a0236f2265de7537608af871900a4f7955eff463e9e38247d" + .to_string(), + recipe: "release/artifacts/recipes/php/build.sh".to_string(), + pv_commit: "0123456789abcdef0123456789abcdef01234567".to_string(), + build_run_id: "local-test".to_string(), + minimum_pv_version: "0.1.0".to_string(), + published_at: "2026-06-08T12:00:00Z".to_string(), + license_files: vec!["LICENSE".to_string()], + notice_files: vec!["NOTICE".to_string()], + php_extensions: vec![ + PhpExtensionRecordRequest { + name: "redis".to_string(), + load_kind: "extension".to_string(), + path: "lib/php/extensions/redis.so".to_string(), + }, + PhpExtensionRecordRequest { + name: "xdebug".to_string(), + load_kind: "zend_extension".to_string(), + path: "lib/php/extensions/xdebug.so".to_string(), + }, + ], + source_inputs: Vec::new(), + })?; + + let json = read_to_string(&record)?; + let parsed = ReleaseRecord::from_json(&record, &json)?; + + assert_eq!(parsed.php_extensions().len(), 2); + assert_snapshot!(json); + Ok(()) +} + #[test] fn release_record_writer_serializes_custom_legal_files() -> Result<()> { let tempdir = tempdir()?; @@ -88,6 +141,7 @@ fn release_record_writer_serializes_custom_legal_files() -> Result<()> { published_at: "2026-06-08T12:00:00Z".to_string(), license_files: vec!["LICENSE".to_string()], notice_files: vec!["NOTICE".to_string(), "THIRD-PARTY-NOTICES".to_string()], + php_extensions: Vec::new(), source_inputs: Vec::new(), })?; diff --git a/crates/pv-release/tests/release_records.rs b/crates/pv-release/tests/release_records.rs index c9eb8b1d..e112b0eb 100644 --- a/crates/pv-release/tests/release_records.rs +++ b/crates/pv-release/tests/release_records.rs @@ -112,6 +112,79 @@ fn release_records_parse_additional_source_inputs() -> Result<()> { Ok(()) } +#[test] +fn release_records_parse_php_extension_metadata() -> Result<()> { + let record_with_php_extensions = FRANKENPHP_RELEASE_RECORD_WITH_SOURCE_INPUTS.replacen( + "\"provenance\": {", + "\"php_extensions\": [\n {\n \"name\": \"redis\",\n \"load_kind\": \"extension\",\n \"path\": \"lib/php/extensions/redis.so\"\n },\n {\n \"name\": \"xdebug\",\n \"load_kind\": \"zend_extension\",\n \"path\": \"lib/php/extensions/xdebug.so\"\n }\n ],\n \"provenance\": {", + 1, + ); + + let record = ReleaseRecord::from_json( + Utf8Path::new("frankenphp-with-extensions.json"), + &record_with_php_extensions, + )?; + + assert_debug_snapshot!(record); + + Ok(()) +} + +#[test] +fn release_records_reject_invalid_php_extension_metadata() -> Result<()> { + let valid = release_record_with_php_extensions(); + let cases = [ + ( + "empty_name", + valid.replace("\"name\": \"redis\"", "\"name\": \"\""), + ), + ( + "invalid_load_kind", + valid.replace("\"load_kind\": \"extension\"", "\"load_kind\": \"module\""), + ), + ( + "empty_path", + valid.replace( + "\"path\": \"lib/php/extensions/redis.so\"", + "\"path\": \"\"", + ), + ), + ( + "current_dir_path", + valid.replace( + "\"path\": \"lib/php/extensions/redis.so\"", + "\"path\": \".\"", + ), + ), + ( + "parent_path", + valid.replace( + "\"path\": \"lib/php/extensions/redis.so\"", + "\"path\": \"../redis.so\"", + ), + ), + ( + "duplicate_name", + valid.replace("\"name\": \"xdebug\"", "\"name\": \"redis\""), + ), + ] + .into_iter() + .map(|(name, json)| { + Ok(( + name, + release_record_error(ReleaseRecord::from_json( + Utf8Path::new("invalid-php-extension.json"), + &json, + ))?, + )) + }) + .collect::>>()?; + + assert_debug_snapshot!(cases); + + Ok(()) +} + #[test] fn release_records_reject_invalid_source_inputs() -> Result<()> { let invalid_name = FRANKENPHP_RELEASE_RECORD_WITH_SOURCE_INPUTS @@ -134,6 +207,34 @@ fn release_records_reject_invalid_source_inputs() -> Result<()> { Ok(()) } +#[test] +fn release_records_reject_php_extensions_for_non_php_resources() -> Result<()> { + let invalid = VALID_RELEASE_RECORD.replacen( + "\"provenance\": {", + "\"php_extensions\": [\n {\n \"name\": \"redis\",\n \"load_kind\": \"extension\",\n \"path\": \"lib/php/extensions/redis.so\"\n }\n ],\n \"provenance\": {", + 1, + ); + let error = release_record_error(ReleaseRecord::from_json( + Utf8Path::new("redis.json"), + &invalid, + ))?; + + assert!( + matches!(error, ReleaseError::InvalidReleaseRecord { ref reason, .. } if reason.contains("php_extensions are only supported on php or frankenphp artifacts")), + "non-PHP php_extensions should be rejected, got {error:?}", + ); + + Ok(()) +} + +fn release_record_with_php_extensions() -> String { + FRANKENPHP_RELEASE_RECORD_WITH_SOURCE_INPUTS.replacen( + "\"provenance\": {", + "\"php_extensions\": [\n {\n \"name\": \"redis\",\n \"load_kind\": \"extension\",\n \"path\": \"lib/php/extensions/redis.so\"\n },\n {\n \"name\": \"xdebug\",\n \"load_kind\": \"zend_extension\",\n \"path\": \"lib/php/extensions/xdebug.so\"\n }\n ],\n \"provenance\": {", + 1, + ) +} + #[test] fn release_records_reject_unknown_metadata_fields() -> Result<()> { let unknown_release_field = diff --git a/crates/pv-release/tests/smoke.rs b/crates/pv-release/tests/smoke.rs index 0ccfb57b..556a6ada 100644 --- a/crates/pv-release/tests/smoke.rs +++ b/crates/pv-release/tests/smoke.rs @@ -181,6 +181,96 @@ exit 28 Ok(()) } +#[test] +fn php_smoke_requires_frankenphp_optional_metadata_names_to_load() -> Result<()> { + let tempdir = tempdir()?; + let artifact_root = tempdir.path().join("artifact"); + let artifact_bin = artifact_root.join("bin"); + let command_bin = tempdir.path().join("commands"); + let metadata_dir = artifact_root.join("share/pv"); + let extension_dir = artifact_root.join("lib/php/extensions"); + let frankenphp_log = tempdir.path().join("frankenphp.log"); + + create_dir_all(&artifact_bin)?; + create_dir_all(&command_bin)?; + create_dir_all(&metadata_dir)?; + create_dir_all(&extension_dir)?; + write_file(&extension_dir.join("redis.so"), "redis module\n")?; + write_file( + &metadata_dir.join("php-extensions.json"), + r#"[ + { + "name": "redis", + "load_kind": "extension", + "path": "lib/php/extensions/redis.so" + } +] +"#, + )?; + write_file(&frankenphp_log, "")?; + write_executable( + &artifact_bin.join("frankenphp"), + r#"#!/bin/sh +set -eu +case "${1:-}" in + php-cli) + [ "${2:-}" = "-r" ] || exit 99 + code=${3:-} + if [ "$code" = 'printf("PHP %s\n", PHP_VERSION);' ]; then + printf '%s\n' 'PHP 8.4.20' + elif [ "$code" = 'foreach (get_loaded_extensions() as $extension) { echo $extension, PHP_EOL; }' ]; then + printf '%s\n' 'json' + else + exit 99 + fi + ;; + php-server) + printf '%s\n' 'php-server' >>"$PV_FRANKENPHP_LOG" + exec sleep 60 + ;; + *) exit 99 ;; +esac +"#, + )?; + write_executable( + &command_bin.join("curl"), + r#"#!/bin/sh +set -eu +if grep -F 'php-server' "$PV_FRANKENPHP_LOG" >/dev/null; then + printf '%s\n' 'pv-frankenphp-ok' + printf '%s\n' 'Configuration File (php.ini) Path => /var/empty/com.prvious.pv/php' + exit 0 +fi +exit 28 +"#, + )?; + + let output = StdCommand::new(php_smoke_hook()) + .arg(&artifact_root) + .env( + "PATH", + format!("{command_bin}:/usr/bin:/bin:/usr/sbin:/sbin"), + ) + .env("PV_EXPECTED_EXTENSIONS", "json") + .env("PV_FRANKENPHP_LOG", &frankenphp_log) + .env("PV_UPSTREAM_VERSION", "8.4.20-frankenphp1.12.3") + .output()?; + + assert!( + !output.status.success(), + "smoke hook should require FrankenPHP metadata extension names: {}", + command_output_debug(&output) + ); + assert_eq!(output.status.code(), Some(43)); + assert!( + String::from_utf8_lossy(&output.stderr).contains("missing PHP extension: redis"), + "smoke hook should report the missing metadata extension: {}", + command_output_debug(&output) + ); + + Ok(()) +} + #[test] fn php_smoke_normalizes_realistic_module_output() -> Result<()> { let tempdir = tempdir()?; @@ -268,6 +358,147 @@ esac Ok(()) } +#[test] +fn php_smoke_loads_optional_extensions_from_metadata() -> Result<()> { + let tempdir = tempdir()?; + let artifact_root = tempdir.path().join("artifact"); + let artifact_bin = artifact_root.join("bin"); + let metadata_dir = artifact_root.join("share/pv"); + let extension_dir = artifact_root.join("lib/php/extensions"); + let smoke_log = tempdir.path().join("php-smoke.log"); + + create_dir_all(&artifact_bin)?; + create_dir_all(&metadata_dir)?; + create_dir_all(&extension_dir)?; + write_file(&extension_dir.join("redis.so"), "redis module\n")?; + write_file(&extension_dir.join("xdebug.so"), "xdebug module\n")?; + write_file( + &metadata_dir.join("php-extensions.json"), + r#"[ + { + "name": "redis", + "load_kind": "extension", + "path": "lib/php/extensions/redis.so" + }, + { + "name": "xdebug", + "load_kind": "zend_extension", + "path": "lib/php/extensions/xdebug.so" + } +] +"#, + )?; + write_file(&smoke_log, "")?; + write_executable( + &artifact_bin.join("php"), + r#"#!/bin/sh +set -eu +case "$1" in + -v) + printf '%s\n' 'PHP 8.4.20 (cli)' + ;; + --ini) + [ -z "${PHP_INI_SCAN_DIR:-}" ] || exit 70 + printf '%s\n' 'Configuration File (php.ini) Path: /var/empty/com.prvious.pv/php' + ;; + -m) + if [ -n "${PHP_INI_SCAN_DIR:-}" ] \ + && grep -R -F "extension=$PV_TEST_ARTIFACT_ROOT/lib/php/extensions/redis.so" "$PHP_INI_SCAN_DIR" >/dev/null \ + && grep -R -F "zend_extension=$PV_TEST_ARTIFACT_ROOT/lib/php/extensions/xdebug.so" "$PHP_INI_SCAN_DIR" >/dev/null; then + printf '%s\n' optional >>"$PV_TEST_PHP_SMOKE_LOG" + printf '%s\n' 'json' 'redis' 'xdebug' + else + printf '%s\n' default >>"$PV_TEST_PHP_SMOKE_LOG" + printf '%s\n' 'json' + fi + ;; + *) exit 99 ;; +esac +"#, + )?; + + let smoke_hook = php_smoke_hook(); + let output = StdCommand::new(smoke_hook) + .arg(&artifact_root) + .env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin") + .env("PV_EXPECTED_EXTENSIONS", "json") + .env("PV_TEST_ARTIFACT_ROOT", &artifact_root) + .env("PV_TEST_PHP_SMOKE_LOG", &smoke_log) + .env("PV_UPSTREAM_VERSION", "8.4.20") + .output()?; + + assert!( + output.status.success(), + "smoke hook failed: {}", + command_output_debug(&output) + ); + let smoke_log = read_file(&smoke_log)?; + assert!( + smoke_log.contains("optional\n"), + "smoke hook should check optional extension metadata: {smoke_log}" + ); + + Ok(()) +} + +#[test] +fn php_smoke_rejects_optional_metadata_names_that_do_not_load() -> Result<()> { + let tempdir = tempdir()?; + let artifact_root = tempdir.path().join("artifact"); + let artifact_bin = artifact_root.join("bin"); + let metadata_dir = artifact_root.join("share/pv"); + let extension_dir = artifact_root.join("lib/php/extensions"); + + create_dir_all(&artifact_bin)?; + create_dir_all(&metadata_dir)?; + create_dir_all(&extension_dir)?; + write_file(&extension_dir.join("redis.so"), "redis module\n")?; + write_file( + &metadata_dir.join("php-extensions.json"), + r#"[ + { + "name": "redis", + "load_kind": "extension", + "path": "lib/php/extensions/redis.so" + } +] +"#, + )?; + write_executable( + &artifact_bin.join("php"), + r#"#!/bin/sh +set -eu +case "$1" in + -v) printf '%s\n' 'PHP 8.4.20 (cli)' ;; + --ini) printf '%s\n' 'Configuration File (php.ini) Path: /var/empty/com.prvious.pv/php' ;; + -m) printf '%s\n' 'json' ;; + *) exit 99 ;; +esac +"#, + )?; + + let output = StdCommand::new(php_smoke_hook()) + .arg(&artifact_root) + .env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin") + .env("PV_EXPECTED_EXTENSIONS", "json") + .env("PV_UPSTREAM_VERSION", "8.4.20") + .output()?; + + assert!( + !output.status.success(), + "smoke hook should require metadata extension names: {}", + command_output_debug(&output) + ); + assert_eq!(output.status.code(), Some(43)); + assert!( + String::from_utf8_lossy(&output.stderr).contains("missing PHP extension: redis"), + "smoke hook should report the missing metadata extension: {}", + command_output_debug(&output) + ); + + Ok(()) +} + #[test] fn php_smoke_rejects_usr_local_ini_path_from_php_ini_output() -> Result<()> { let tempdir = tempdir()?; @@ -436,7 +667,7 @@ fn php_build_recipe_smoke() -> Result<()> { ); let expected_log = format!( "pwd={}/work/php-pair-8.4-darwin-arm64/staticphp\n\ -argv=[build:php][json][--build-cli][--build-frankenphp][--enable-zts][--with-config-file-path=/var/empty/com.prvious.pv/php][--with-config-file-scan-dir=/var/empty/com.prvious.pv/php/conf.d][--dl-with-php=8.4.20][--dl-retry=3][--dl-custom-local][php-src:{php_source_dir}][--dl-custom-local][frankenphp:{frankenphp_source_dir}]\n", +argv=[build:php][json,redis,xdebug][--build-shared=redis,xdebug][--build-cli][--build-frankenphp][--enable-zts][--with-config-file-path=/var/empty/com.prvious.pv/php][--with-config-file-scan-dir=/var/empty/com.prvious.pv/php/conf.d][--dl-with-php=8.4.20][--dl-retry=3][--dl-custom-local][php-src:{php_source_dir}][--dl-custom-local][frankenphp:{frankenphp_source_dir}]\n", run.out_dir ); @@ -1607,6 +1838,74 @@ fn php_pair_build_smoke_removes_unmanaged_frankenphp_rpath_before_validation() - Ok(()) } +#[test] +fn php_pair_build_smoke_removes_unmanaged_optional_extension_rpath_before_validation() -> Result<()> +{ + let run = run_php_build_recipe_smoke_with_options(BuildRecipeOptions { + extension_macho_rpaths: "@loader_path/../lib\n/usr/local/lib", + ..default_build_recipe_options() + })?; + + assert!( + run.output.status.success(), + "build recipe failed: {}", + command_output_debug(&run.output) + ); + let removed_rpaths_log = run.removed_rpaths_log.replace(&run.out_dir, ""); + assert!( + removed_rpaths_log + .contains("/work/php-pair-8.4-darwin-arm64/php-8.4.20-pv1-darwin-arm64/lib/php/extensions/redis.so|/usr/local/lib"), + "PHP optional redis module should have its stale rpath deleted: {removed_rpaths_log}" + ); + assert!( + removed_rpaths_log + .contains("/work/php-pair-8.4-darwin-arm64/php-8.4.20-pv1-darwin-arm64/lib/php/extensions/xdebug.so|/usr/local/lib"), + "PHP optional xdebug module should have its stale rpath deleted: {removed_rpaths_log}" + ); + assert!( + removed_rpaths_log + .contains("/work/php-pair-8.4-darwin-arm64/frankenphp-8.4.20-frankenphp1.12.3-pv1-darwin-arm64/lib/php/extensions/redis.so|/usr/local/lib"), + "FrankenPHP optional redis module should have its stale rpath deleted: {removed_rpaths_log}" + ); + assert!( + removed_rpaths_log + .contains("/work/php-pair-8.4-darwin-arm64/frankenphp-8.4.20-frankenphp1.12.3-pv1-darwin-arm64/lib/php/extensions/xdebug.so|/usr/local/lib"), + "FrankenPHP optional xdebug module should have its stale rpath deleted: {removed_rpaths_log}" + ); + + Ok(()) +} + +#[test] +fn php_pair_build_smoke_rejects_unmanaged_optional_extension_library() -> Result<()> { + let run = run_php_build_recipe_smoke_with_options(BuildRecipeOptions { + extension_macho_libraries: "\t/usr/local/lib/libfoo.dylib (compatibility version 1.0.0, current version 1.0.0)", + ..default_build_recipe_options() + })?; + + assert!( + !run.output.status.success(), + "build recipe unexpectedly succeeded: {}", + command_output_debug(&run.output) + ); + assert_eq!( + run.validate_log, "", + "archive validation should not run before optional extension Mach-O validation succeeds" + ); + assert_debug_snapshot!(build_recipe_output_summary(&run), @r#" + ( + false, + Some( + 1, + ), + "", + "error: /work/php-pair-8.4-darwin-arm64/php-8.4.20-pv1-darwin-arm64/lib/php/extensions/redis.so Mach-O linked library references unmanaged runtime path /usr/local/lib/libfoo.dylib\n", + ) + "#); + + Ok(()) +} + #[test] fn php_build_smoke_accepts_system_and_relative_macho_runtime_metadata() -> Result<()> { let run = run_php_build_recipe_smoke_with_options(BuildRecipeOptions { @@ -1639,6 +1938,7 @@ struct BuildRecipeRun { curl_log: String, validate_log: String, deleted_rpath_log: String, + removed_rpaths_log: String, php_archive_exists: bool, frankenphp_archive_exists: bool, } @@ -1726,6 +2026,8 @@ struct BuildRecipeOptions<'a> { macho_minos: &'a str, macho_libraries: &'a str, macho_rpaths: &'a str, + extension_macho_libraries: &'a str, + extension_macho_rpaths: &'a str, frankenphp_macho_libraries: &'a str, frankenphp_macho_rpaths: &'a str, validate_archive_failure_resource: &'a str, @@ -2330,6 +2632,8 @@ fn default_build_recipe_options() -> BuildRecipeOptions<'static> { macho_minos: "13.0", macho_libraries: "", macho_rpaths: "", + extension_macho_libraries: "", + extension_macho_rpaths: "", frankenphp_macho_libraries: "", frankenphp_macho_rpaths: "", validate_archive_failure_resource: "", @@ -2397,6 +2701,14 @@ fn run_php_build_recipe_smoke_with_options( .env("PV_TEST_MACHO_LIBRARIES", options.macho_libraries) .env("PV_TEST_MACHO_MINOS", options.macho_minos) .env("PV_TEST_MACHO_RPATHS", options.macho_rpaths) + .env( + "PV_TEST_EXTENSION_MACHO_LIBRARIES", + options.extension_macho_libraries, + ) + .env( + "PV_TEST_EXTENSION_MACHO_RPATHS", + options.extension_macho_rpaths, + ) .env("PV_TEST_CURL_LOG", &curl_log) .env("PV_TEST_DELETED_RPATH_LOG", &deleted_rpath_log) .env("PV_TEST_INSTALL_NAME_LOG", &install_name_log) @@ -2479,6 +2791,7 @@ fn run_php_build_recipe_smoke_with_options( curl_log: read_file(&curl_log)?, validate_log: read_file(&validate_log)?, deleted_rpath_log: read_file(&deleted_rpath_log)?, + removed_rpaths_log: read_file(&removed_rpaths_log)?, php_archive_exists: path_exists(&php_archive), frankenphp_archive_exists: path_exists(&frankenphp_archive), }) @@ -2583,7 +2896,9 @@ PV_SOURCE_URL=$source_url PV_SOURCE_SHA256=$source_sha256 $php_source_env PV_EXPECTED_EXTENSIONS=json -PV_BUILD_EXTENSIONS=json +PV_DEFAULT_EXTENSIONS=json +PV_OPTIONAL_EXTENSIONS=redis,xdebug +PV_BUILD_EXTENSIONS=json,redis,xdebug PV_DEPLOYMENT_TARGET=13.0 PV_PV_BUILD_REVISION=pv1 PV_MINIMUM_PV_VERSION=0.1.0 @@ -2599,6 +2914,8 @@ EOF build_run_id= source_inputs_json= source_input_count=0 + php_extensions_json= + php_extension_count=0 while [ "$#" -gt 0 ]; do case "$1" in --record) @@ -2629,6 +2946,22 @@ EOF shift build_run_id=${1:-} ;; + --php-extension) + shift + extension_name=${1:-} + shift + extension_load_kind=${1:-} + shift + extension_path=${1:-} + extension_json=" {\"name\": \"$extension_name\", \"load_kind\": \"$extension_load_kind\", \"path\": \"$extension_path\"}" + if [ "$php_extension_count" -eq 0 ]; then + php_extensions_json=$extension_json + else + php_extensions_json="$php_extensions_json, +$extension_json" + fi + php_extension_count=$((php_extension_count + 1)) + ;; --source-input) shift input_name=${1:-} @@ -2650,7 +2983,11 @@ $input_json" done mkdir -p "$(dirname "$record")" { - printf '{\n "object_key": "%s",\n "provenance": {\n' "$object_key" + printf '{\n "object_key": "%s",\n' "$object_key" + if [ "$php_extension_count" -gt 0 ]; then + printf ' "php_extensions": [\n%s\n ],\n' "$php_extensions_json" + fi + printf ' "provenance": {\n' printf ' "source_url": "%s",\n' "$source_url" printf ' "source_sha256": "%s",\n' "$source_sha256" if [ "$source_input_count" -gt 0 ]; then @@ -3429,7 +3766,17 @@ fn write_fake_lipo(path: &Utf8Path) -> Result<()> { set -eu [ "${1:-}" = "-archs" ] || exit 78 -printf '%s\n' "$PV_TEST_LIPO_ARCHS" +binary=${2:-} +archs=$PV_TEST_LIPO_ARCHS +case "$binary" in + */lib/php/extensions/*.so) + archs=${PV_TEST_EXTENSION_LIPO_ARCHS:-arm64} + ;; + */bin/frankenphp) + archs=${PV_TEST_FRANKENPHP_LIPO_ARCHS:-arm64} + ;; +esac +printf '%s\n' "$archs" "#, ) } @@ -3442,11 +3789,26 @@ set -eu binary=${2:-} macho_libraries=${PV_TEST_MACHO_LIBRARIES:-} +macho_minos=${PV_TEST_MACHO_MINOS:-} macho_rpaths=${PV_TEST_MACHO_RPATHS:-} case "$binary" in + */lib/php/extensions/*.so) + macho_minos=${PV_TEST_EXTENSION_MACHO_MINOS:-13.0} + if [ "${PV_TEST_EXTENSION_MACHO_LIBRARIES+x}" = x ]; then + macho_libraries=$PV_TEST_EXTENSION_MACHO_LIBRARIES + fi + if [ "${PV_TEST_EXTENSION_MACHO_RPATHS+x}" = x ]; then + macho_rpaths=$PV_TEST_EXTENSION_MACHO_RPATHS + fi + ;; */bin/frankenphp) - macho_libraries=${PV_TEST_FRANKENPHP_MACHO_LIBRARIES:-$macho_libraries} - macho_rpaths=${PV_TEST_FRANKENPHP_MACHO_RPATHS:-$macho_rpaths} + macho_minos=${PV_TEST_FRANKENPHP_MACHO_MINOS:-13.0} + if [ "${PV_TEST_FRANKENPHP_MACHO_LIBRARIES+x}" = x ]; then + macho_libraries=$PV_TEST_FRANKENPHP_MACHO_LIBRARIES + fi + if [ "${PV_TEST_FRANKENPHP_MACHO_RPATHS+x}" = x ]; then + macho_rpaths=$PV_TEST_FRANKENPHP_MACHO_RPATHS + fi ;; esac @@ -3463,7 +3825,7 @@ Load command 1 cmd LC_BUILD_VERSION cmdsize 32 platform MACOS - minos $PV_TEST_MACHO_MINOS + minos $macho_minos sdk 15.0 EOF if [ -n "$macho_rpaths" ]; then @@ -3889,6 +4251,20 @@ esac printf '%s\n' 'missing StaticPHP build target flag' >&2 exit 78 } +for arg in "$@"; do + case "$arg" in + --build-shared=*) + mkdir -p buildroot/lib/php/extensions + old_ifs=$IFS + IFS=, + for extension in ${arg#--build-shared=}; do + [ -n "$extension" ] || continue + printf '%s\n' "$extension module" >"buildroot/lib/php/extensions/$extension.so" + done + IFS=$old_ifs + ;; + esac +done "#, ) } diff --git a/crates/pv-release/tests/snapshots/archive_validation__archive_validation_rejects_advertised_php_extension_modules_that_are_not_files.snap b/crates/pv-release/tests/snapshots/archive_validation__archive_validation_rejects_advertised_php_extension_modules_that_are_not_files.snap new file mode 100644 index 00000000..850fb551 --- /dev/null +++ b/crates/pv-release/tests/snapshots/archive_validation__archive_validation_rejects_advertised_php_extension_modules_that_are_not_files.snap @@ -0,0 +1,20 @@ +--- +source: crates/pv-release/tests/archive_validation.rs +expression: "(unit_validation_outcome(validate_archive_for_record_file(&missing_module,\n&missing_record), tempdir.path(),),\nunit_validation_outcome(validate_archive_for_record_file(&directory_module,\n&directory_record), tempdir.path(),),)" +--- +( + Err( + ( + "InvalidArchive", + "missing-module.tar.gz", + "missing advertised PHP extension module `lib/php/extensions/redis.so`", + ), + ), + Err( + ( + "InvalidArchive", + "directory-module.tar.gz", + "missing advertised PHP extension module `lib/php/extensions/redis.so`", + ), + ), +) diff --git a/crates/pv-release/tests/snapshots/recipe_metadata__print_recipe_env_frankenphp.snap b/crates/pv-release/tests/snapshots/recipe_metadata__print_recipe_env_frankenphp.snap index 37c43d4f..eb30eb0e 100644 --- a/crates/pv-release/tests/snapshots/recipe_metadata__print_recipe_env_frankenphp.snap +++ b/crates/pv-release/tests/snapshots/recipe_metadata__print_recipe_env_frankenphp.snap @@ -13,7 +13,9 @@ PV_SOURCE_SHA256='2996fb95bbdf8410847fdcd59df04cd2e297568f6472ebe488af5fb5f3c793 PV_PHP_SOURCE_URL='https://www.php.net/distributions/php-8.4.20.tar.gz' PV_PHP_SOURCE_SHA256='a2def5d534d57c6a0236f2265de7537608af871900a4f7955eff463e9e38247d' PV_DEPLOYMENT_TARGET='13.0' -PV_BUILD_EXTENSIONS='bcmath,curl,intl,mbstring,openssl,pcntl,pdo_mysql,pdo_pgsql,pdo_sqlite,pdo_sqlsrv,redis,sodium,sqlsrv,zip' -PV_EXPECTED_EXTENSIONS='bcmath,ctype,curl,dom,fileinfo,filter,hash,iconv,intl,json,libxml,mbstring,openssl,pcntl,pcre,pdo,pdo_mysql,pdo_pgsql,pdo_sqlite,pdo_sqlsrv,phar,posix,redis,session,simplexml,sodium,sqlite3,sqlsrv,tokenizer,xml,xmlreader,xmlwriter,zip,zlib' +PV_DEFAULT_EXTENSIONS='bcmath,curl,intl,mbstring,openssl,pcntl,pdo_mysql,pdo_pgsql,pdo_sqlite,sockets,sodium,zip' +PV_OPTIONAL_EXTENSIONS='redis,sqlsrv,pdo_sqlsrv,xdebug,apcu,pcov,imagick,mongodb,yaml' +PV_BUILD_EXTENSIONS='bcmath,curl,intl,mbstring,openssl,pcntl,pdo_mysql,pdo_pgsql,pdo_sqlite,sockets,sodium,zip,redis,sqlsrv,pdo_sqlsrv,xdebug,apcu,pcov,imagick,mongodb,yaml' +PV_EXPECTED_EXTENSIONS='bcmath,ctype,curl,dom,fileinfo,filter,hash,iconv,intl,json,libxml,mbstring,openssl,pcntl,pcre,pdo,pdo_mysql,pdo_pgsql,pdo_sqlite,phar,posix,session,simplexml,sockets,sodium,sqlite3,tokenizer,xml,xmlreader,xmlwriter,zip,zlib' PV_MINIMUM_PV_VERSION='0.1.0' PV_PV_BUILD_REVISION='pv2' diff --git a/crates/pv-release/tests/snapshots/recipe_metadata__print_recipe_env_php.snap b/crates/pv-release/tests/snapshots/recipe_metadata__print_recipe_env_php.snap index a20ad500..805bf129 100644 --- a/crates/pv-release/tests/snapshots/recipe_metadata__print_recipe_env_php.snap +++ b/crates/pv-release/tests/snapshots/recipe_metadata__print_recipe_env_php.snap @@ -11,7 +11,9 @@ PV_ARTIFACT_VERSION='8.4.20-pv2' PV_SOURCE_URL='https://www.php.net/distributions/php-8.4.20.tar.gz' PV_SOURCE_SHA256='a2def5d534d57c6a0236f2265de7537608af871900a4f7955eff463e9e38247d' PV_DEPLOYMENT_TARGET='13.0' -PV_BUILD_EXTENSIONS='bcmath,curl,intl,mbstring,openssl,pcntl,pdo_mysql,pdo_pgsql,pdo_sqlite,pdo_sqlsrv,redis,sodium,sqlsrv,zip' -PV_EXPECTED_EXTENSIONS='bcmath,ctype,curl,dom,fileinfo,filter,hash,iconv,intl,json,libxml,mbstring,openssl,pcntl,pcre,pdo,pdo_mysql,pdo_pgsql,pdo_sqlite,pdo_sqlsrv,phar,posix,redis,session,simplexml,sodium,sqlite3,sqlsrv,tokenizer,xml,xmlreader,xmlwriter,zip,zlib' +PV_DEFAULT_EXTENSIONS='bcmath,curl,intl,mbstring,openssl,pcntl,pdo_mysql,pdo_pgsql,pdo_sqlite,sockets,sodium,zip' +PV_OPTIONAL_EXTENSIONS='redis,sqlsrv,pdo_sqlsrv,xdebug,apcu,pcov,imagick,mongodb,yaml' +PV_BUILD_EXTENSIONS='bcmath,curl,intl,mbstring,openssl,pcntl,pdo_mysql,pdo_pgsql,pdo_sqlite,sockets,sodium,zip,redis,sqlsrv,pdo_sqlsrv,xdebug,apcu,pcov,imagick,mongodb,yaml' +PV_EXPECTED_EXTENSIONS='bcmath,ctype,curl,dom,fileinfo,filter,hash,iconv,intl,json,libxml,mbstring,openssl,pcntl,pcre,pdo,pdo_mysql,pdo_pgsql,pdo_sqlite,phar,posix,session,simplexml,sockets,sodium,sqlite3,tokenizer,xml,xmlreader,xmlwriter,zip,zlib' PV_MINIMUM_PV_VERSION='0.1.0' PV_PV_BUILD_REVISION='pv2' diff --git a/crates/pv-release/tests/snapshots/recipe_metadata__recipe_metadata_rejects_strict_php_metadata.snap b/crates/pv-release/tests/snapshots/recipe_metadata__recipe_metadata_rejects_strict_php_metadata.snap index 8131ea5a..689401aa 100644 --- a/crates/pv-release/tests/snapshots/recipe_metadata__recipe_metadata_rejects_strict_php_metadata.snap +++ b/crates/pv-release/tests/snapshots/recipe_metadata__recipe_metadata_rejects_strict_php_metadata.snap @@ -1,6 +1,7 @@ --- source: crates/pv-release/tests/recipe_metadata.rs -expression: "(PhpRecipe::from_toml(Utf8Path::new(\"invalid-deployment-target.toml\"),\n&invalid_deployment_target,),\nPhpRecipe::from_toml(Utf8Path::new(\"php-version-without-patch.toml\"),\n&php_version_without_patch,),\nPhpRecipe::from_toml(Utf8Path::new(\"unexpected-expected-extension.toml\"),\n&unexpected_expected_extension,),\nPhpRecipe::from_toml(Utf8Path::new(\"empty-license-files.toml\"),\n&empty_license_files),\nPhpRecipe::from_toml(Utf8Path::new(\"unsafe-license-file.toml\"),\n&unsafe_license_file),\nPhpRecipe::from_toml(Utf8Path::new(\"unsafe-notice-file.toml\"),\n&unsafe_notice_file),)" +assertion_line: 493 +expression: "(PhpRecipe::from_toml(Utf8Path::new(\"invalid-deployment-target.toml\"),\n&invalid_deployment_target,),\nPhpRecipe::from_toml(Utf8Path::new(\"php-version-without-patch.toml\"),\n&php_version_without_patch,),\nPhpRecipe::from_toml(Utf8Path::new(\"unexpected-expected-extension.toml\"),\n&unexpected_expected_extension,),\nPhpRecipe::from_toml(Utf8Path::new(\"duplicate-default-extension.toml\"),\n&duplicate_default_extension,),\nPhpRecipe::from_toml(Utf8Path::new(\"invalid-default-extension.toml\"),\n&invalid_default_extension,),\nPhpRecipe::from_toml(Utf8Path::new(\"overlapping-optional-extension.toml\"),\n&overlapping_optional_extension,),\nPhpRecipe::from_toml(Utf8Path::new(\"empty-license-files.toml\"),\n&empty_license_files),\nPhpRecipe::from_toml(Utf8Path::new(\"unsafe-license-file.toml\"),\n&unsafe_license_file),\nPhpRecipe::from_toml(Utf8Path::new(\"unsafe-notice-file.toml\"),\n&unsafe_notice_file),)" --- ( Err( @@ -21,6 +22,24 @@ expression: "(PhpRecipe::from_toml(Utf8Path::new(\"invalid-deployment-target.tom reason: "expected_extensions contains unexpected extensions: xdebug", }, ), + Err( + InvalidRecipeMetadata { + path: "duplicate-default-extension.toml", + reason: "php.default_extensions contains duplicate extension `bcmath`", + }, + ), + Err( + InvalidRecipeMetadata { + path: "invalid-default-extension.toml", + reason: "php.default_extensions contains invalid extension `bad-extension`", + }, + ), + Err( + InvalidRecipeMetadata { + path: "overlapping-optional-extension.toml", + reason: "php.default_extensions and php.optional_extensions both contain extension `bcmath`", + }, + ), Err( InvalidRecipeMetadata { path: "empty-license-files.toml", diff --git a/crates/pv-release/tests/snapshots/record_writer__release_record_writer_serializes_php_extension_metadata.snap b/crates/pv-release/tests/snapshots/record_writer__release_record_writer_serializes_php_extension_metadata.snap new file mode 100644 index 00000000..b38fba35 --- /dev/null +++ b/crates/pv-release/tests/snapshots/record_writer__release_record_writer_serializes_php_extension_metadata.snap @@ -0,0 +1,42 @@ +--- +source: crates/pv-release/tests/record_writer.rs +expression: json +--- +{ + "resource": "php", + "track": "8.4", + "upstream_version": "8.4.20", + "pv_build_revision": "pv1", + "artifact_version": "8.4.20-pv1", + "platform": "darwin-arm64", + "object_key": "resources/php/8.4/8.4.20-pv1/darwin-arm64/php-8.4.20-pv1-darwin-arm64.tar.gz", + "sha256": "4659fc0570122b0e0aa14f4ff7c261b1fe51795a01ba79963f462ebf40d7520d", + "size": 14, + "published_at": "2026-06-08T12:00:00Z", + "minimum_pv_version": "0.1.0", + "license_files": [ + "LICENSE" + ], + "notice_files": [ + "NOTICE" + ], + "php_extensions": [ + { + "name": "redis", + "load_kind": "extension", + "path": "lib/php/extensions/redis.so" + }, + { + "name": "xdebug", + "load_kind": "zend_extension", + "path": "lib/php/extensions/xdebug.so" + } + ], + "provenance": { + "source_url": "https://www.php.net/distributions/php-8.4.20.tar.gz", + "source_sha256": "a2def5d534d57c6a0236f2265de7537608af871900a4f7955eff463e9e38247d", + "recipe": "release/artifacts/recipes/php/build.sh", + "pv_commit": "0123456789abcdef0123456789abcdef01234567", + "build_run_id": "local-test" + } +} diff --git a/crates/pv-release/tests/snapshots/release_records__release_records_parse_identity_and_required_fields.snap b/crates/pv-release/tests/snapshots/release_records__release_records_parse_identity_and_required_fields.snap index ed39c200..c9f4cd72 100644 --- a/crates/pv-release/tests/snapshots/release_records__release_records_parse_identity_and_required_fields.snap +++ b/crates/pv-release/tests/snapshots/release_records__release_records_parse_identity_and_required_fields.snap @@ -39,6 +39,7 @@ ReleaseRecord { notice_files: [ "NOTICE", ], + php_extensions: [], provenance: Provenance { source_url: "https://download.redis.io/releases/redis-7.2.5.tar.gz", source_sha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", diff --git a/crates/pv-release/tests/snapshots/release_records__release_records_parse_php_extension_metadata.snap b/crates/pv-release/tests/snapshots/release_records__release_records_parse_php_extension_metadata.snap new file mode 100644 index 00000000..815b73ca --- /dev/null +++ b/crates/pv-release/tests/snapshots/release_records__release_records_parse_php_extension_metadata.snap @@ -0,0 +1,73 @@ +--- +source: crates/pv-release/tests/release_records.rs +expression: record +--- +ReleaseRecord { + path: "frankenphp-with-extensions.json", + identity: ArtifactIdentity { + resource: ResourceName( + "frankenphp", + ), + track: TrackName( + "8.4", + ), + upstream_version: "8.4.20-frankenphp1.12.3", + pv_build_revision: "pv1", + platform: DarwinArm64, + }, + artifact_version: ArtifactVersion( + "8.4.20-frankenphp1.12.3-pv1", + ), + object_key: "resources/frankenphp/8.4/8.4.20-frankenphp1.12.3-pv1/darwin-arm64/frankenphp-8.4.20-frankenphp1.12.3-pv1-darwin-arm64.tar.gz", + sha256: Sha256Digest( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ), + size: 42, + published_at_raw: "2026-06-06T12:00:00Z", + published_at: PublishedAt( + 2026-06-06 12:00:00.0 +00:00:00, + ), + minimum_pv_version: PvVersion { + major: 0, + minor: 1, + patch: 0, + raw: "0.1.0", + }, + license_files: [ + "LICENSE", + ], + notice_files: [ + "NOTICE", + ], + php_extensions: [ + PhpExtensionRecord { + name: "redis", + load_kind: "extension", + path: "lib/php/extensions/redis.so", + }, + PhpExtensionRecord { + name: "xdebug", + load_kind: "zend_extension", + path: "lib/php/extensions/xdebug.so", + }, + ], + provenance: Provenance { + source_url: "https://github.com/php/frankenphp/archive/refs/tags/v1.12.3.tar.gz", + source_sha256: "2996fb95bbdf8410847fdcd59df04cd2e297568f6472ebe488af5fb5f3c79363", + source_inputs: [ + SourceInput { + name: "frankenphp", + source_url: "https://github.com/php/frankenphp/archive/refs/tags/v1.12.3.tar.gz", + source_sha256: "2996fb95bbdf8410847fdcd59df04cd2e297568f6472ebe488af5fb5f3c79363", + }, + SourceInput { + name: "php", + source_url: "https://www.php.net/distributions/php-8.4.20.tar.gz", + source_sha256: "a2def5d534d57c6a0236f2265de7537608af871900a4f7955eff463e9e38247d", + }, + ], + recipe: "release/artifacts/recipes/php/build.sh", + pv_commit: "0123456789abcdef0123456789abcdef01234567", + build_run_id: "local-test", + }, +} diff --git a/crates/pv-release/tests/snapshots/release_records__release_records_reject_invalid_php_extension_metadata.snap b/crates/pv-release/tests/snapshots/release_records__release_records_reject_invalid_php_extension_metadata.snap new file mode 100644 index 00000000..fe96b746 --- /dev/null +++ b/crates/pv-release/tests/snapshots/release_records__release_records_reject_invalid_php_extension_metadata.snap @@ -0,0 +1,49 @@ +--- +source: crates/pv-release/tests/release_records.rs +assertion_line: 183 +expression: cases +--- +[ + ( + "empty_name", + InvalidReleaseRecord { + path: "invalid-php-extension.json", + reason: "php_extensions contains invalid extension ``", + }, + ), + ( + "invalid_load_kind", + InvalidReleaseRecord { + path: "invalid-php-extension.json", + reason: "php_extensions contains invalid load kind `module`", + }, + ), + ( + "empty_path", + InvalidReleaseRecord { + path: "invalid-php-extension.json", + reason: "php_extensions.path contains invalid relative path ``", + }, + ), + ( + "current_dir_path", + InvalidReleaseRecord { + path: "invalid-php-extension.json", + reason: "php_extensions.path contains invalid relative path `.`", + }, + ), + ( + "parent_path", + InvalidReleaseRecord { + path: "invalid-php-extension.json", + reason: "php_extensions.path contains invalid relative path `../redis.so`", + }, + ), + ( + "duplicate_name", + InvalidReleaseRecord { + path: "invalid-php-extension.json", + reason: "php_extensions contains duplicate extension `redis`", + }, + ), +] diff --git a/crates/resources/src/lib.rs b/crates/resources/src/lib.rs index 9ff4f740..c12e73cb 100644 --- a/crates/resources/src/lib.rs +++ b/crates/resources/src/lib.rs @@ -10,6 +10,7 @@ pub mod identity; pub mod install; pub mod manifest; pub mod php_defaults; +pub mod php_extensions; pub mod platform; pub mod registry; pub mod runtime; @@ -43,6 +44,12 @@ pub use php_defaults::{ PHP_TRACK_DEFAULT_INI, PhpTrackDefaults, ensure_php_track_defaults, php_track_defaults, php_track_environment, php_track_exec_environment, }; +pub use php_extensions::{ + PHP_EXTENSION_METADATA_PATH, PhpExtensionLoadKind, PhpExtensionModule, PhpExtensionResolution, + ensure_php_runtime_overlay, php_runtime_environment, php_runtime_exec_environment, + read_php_extension_metadata, resolve_persisted_php_extension_modules, + resolve_php_extension_request, +}; pub use platform::{ArtifactPlatform, TargetPlatform}; pub use registry::{ResourceCapability, ResourceDescriptor, ResourceKind}; pub use runtime::{ diff --git a/crates/resources/src/manifest.rs b/crates/resources/src/manifest.rs index 60ae42d7..862f46ea 100644 --- a/crates/resources/src/manifest.rs +++ b/crates/resources/src/manifest.rs @@ -2,8 +2,10 @@ use crate::error::{ResourcesError, Result}; use crate::identity::{ ArtifactVersion, PublishedAt, PvVersion, ResourceName, Sha256Digest, TrackName, TrackSelector, }; +use crate::php_extensions::{PhpExtensionLoadKind, PhpExtensionModule}; use crate::platform::{ArtifactPlatform, TargetPlatform}; use crate::registry; +use camino::Utf8PathBuf; use serde::Deserialize; use std::collections::{BTreeMap, BTreeSet}; use url::Url; @@ -40,6 +42,7 @@ pub struct ManifestArtifact { sha256: Sha256Digest, size: u64, published_at: PublishedAt, + php_extensions: Vec, revocation_state: RevocationState, } @@ -89,11 +92,21 @@ struct RawArtifact { size: u64, published_at: String, #[serde(default)] + php_extensions: Vec, + #[serde(default)] revoked: bool, #[serde(default)] revocation_reason: Option, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawManifestPhpExtension { + name: String, + load_kind: String, + path: String, +} + impl ArtifactManifest { pub fn parse(json: &str) -> Result { let raw: RawManifest = @@ -443,6 +456,19 @@ impl ManifestTrack { impl ManifestArtifact { fn from_raw(raw: RawArtifact, resource_name: &ResourceName, track: &TrackName) -> Result { + let php_extensions = raw + .php_extensions + .into_iter() + .map(php_extension_from_raw) + .collect::>>()?; + validate_manifest_php_extension_duplicates(&php_extensions)?; + if !php_extensions.is_empty() && !resource_supports_php_extensions(resource_name) { + return Err(ResourcesError::InvalidManifest { + reason: "php_extensions are only supported on php or frankenphp artifacts" + .to_string(), + }); + } + Ok(Self { resource_name: resource_name.clone(), track: track.clone(), @@ -454,6 +480,7 @@ impl ManifestArtifact { sha256: Sha256Digest::new(raw.sha256)?, size: raw.size, published_at: PublishedAt::parse(raw.published_at)?, + php_extensions, revocation_state: RevocationState::from_raw(raw.revoked, raw.revocation_reason)?, }) } @@ -498,6 +525,10 @@ impl ManifestArtifact { &self.published_at } + pub fn php_extensions(&self) -> &[PhpExtensionModule] { + &self.php_extensions + } + pub fn revocation_state(&self) -> &RevocationState { &self.revocation_state } @@ -507,6 +538,10 @@ impl ManifestArtifact { } } +fn resource_supports_php_extensions(resource: &ResourceName) -> bool { + matches!(resource.as_str(), "php" | "frankenphp") +} + fn validate_artifact_url(url: String) -> Result { if url.contains('\\') { return Err(ResourcesError::InvalidArtifactUrl { url }); @@ -533,6 +568,71 @@ fn validate_artifact_url(url: String) -> Result { Ok(url) } +fn php_extension_from_raw(raw: RawManifestPhpExtension) -> Result { + validate_manifest_php_extension_name(&raw.name)?; + let relative_path = validate_manifest_php_extension_path(raw.path)?; + let load_kind = match raw.load_kind.as_str() { + "extension" => PhpExtensionLoadKind::Extension, + "zend_extension" => PhpExtensionLoadKind::ZendExtension, + _ => { + return Err(ResourcesError::InvalidManifest { + reason: format!("invalid PHP extension load kind `{}`", raw.load_kind), + }); + } + }; + + Ok(PhpExtensionModule { + name: raw.name, + load_kind, + relative_path, + }) +} + +fn validate_manifest_php_extension_name(name: &str) -> Result<()> { + let valid = !name.is_empty() + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'); + if valid { + return Ok(()); + } + + Err(ResourcesError::InvalidManifest { + reason: format!("invalid PHP extension name `{name}`"), + }) +} + +fn validate_manifest_php_extension_duplicates(extensions: &[PhpExtensionModule]) -> Result<()> { + let mut names = BTreeSet::new(); + for extension in extensions { + if !names.insert(extension.name.as_str()) { + return Err(ResourcesError::InvalidManifest { + reason: format!("duplicate PHP extension `{}`", extension.name), + }); + } + } + + Ok(()) +} + +fn validate_manifest_php_extension_path(path: String) -> Result { + let path = Utf8PathBuf::from(path); + if path.as_str().is_empty() + || path.as_str().contains('\\') + || path.as_str().split('/').any(str::is_empty) + || path.is_absolute() + || path + .components() + .any(|component| matches!(component.as_str(), "." | "..")) + { + return Err(ResourcesError::InvalidManifest { + reason: format!("invalid PHP extension path `{path}`"), + }); + } + + Ok(path) +} + impl RevocationState { fn from_raw(revoked: bool, reason: Option) -> Result { match (revoked, reason) { diff --git a/crates/resources/src/php_extensions.rs b/crates/resources/src/php_extensions.rs new file mode 100644 index 00000000..459be806 --- /dev/null +++ b/crates/resources/src/php_extensions.rs @@ -0,0 +1,282 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::io; + +use camino::{Utf8Path, Utf8PathBuf}; +use serde::Deserialize; +use state::{PvPaths, StateError, fs}; + +use crate::{ResourcesError, Result, php_track_environment}; + +pub const PHP_EXTENSION_METADATA_PATH: &str = "share/pv/php-extensions.json"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub enum PhpExtensionLoadKind { + Extension, + ZendExtension, +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub struct PhpExtensionModule { + pub name: String, + pub load_kind: PhpExtensionLoadKind, + pub relative_path: Utf8PathBuf, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PhpExtensionResolution { + pub requested: Vec, + pub loaded: Vec, + pub ignored: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawPhpExtensionModule { + name: String, + load_kind: String, + path: String, +} + +pub fn read_php_extension_metadata(artifact_root: &Utf8Path) -> Result> { + let path = artifact_root.join(PHP_EXTENSION_METADATA_PATH); + if !fs::path_entry_exists(&path).map_err(resources_error_from_state)? { + return Ok(Vec::new()); + } + + let source = fs::read_to_string(&path).map_err(resources_error_from_state)?; + let raw_modules = + serde_json::from_str::>(&source).map_err(|error| { + ResourcesError::InvalidArtifactLayout { + resource: "php".to_string(), + reason: format!("invalid PHP extension metadata: {error}"), + } + })?; + + let mut modules = Vec::new(); + let mut names = BTreeSet::new(); + for raw in raw_modules { + let module = PhpExtensionModule::from_raw(raw)?; + if !names.insert(module.name.clone()) { + return Err(ResourcesError::InvalidArtifactLayout { + resource: "php".to_string(), + reason: format!("duplicate PHP extension `{}`", module.name), + }); + } + let module_path = artifact_root.join(&module.relative_path); + if !fs::path_is_file(&module_path).map_err(resources_error_from_state)? { + return Err(ResourcesError::InvalidArtifactLayout { + resource: "php".to_string(), + reason: format!("PHP extension module `{module_path}` is missing"), + }); + } + modules.push(module); + } + + Ok(modules) +} + +pub fn resolve_php_extension_request( + artifact_root: &Utf8Path, + requested: &[String], +) -> Result { + let catalog = read_php_extension_metadata(artifact_root)?; + let requested = requested.to_vec(); + let mut requested_unique = BTreeSet::new(); + let mut ignored = Vec::new(); + + for name in &requested { + if !requested_unique.insert(name.clone()) { + continue; + } + if !catalog.iter().any(|module| module.name == *name) { + ignored.push(name.clone()); + } + } + let loaded = catalog + .into_iter() + .filter(|module| requested_unique.contains(&module.name)) + .collect(); + + Ok(PhpExtensionResolution { + requested, + loaded, + ignored, + }) +} + +pub fn resolve_persisted_php_extension_modules( + artifact_root: &Utf8Path, + loaded_extensions: &[String], +) -> Result> { + if loaded_extensions.is_empty() { + return Ok(Vec::new()); + } + + let catalog = read_php_extension_metadata(artifact_root)?; + let mut loaded_unique = BTreeSet::new(); + + for name in loaded_extensions { + if !loaded_unique.insert(name.clone()) { + continue; + } + if !catalog.iter().any(|module| module.name == *name) { + return Err(ResourcesError::InvalidArtifactLayout { + resource: "php".to_string(), + reason: format!( + "persisted PHP extension `{name}` is missing from installed artifact metadata" + ), + }); + } + } + let loaded = catalog + .into_iter() + .filter(|module| loaded_unique.contains(&module.name)) + .collect(); + + Ok(loaded) +} + +pub fn ensure_php_runtime_overlay( + paths: &PvPaths, + runtime_key: &str, + artifact_root: &Utf8Path, + modules: &[PhpExtensionModule], +) -> Result { + let conf_dir = paths + .config() + .join("php-runtimes") + .join(runtime_key) + .join("conf.d"); + match fs::delete_dir_all(&conf_dir) { + Ok(()) => {} + Err(StateError::Filesystem { source, .. }) if source.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(resources_error_from_state(error)), + } + fs::ensure_user_dir(&conf_dir).map_err(resources_error_from_state)?; + + for (index, module) in modules.iter().enumerate() { + let prefix = 10 + (index * 10); + let directive = match module.load_kind { + PhpExtensionLoadKind::Extension => "extension", + PhpExtensionLoadKind::ZendExtension => "zend_extension", + }; + let module_path = artifact_root.join(&module.relative_path); + let ini = format!("{directive}={module_path}\n"); + fs::write_sensitive_file( + &conf_dir.join(format!("{prefix}-{}.ini", module.name)), + &ini, + ) + .map_err(resources_error_from_state)?; + } + + Ok(conf_dir) +} + +pub fn php_runtime_environment( + paths: &PvPaths, + track: &str, + runtime_key: &str, + artifact_root: &Utf8Path, + modules: &[PhpExtensionModule], +) -> Result> { + let mut environment = + php_track_environment(paths, track).map_err(resources_error_from_state)?; + if !modules.is_empty() { + let overlay = ensure_php_runtime_overlay(paths, runtime_key, artifact_root, modules)?; + let scan_dir = environment + .entry("PHP_INI_SCAN_DIR".to_string()) + .or_default(); + if !scan_dir.is_empty() { + scan_dir.push(':'); + } + scan_dir.push_str(overlay.as_str()); + } + + Ok(environment) +} + +pub fn php_runtime_exec_environment( + paths: &PvPaths, + track: &str, + runtime_key: &str, + artifact_root: &Utf8Path, + modules: &[PhpExtensionModule], +) -> Result> { + Ok( + php_runtime_environment(paths, track, runtime_key, artifact_root, modules)? + .into_iter() + .map(|(key, value)| (OsString::from(key), OsString::from(value))) + .collect(), + ) +} + +impl PhpExtensionModule { + fn from_raw(raw: RawPhpExtensionModule) -> Result { + validate_extension_name(&raw.name)?; + let relative_path = validate_relative_path(raw.path)?; + let load_kind = match raw.load_kind.as_str() { + "extension" => PhpExtensionLoadKind::Extension, + "zend_extension" => PhpExtensionLoadKind::ZendExtension, + _ => { + return Err(ResourcesError::InvalidArtifactLayout { + resource: "php".to_string(), + reason: format!("invalid PHP extension load kind `{}`", raw.load_kind), + }); + } + }; + + Ok(Self { + name: raw.name, + load_kind, + relative_path, + }) + } +} + +fn validate_extension_name(name: &str) -> Result<()> { + let valid = !name.is_empty() + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'); + if valid { + return Ok(()); + } + + Err(ResourcesError::InvalidArtifactLayout { + resource: "php".to_string(), + reason: format!("invalid PHP extension name `{name}`"), + }) +} + +fn validate_relative_path(path: String) -> Result { + let path = Utf8PathBuf::from(path); + if path.as_str().is_empty() + || path.as_str().contains('\\') + || path.as_str().split('/').any(str::is_empty) + || path.is_absolute() + || path + .components() + .any(|component| matches!(component.as_str(), "." | "..")) + { + return Err(ResourcesError::InvalidArtifactLayout { + resource: "php".to_string(), + reason: format!("invalid PHP extension path `{path}`"), + }); + } + + Ok(path) +} + +fn resources_error_from_state(error: StateError) -> ResourcesError { + match error { + StateError::Filesystem { path, source } => ResourcesError::Filesystem { + path: path.to_string(), + reason: source.to_string(), + }, + error => ResourcesError::InvalidArtifactLayout { + resource: "php".to_string(), + reason: error.to_string(), + }, + } +} diff --git a/crates/resources/tests/manifest_foundation.rs b/crates/resources/tests/manifest_foundation.rs index f948f83a..c20a7578 100644 --- a/crates/resources/tests/manifest_foundation.rs +++ b/crates/resources/tests/manifest_foundation.rs @@ -2,6 +2,7 @@ use anyhow::Result; use insta::assert_debug_snapshot; use resources::ArtifactManifest; use resources::ManifestSelection; +use resources::PhpExtensionLoadKind; use resources::ResourcesError; use resources::registry; use resources::{ArtifactPlatform, TargetPlatform}; @@ -29,6 +30,16 @@ struct InvalidUrlSnapshot { error: ResourcesError, } +#[derive(Debug)] +#[expect( + dead_code, + reason = "snapshot-only structure is read through derived Debug" +)] +struct InvalidManifestSnapshot { + name: &'static str, + error: ResourcesError, +} + #[test] fn registry_lists_all_pv_managed_artifact_resources() -> Result<()> { let descriptors = registry::all() @@ -103,12 +114,42 @@ fn platform_matching_prefers_exact_matches_over_any() -> Result<()> { #[test] fn manifest_parses_registry_backed_resources_tracks_and_artifacts() -> Result<()> { let manifest = ArtifactManifest::parse(VALID_MANIFEST)?; + let resource = ResourceName::new("redis")?; + let track = TrackName::new("7")?; + let selected = + manifest.select_latest(&resource, &track, TargetPlatform::new("darwin-arm64")?)?; + assert!(selected.artifact().php_extensions().is_empty()); assert_debug_snapshot!(manifest); Ok(()) } +#[test] +fn manifest_parses_php_extension_metadata_for_php_artifacts() -> Result<()> { + let manifest = ArtifactManifest::parse(&manifest_with_php_extension_metadata())?; + let resource = ResourceName::new("php")?; + let track = TrackName::new("8.4")?; + let selected = + manifest.select_latest(&resource, &track, TargetPlatform::new("darwin-arm64")?)?; + + assert_eq!( + selected + .artifact() + .php_extensions() + .iter() + .map(|module| module.name.as_str()) + .collect::>(), + ["redis", "xdebug"] + ); + assert_eq!( + selected.artifact().php_extensions()[1].load_kind, + PhpExtensionLoadKind::ZendExtension + ); + + Ok(()) +} + #[test] fn manifest_rejects_mixed_any_and_exact_artifacts_for_one_version() -> Result<()> { let mixed_platforms = SELECTION_MANIFEST.replacen( @@ -498,6 +539,51 @@ fn manifest_rejects_invalid_artifact_urls() -> Result<()> { Ok(()) } +#[test] +fn manifest_rejects_invalid_php_extension_metadata() -> Result<()> { + let invalid_extensions = [ + ( + "empty_path", + manifest_with_php_extension_metadata() + .replace("\"path\":\"lib/php/extensions/redis.so\"", "\"path\":\"\""), + ), + ( + "current_dir", + manifest_with_php_extension_metadata() + .replace("\"path\":\"lib/php/extensions/redis.so\"", "\"path\":\".\""), + ), + ( + "duplicate_name", + manifest_with_php_extension_metadata() + .replace("\"name\":\"xdebug\"", "\"name\":\"redis\""), + ), + ] + .into_iter() + .map(|(name, json)| { + Ok(InvalidManifestSnapshot { + name, + error: parse_manifest_error(&json)?, + }) + }) + .collect::>>()?; + + assert_debug_snapshot!(invalid_extensions); + + Ok(()) +} + +#[test] +fn manifest_rejects_php_extension_metadata_for_non_php_artifacts() -> Result<()> { + let error = parse_manifest_error(&non_php_manifest_with_php_extension_metadata())?; + + assert!( + matches!(error, ResourcesError::InvalidManifest { ref reason } if reason.contains("php_extensions are only supported on php or frankenphp artifacts")), + "non-PHP php_extensions should be rejected, got {error:?}", + ); + + Ok(()) +} + #[test] fn manifest_allows_same_version_exact_artifacts_for_different_targets() -> Result<()> { let manifest_json = VALID_MANIFEST.replacen( @@ -528,6 +614,55 @@ fn manifest_allows_same_version_exact_artifacts_for_different_targets() -> Resul Ok(()) } +fn manifest_with_php_extension_metadata() -> String { + r#" +{ + "schema_version": 1, + "minimum_pv_version": "0.1.0", + "resources": [ + { + "name": "php", + "default_track": "8.4", + "tracks": [ + { + "name": "8.4", + "artifacts": [ + { + "artifact_version": "8.4.20-pv1", + "upstream_version": "8.4.20", + "pv_build_revision": "pv1", + "platform": "darwin-arm64", + "url": "https://artifacts.example.test/php-8.4.20-pv1-darwin-arm64.tar.gz", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 12345, + "published_at": "2026-05-26T14:30:00Z", + "php_extensions": [ + {"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}, + {"name":"xdebug","load_kind":"zend_extension","path":"lib/php/extensions/xdebug.so"} + ] + } + ] + } + ] + } + ] +} +"# + .to_string() +} + +fn non_php_manifest_with_php_extension_metadata() -> String { + VALID_MANIFEST.replacen( + r#""published_at": "2026-05-26T14:30:00Z""#, + r#""published_at": "2026-05-26T14:30:00Z", + "php_extensions": [ + {"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}, + {"name":"xdebug","load_kind":"zend_extension","path":"lib/php/extensions/xdebug.so"} + ]"#, + 1, + ) +} + const VALID_MANIFEST: &str = r#" { "schema_version": 1, diff --git a/crates/resources/tests/php_extensions.rs b/crates/resources/tests/php_extensions.rs new file mode 100644 index 00000000..4cd9ad7c --- /dev/null +++ b/crates/resources/tests/php_extensions.rs @@ -0,0 +1,241 @@ +use anyhow::Result; +use camino_tempfile::tempdir; +use resources::{ + PhpExtensionLoadKind, ResourcesError, ensure_php_runtime_overlay, php_runtime_environment, + resolve_persisted_php_extension_modules, resolve_php_extension_request, +}; +use state::{PvPaths, fs}; + +#[test] +fn resolves_available_and_ignored_php_extensions_from_artifact_metadata() -> Result<()> { + let tempdir = tempdir()?; + let artifact = tempdir.path().join("php"); + fs::write_sensitive_file( + &artifact.join("share/pv/php-extensions.json"), + r#" +[ + {"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}, + {"name":"xdebug","load_kind":"zend_extension","path":"lib/php/extensions/xdebug.so"} +] +"#, + )?; + fs::write_sensitive_file(&artifact.join("lib/php/extensions/redis.so"), "")?; + fs::write_sensitive_file(&artifact.join("lib/php/extensions/xdebug.so"), "")?; + + let resolution = resolve_php_extension_request( + &artifact, + &["xdebug".into(), "missing".into(), "redis".into()], + )?; + + assert_eq!(resolution.requested, ["xdebug", "missing", "redis"]); + assert_eq!( + resolution + .loaded + .iter() + .map(|module| module.name.as_str()) + .collect::>(), + ["redis", "xdebug"] + ); + assert_eq!( + resolution.loaded[1].load_kind, + PhpExtensionLoadKind::ZendExtension + ); + assert_eq!(resolution.ignored, ["missing"]); + + Ok(()) +} + +#[test] +fn resolves_loaded_php_extensions_in_artifact_metadata_order() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let artifact = tempdir.path().join("php"); + fs::write_sensitive_file( + &artifact.join("share/pv/php-extensions.json"), + r#" +[ + {"name":"sqlsrv","load_kind":"extension","path":"lib/php/extensions/sqlsrv.so"}, + {"name":"pdo_sqlsrv","load_kind":"extension","path":"lib/php/extensions/pdo_sqlsrv.so"} +] +"#, + )?; + fs::write_sensitive_file(&artifact.join("lib/php/extensions/sqlsrv.so"), "")?; + fs::write_sensitive_file(&artifact.join("lib/php/extensions/pdo_sqlsrv.so"), "")?; + + let resolution = + resolve_php_extension_request(&artifact, &["pdo_sqlsrv".into(), "sqlsrv".into()])?; + let overlay = ensure_php_runtime_overlay( + &paths, + "8.4+pdo_sqlsrv+sqlsrv", + &artifact, + &resolution.loaded, + )?; + + assert_eq!( + resolution + .loaded + .iter() + .map(|module| module.name.as_str()) + .collect::>(), + ["sqlsrv", "pdo_sqlsrv"] + ); + assert!(overlay.join("10-sqlsrv.ini").is_file()); + assert!(overlay.join("20-pdo_sqlsrv.ini").is_file()); + + Ok(()) +} + +#[test] +fn persisted_php_extension_resolution_rejects_missing_metadata_names() -> Result<()> { + let tempdir = tempdir()?; + let artifact = tempdir.path().join("php"); + fs::write_sensitive_file( + &artifact.join("share/pv/php-extensions.json"), + r#" +[ + {"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"} +] +"#, + )?; + fs::write_sensitive_file(&artifact.join("lib/php/extensions/redis.so"), "")?; + + let result = + resolve_persisted_php_extension_modules(&artifact, &["redis".into(), "xdebug".into()]); + + assert!(matches!( + result, + Err(ResourcesError::InvalidArtifactLayout { resource, reason }) + if resource == "php" && reason.contains("persisted PHP extension `xdebug`") + )); + + Ok(()) +} + +#[test] +fn writes_runtime_overlay_for_loaded_php_extensions() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let artifact = tempdir.path().join("php"); + fs::write_sensitive_file( + &artifact.join("share/pv/php-extensions.json"), + r#" +[ + {"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}, + {"name":"xdebug","load_kind":"zend_extension","path":"lib/php/extensions/xdebug.so"} +] +"#, + )?; + fs::write_sensitive_file(&artifact.join("lib/php/extensions/redis.so"), "")?; + fs::write_sensitive_file(&artifact.join("lib/php/extensions/xdebug.so"), "")?; + let resolution = resolve_php_extension_request(&artifact, &["redis".into(), "xdebug".into()])?; + + let overlay = + ensure_php_runtime_overlay(&paths, "8.4+redis+xdebug", &artifact, &resolution.loaded)?; + let redis_ini = fs::read_to_string(&overlay.join("10-redis.ini"))?; + let xdebug_ini = fs::read_to_string(&overlay.join("20-xdebug.ini"))?; + let env = php_runtime_environment( + &paths, + "8.4", + "8.4+redis+xdebug", + &artifact, + &resolution.loaded, + )?; + + assert!(redis_ini.contains("extension=")); + assert!(redis_ini.contains("redis.so")); + assert!(xdebug_ini.contains("zend_extension=")); + assert!(xdebug_ini.contains("xdebug.so")); + assert!(env["PHP_INI_SCAN_DIR"].contains("conf.d")); + assert!(env["PHP_INI_SCAN_DIR"].contains("php-runtimes/8.4+redis+xdebug/conf.d")); + + Ok(()) +} + +#[test] +fn runtime_overlay_prunes_stale_extension_ini_files() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let artifact = tempdir.path().join("php"); + fs::write_sensitive_file( + &artifact.join("share/pv/php-extensions.json"), + r#" +[ + {"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}, + {"name":"xdebug","load_kind":"zend_extension","path":"lib/php/extensions/xdebug.so"} +] +"#, + )?; + fs::write_sensitive_file(&artifact.join("lib/php/extensions/redis.so"), "")?; + fs::write_sensitive_file(&artifact.join("lib/php/extensions/xdebug.so"), "")?; + let initial = resolve_php_extension_request(&artifact, &["redis".into(), "xdebug".into()])?; + let runtime_key = "8.4+redis+xdebug"; + let overlay = ensure_php_runtime_overlay(&paths, runtime_key, &artifact, &initial.loaded)?; + + assert!(overlay.join("10-redis.ini").is_file()); + assert!(overlay.join("20-xdebug.ini").is_file()); + + fs::write_sensitive_file( + &artifact.join("share/pv/php-extensions.json"), + r#" +[ + {"name":"xdebug","load_kind":"zend_extension","path":"lib/php/extensions/xdebug.so"}, + {"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"} +] +"#, + )?; + let reordered = resolve_php_extension_request(&artifact, &["redis".into(), "xdebug".into()])?; + let overlay = ensure_php_runtime_overlay(&paths, runtime_key, &artifact, &reordered.loaded)?; + let mut file_names = fs::read_dir_paths(&overlay)? + .into_iter() + .filter_map(|path| path.file_name().map(str::to_owned)) + .collect::>(); + file_names.sort(); + + assert_eq!(file_names, ["10-xdebug.ini", "20-redis.ini"]); + + Ok(()) +} + +#[test] +fn php_extension_metadata_rejects_invalid_paths_duplicates_and_missing_modules() -> Result<()> { + let tempdir = tempdir()?; + let cases = [ + ( + "empty-path", + r#"[{"name":"redis","load_kind":"extension","path":""}]"#, + ), + ( + "current-dir", + r#"[{"name":"redis","load_kind":"extension","path":"."}]"#, + ), + ( + "duplicate-name", + r#"[ + {"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}, + {"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis-copy.so"} +]"#, + ), + ( + "missing-module", + r#"[{"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}]"#, + ), + ]; + + for (name, metadata) in cases { + let artifact = tempdir.path().join(format!("php-{name}")); + fs::write_sensitive_file(&artifact.join("share/pv/php-extensions.json"), metadata)?; + if name == "duplicate-name" { + fs::write_sensitive_file(&artifact.join("lib/php/extensions/redis.so"), "")?; + fs::write_sensitive_file(&artifact.join("lib/php/extensions/redis-copy.so"), "")?; + } + + let result = resolve_php_extension_request(&artifact, &["redis".into()]); + + assert!( + matches!(result, Err(ResourcesError::InvalidArtifactLayout { ref resource, .. }) if resource == "php"), + "{name} should be rejected, got {result:?}", + ); + } + + Ok(()) +} diff --git a/crates/resources/tests/snapshots/artifact_cache_download__manifest_cache_fetches_latest_and_falls_back_to_cached_manifest-2.snap b/crates/resources/tests/snapshots/artifact_cache_download__manifest_cache_fetches_latest_and_falls_back_to_cached_manifest-2.snap index 84917f1b..220a6e26 100644 --- a/crates/resources/tests/snapshots/artifact_cache_download__manifest_cache_fetches_latest_and_falls_back_to_cached_manifest-2.snap +++ b/crates/resources/tests/snapshots/artifact_cache_download__manifest_cache_fetches_latest_and_falls_back_to_cached_manifest-2.snap @@ -46,6 +46,7 @@ ArtifactManifest { published_at: PublishedAt( 2026-05-26 14:30:00.0 +00:00:00, ), + php_extensions: [], revocation_state: Active, }, ], diff --git a/crates/resources/tests/snapshots/manifest_foundation__manifest_parses_registry_backed_resources_tracks_and_artifacts.snap b/crates/resources/tests/snapshots/manifest_foundation__manifest_parses_registry_backed_resources_tracks_and_artifacts.snap index 69dbbc60..8622df36 100644 --- a/crates/resources/tests/snapshots/manifest_foundation__manifest_parses_registry_backed_resources_tracks_and_artifacts.snap +++ b/crates/resources/tests/snapshots/manifest_foundation__manifest_parses_registry_backed_resources_tracks_and_artifacts.snap @@ -1,6 +1,5 @@ --- source: crates/resources/tests/manifest_foundation.rs -assertion_line: 107 expression: manifest --- ArtifactManifest { @@ -46,6 +45,7 @@ ArtifactManifest { published_at: PublishedAt( 2026-05-26 14:30:00.0 +00:00:00, ), + php_extensions: [], revocation_state: Active, }, ], @@ -86,6 +86,7 @@ ArtifactManifest { published_at: PublishedAt( 2026-05-26 15:30:00.0 +00:00:00, ), + php_extensions: [], revocation_state: Active, }, ], diff --git a/crates/resources/tests/snapshots/manifest_foundation__manifest_rejects_invalid_php_extension_metadata.snap b/crates/resources/tests/snapshots/manifest_foundation__manifest_rejects_invalid_php_extension_metadata.snap new file mode 100644 index 00000000..d49ad9a6 --- /dev/null +++ b/crates/resources/tests/snapshots/manifest_foundation__manifest_rejects_invalid_php_extension_metadata.snap @@ -0,0 +1,25 @@ +--- +source: crates/resources/tests/manifest_foundation.rs +assertion_line: 556 +expression: invalid_extensions +--- +[ + InvalidManifestSnapshot { + name: "empty_path", + error: InvalidManifest { + reason: "invalid PHP extension path ``", + }, + }, + InvalidManifestSnapshot { + name: "current_dir", + error: InvalidManifest { + reason: "invalid PHP extension path `.`", + }, + }, + InvalidManifestSnapshot { + name: "duplicate_name", + error: InvalidManifest { + reason: "duplicate PHP extension `redis`", + }, + }, +] diff --git a/crates/state/src/database.rs b/crates/state/src/database.rs index 53680b7d..9dce8505 100644 --- a/crates/state/src/database.rs +++ b/crates/state/src/database.rs @@ -24,6 +24,7 @@ const RESERVED_HOSTNAME: &str = "pv.test"; const RESERVED_TRACK_NAME: &str = "latest"; const PROJECT_ENV_OBSERVED_SUBJECT_KIND: &str = "project_env"; const RUNTIME_OBSERVED_SUBJECT_KIND: &str = "runtime"; +const PHP_RUNTIME_WORKER_OBSERVED_SUBJECT_KIND: &str = "php_runtime_worker"; pub type EnvContextValues = BTreeMap; @@ -98,7 +99,7 @@ pub enum PortOwner { Dns, Gateway(GatewayPort), PhpWorker { - php_track: String, + php_runtime_key: String, }, Resource { name: String, @@ -193,11 +194,28 @@ pub struct ProjectRecord { pub primary_hostname: String, pub config_path: Utf8PathBuf, pub desired_php_track: Option, + pub php_runtime: ProjectPhpRuntimeRecord, pub additional_hostnames: Vec, pub created_at: String, pub updated_at: String, } +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ProjectPhpRuntimeRecord { + pub track: Option, + pub requested_extensions: Vec, + pub loaded_extensions: Vec, + pub ignored_extensions: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectPhpRuntimeInput { + pub track: String, + pub requested_extensions: Vec, + pub loaded_extensions: Vec, + pub ignored_extensions: Vec, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct ProjectManagedResourceInput { pub resource_name: String, @@ -296,6 +314,7 @@ pub struct ProjectEnvObservedStateRecord { pub enum RuntimeSubject { Gateway, PhpWorker { php_track: String }, + PhpRuntimeWorker { php_runtime_key: String }, Resource { name: String, track: String }, } @@ -316,6 +335,22 @@ pub struct RuntimeObservedStateRecord { pub observed_at: String, } +pub fn php_runtime_key(track: &str, loaded_extensions: &[String]) -> Result { + validate_project_php_track(track)?; + for extension in loaded_extensions { + validate_php_extension_identity(extension)?; + } + + let mut loaded_extensions = loaded_extensions.to_vec(); + loaded_extensions.sort(); + loaded_extensions.dedup(); + if loaded_extensions.is_empty() { + return Ok(track.to_string()); + } + + Ok(format!("{track}+{}", loaded_extensions.join("+"))) +} + struct JobRecordRow { id: String, kind: String, @@ -385,6 +420,7 @@ struct ProjectEnvObservedWarningRow { } struct RuntimeObservedStateRow { + subject_kind: String, subject_id: String, status: String, message: Option, @@ -706,38 +742,50 @@ impl Database { project_id: &str, desired_php_track: Option<&str>, ) -> Result { - let transaction = self - .connection - .transaction_with_behavior(TransactionBehavior::Immediate)?; - let project = project_by_id_in_transaction(&transaction, project_id)?.ok_or_else(|| { - StateError::ProjectNotFound { - target: project_id.to_string(), - } - })?; - if let Some(track) = desired_php_track { - validate_project_php_track(track)?; - } + let runtime = desired_php_track.map(|track| ProjectPhpRuntimeInput { + track: track.to_string(), + requested_extensions: Vec::new(), + loaded_extensions: Vec::new(), + ignored_extensions: Vec::new(), + }); - if project.desired_php_track.as_deref() != desired_php_track { - let input = LinkProjectInput { - path: project.path.clone(), - original_path: project.original_path.clone(), - primary_hostname: project.primary_hostname.clone(), - config_path: project.config_path.clone(), - desired_php_track: desired_php_track.map(str::to_string), - additional_hostnames: project.additional_hostnames.clone(), - }; - update_project_in_transaction(&transaction, project_id, &input)?; - } + self.replace_project_php_runtime(project_id, runtime.as_ref()) + } - let project = project_by_id_in_transaction(&transaction, project_id)?.ok_or_else(|| { - StateError::ProjectNotFound { - target: project_id.to_string(), + pub fn replace_project_php_runtime( + &mut self, + project_id: &str, + runtime: Option<&ProjectPhpRuntimeInput>, + ) -> Result { + let (track, requested, loaded, ignored) = match runtime { + Some(runtime) => { + validate_project_php_track(&runtime.track)?; + ( + Some(runtime.track.as_str()), + user_extension_json(&runtime.requested_extensions)?, + runtime_extension_json(&runtime.loaded_extensions)?, + user_extension_json(&runtime.ignored_extensions)?, + ) } - })?; - transaction.commit()?; + None => (None, "[]".to_string(), "[]".to_string(), "[]".to_string()), + }; + let updated_at = timestamp()?; - Ok(project) + self.connection.execute( + "UPDATE projects + SET desired_php_track = ?2, + desired_php_requested_extensions_json = ?3, + desired_php_loaded_extensions_json = ?4, + desired_php_ignored_extensions_json = ?5, + updated_at = ?6 + WHERE id = ?1", + params![project_id, track, requested, loaded, ignored, updated_at], + )?; + + self.project_by_id(project_id)? + .ok_or_else(|| StateError::ProjectNotFound { + target: project_id.to_string(), + }) } pub fn project_by_id(&self, project_id: &str) -> Result, StateError> { @@ -1512,6 +1560,7 @@ impl Database { status: RuntimeObservedStatus, message: Option<&str>, ) -> Result { + let subject_kind = subject.subject_kind(); let subject_id = subject.subject_id()?; let observed_at = timestamp()?; self.connection.execute( @@ -1528,7 +1577,7 @@ impl Database { message = excluded.message, observed_at = excluded.observed_at", params![ - RUNTIME_OBSERVED_SUBJECT_KIND, + subject_kind, subject_id.as_str(), status.as_str(), message, @@ -1546,13 +1595,16 @@ impl Database { pub fn runtime_observed_states(&self) -> Result, StateError> { let mut statement = self.connection.prepare( - "SELECT subject_id, status, message, observed_at + "SELECT subject_kind, subject_id, status, message, observed_at FROM observed_states - WHERE subject_kind = ?1 - ORDER BY subject_id", + WHERE subject_kind IN (?1, ?2) + ORDER BY subject_kind, subject_id", )?; let rows = statement.query_map( - params![RUNTIME_OBSERVED_SUBJECT_KIND], + params![ + RUNTIME_OBSERVED_SUBJECT_KIND, + PHP_RUNTIME_WORKER_OBSERVED_SUBJECT_KIND, + ], runtime_observed_state_from_row, )?; let mut states = Vec::new(); @@ -1857,14 +1909,14 @@ impl PortRequest { } pub fn php_worker( - php_track: impl Into, + php_runtime_key: impl Into, preferred_port: u16, fallback_start: u16, fallback_end: u16, ) -> Self { Self::new( PortOwner::PhpWorker { - php_track: php_track.into(), + php_runtime_key: php_runtime_key.into(), }, preferred_port, fallback_start, @@ -1955,13 +2007,13 @@ impl PortOwner { owner_track: String::new(), owner_port: String::new(), }), - Self::PhpWorker { php_track } => { - validate_concrete_track(php_track)?; + Self::PhpWorker { php_runtime_key } => { + validate_php_runtime_key(php_runtime_key)?; Ok(PortIdentity { owner_kind: "php_worker", owner_id: "php".to_string(), - owner_track: php_track.clone(), + owner_track: php_runtime_key.clone(), owner_port: String::new(), }) } @@ -2004,13 +2056,16 @@ impl PortOwner { "php_worker" if owner_id == "php" && !owner_track.is_empty() && owner_port.is_empty() => { - Ok(Self::PhpWorker { - php_track: owner_track, - }) + let owner = Self::PhpWorker { + php_runtime_key: owner_track, + }; + owner.identity()?; + + Ok(owner) } "php_worker" => Err(StateError::InvalidPortOwner { owner: describe_port_identity(&owner_kind, &owner_id, &owner_track, &owner_port), - reason: "php worker ports must use owner id `php`, include a php track, and use an empty owner port", + reason: "php worker ports must use owner id `php`, include a php runtime key, and use an empty owner port", }), "resource" if !owner_id.is_empty() && !owner_track.is_empty() && !owner_port.is_empty() => @@ -2036,7 +2091,9 @@ impl PortOwner { match self { Self::Dns => "dns".to_string(), Self::Gateway(gateway_port) => format!("gateway {}", gateway_port.as_str()), - Self::PhpWorker { php_track } => format!("php worker {php_track:?}"), + Self::PhpWorker { php_runtime_key } => { + format!("php worker {php_runtime_key:?}") + } Self::Resource { name, track, port } => { format!("resource {name:?} track {track:?} port {port:?}") } @@ -2223,6 +2280,15 @@ impl ProjectEnvObservedWarningRow { } impl RuntimeSubject { + fn subject_kind(&self) -> &'static str { + match self { + Self::PhpRuntimeWorker { .. } => PHP_RUNTIME_WORKER_OBSERVED_SUBJECT_KIND, + Self::Gateway | Self::PhpWorker { .. } | Self::Resource { .. } => { + RUNTIME_OBSERVED_SUBJECT_KIND + } + } + } + fn subject_id(&self) -> Result { match self { Self::Gateway => Ok("gateway".to_string()), @@ -2231,6 +2297,11 @@ impl RuntimeSubject { Ok(format!("php_worker:{php_track}")) } + Self::PhpRuntimeWorker { php_runtime_key } => { + validate_php_runtime_key(php_runtime_key)?; + + Ok(php_runtime_key.clone()) + } Self::Resource { name, track } => { validate_managed_resource_identity("name", name)?; validate_concrete_track(track)?; @@ -2240,7 +2311,24 @@ impl RuntimeSubject { } } - fn from_subject_id(subject_id: String) -> Result { + fn from_database(subject_kind: String, subject_id: String) -> Result { + match subject_kind.as_str() { + RUNTIME_OBSERVED_SUBJECT_KIND => Self::from_runtime_subject_id(subject_id), + PHP_RUNTIME_WORKER_OBSERVED_SUBJECT_KIND => { + validate_php_runtime_key(&subject_id)?; + + Ok(Self::PhpRuntimeWorker { + php_runtime_key: subject_id, + }) + } + _ => Err(StateError::InvalidRuntimeSubject { + kind: "subject_kind", + value: subject_kind, + }), + } + } + + fn from_runtime_subject_id(subject_id: String) -> Result { if subject_id == "gateway" { return Ok(Self::Gateway); } @@ -2298,7 +2386,7 @@ impl RuntimeObservedStatus { impl RuntimeObservedStateRow { fn into_record(self) -> Result { Ok(RuntimeObservedStateRecord { - subject: RuntimeSubject::from_subject_id(self.subject_id)?, + subject: RuntimeSubject::from_database(self.subject_kind, self.subject_id)?, status: RuntimeObservedStatus::from_database(self.status)?, message: self.message, observed_at: self.observed_at, @@ -2309,6 +2397,8 @@ impl RuntimeObservedStateRow { impl ProjectRow { fn into_record(self, connection: &Connection) -> Result { let additional_hostnames = additional_hostnames_for_project(connection, &self.id)?; + let php_runtime = + project_php_runtime_for_project(connection, &self.id, self.desired_php_track.clone())?; let path = Utf8PathBuf::from(self.path); let original_path = self .original_path @@ -2326,6 +2416,7 @@ impl ProjectRow { primary_hostname: self.primary_hostname, config_path, desired_php_track: self.desired_php_track, + php_runtime, additional_hostnames, created_at: self.created_at, updated_at: self.updated_at, @@ -2837,21 +2928,74 @@ fn validate_project_php_track(track: &str) -> Result<(), StateError> { } fn validate_runtime_php_track(php_track: &str) -> Result<(), StateError> { - validate_concrete_track(php_track).map_err(|error| match error { - StateError::InvalidManagedResourceIdentity { value, .. } => { - StateError::InvalidRuntimeSubject { - kind: "php_track", - value, - } - } - StateError::ReservedConcreteTrack { track } => StateError::InvalidRuntimeSubject { - kind: "php_track", - value: track, - }, - error => error, + validate_concrete_track(php_track).map_err(|_error| invalid_php_runtime(php_track)) +} + +fn validate_php_runtime_key(php_runtime: &str) -> Result<(), StateError> { + let mut parts = php_runtime.split('+'); + let Some(track) = parts.next() else { + return Err(invalid_php_runtime(php_runtime)); + }; + + validate_concrete_track(track).map_err(|_error| invalid_php_runtime(php_runtime))?; + for extension in parts { + validate_php_extension_identity(extension) + .map_err(|_error| invalid_php_runtime(php_runtime))?; + } + + Ok(()) +} + +fn invalid_php_runtime(php_runtime: &str) -> StateError { + StateError::InvalidRuntimeSubject { + kind: "php_runtime", + value: php_runtime.to_string(), + } +} + +fn validate_php_extension_list(extensions: &[String]) -> Result<(), StateError> { + for extension in extensions { + validate_php_extension_identity(extension)?; + } + + Ok(()) +} + +fn validate_requested_php_extension_list(extensions: &[String]) -> Result<(), StateError> { + for extension in extensions { + validate_requested_php_extension(extension)?; + } + + Ok(()) +} + +fn validate_requested_php_extension(extension: &str) -> Result<(), StateError> { + if !extension.trim().is_empty() { + return Ok(()); + } + + Err(StateError::InvalidRuntimeSubject { + kind: "php_extension", + value: extension.to_string(), }) } +fn validate_php_extension_identity(extension: &str) -> Result<(), StateError> { + let is_valid = !extension.is_empty() + && extension + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'); + + if is_valid { + Ok(()) + } else { + Err(StateError::InvalidRuntimeSubject { + kind: "php_extension", + value: extension.to_string(), + }) + } +} + fn validate_original_project_path(path: &Utf8Path) -> Result<(), StateError> { if !path.is_absolute() { return Err(StateError::InvalidProjectPath { @@ -3001,6 +3145,12 @@ fn update_project_in_transaction( project_id: &str, input: &LinkProjectInput, ) -> Result<(), StateError> { + let previous_desired_php_track = transaction.query_row( + "SELECT desired_php_track FROM projects WHERE id = ?1", + params![project_id], + |row| row.get::<_, Option>(0), + )?; + let should_clear_runtime_extensions = previous_desired_php_track != input.desired_php_track; let updated_at = timestamp()?; transaction.execute( "UPDATE projects @@ -3020,6 +3170,17 @@ fn update_project_in_transaction( ], )?; + if should_clear_runtime_extensions && project_php_runtime_columns_exist(transaction)? { + transaction.execute( + "UPDATE projects + SET desired_php_requested_extensions_json = '[]', + desired_php_loaded_extensions_json = '[]', + desired_php_ignored_extensions_json = '[]' + WHERE id = ?1", + params![project_id], + )?; + } + Ok(()) } @@ -3171,6 +3332,63 @@ fn additional_hostnames_for_project( Ok(hostnames) } +fn project_php_runtime_for_project( + connection: &Connection, + project_id: &str, + track: Option, +) -> Result { + if !project_php_runtime_columns_exist(connection)? { + return Ok(ProjectPhpRuntimeRecord { + track, + ..ProjectPhpRuntimeRecord::default() + }); + } + + let row = connection + .query_row( + "SELECT + desired_php_requested_extensions_json, + desired_php_loaded_extensions_json, + desired_php_ignored_extensions_json + FROM projects + WHERE id = ?1", + params![project_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .optional()?; + + let Some((requested, loaded, ignored)) = row else { + return Err(StateError::ProjectNotFound { + target: project_id.to_string(), + }); + }; + + Ok(ProjectPhpRuntimeRecord { + track, + requested_extensions: parse_user_extension_json(project_id, "requested", &requested)?, + loaded_extensions: parse_runtime_extension_json(project_id, "loaded", &loaded)?, + ignored_extensions: parse_user_extension_json(project_id, "ignored", &ignored)?, + }) +} + +fn project_php_runtime_columns_exist(connection: &Connection) -> Result { + let count = connection.query_row( + "SELECT COUNT(*) + FROM pragma_table_info('projects') + WHERE name = 'desired_php_requested_extensions_json'", + [], + |row| row.get::<_, i64>(0), + )?; + + Ok(count > 0) +} + fn port_assignment_in_transaction( transaction: &Transaction<'_>, identity: &PortIdentity, @@ -3427,10 +3645,11 @@ fn runtime_observed_state_from_row( row: &rusqlite::Row<'_>, ) -> rusqlite::Result { Ok(RuntimeObservedStateRow { - subject_id: row.get(0)?, - status: row.get(1)?, - message: row.get(2)?, - observed_at: row.get(3)?, + subject_kind: row.get(0)?, + subject_id: row.get(1)?, + status: row.get(2)?, + message: row.get(3)?, + observed_at: row.get(4)?, }) } @@ -3534,6 +3753,58 @@ fn validate_project_env_observed_warning_component( Ok(()) } +fn user_extension_json(extensions: &[String]) -> Result { + validate_requested_php_extension_list(extensions)?; + serialize_extension_json(extensions) +} + +fn runtime_extension_json(extensions: &[String]) -> Result { + validate_php_extension_list(extensions)?; + serialize_extension_json(extensions) +} + +fn serialize_extension_json(extensions: &[String]) -> Result { + serde_json::to_string(extensions).map_err(|source| StateError::InvalidEnvJson { + context: "Project PHP runtime extensions".to_string(), + reason: source.to_string(), + }) +} + +fn parse_user_extension_json( + project_id: &str, + extension_kind: &str, + extension_json: &str, +) -> Result, StateError> { + let extensions = parse_extension_json(project_id, extension_kind, extension_json)?; + validate_requested_php_extension_list(&extensions)?; + + Ok(extensions) +} + +fn parse_runtime_extension_json( + project_id: &str, + extension_kind: &str, + extension_json: &str, +) -> Result, StateError> { + let extensions = parse_extension_json(project_id, extension_kind, extension_json)?; + validate_php_extension_list(&extensions)?; + + Ok(extensions) +} + +fn parse_extension_json( + project_id: &str, + extension_kind: &str, + extension_json: &str, +) -> Result, StateError> { + serde_json::from_str::>(extension_json).map_err(|source| { + StateError::InvalidEnvJson { + context: format!("Project {project_id:?} PHP {extension_kind} extensions"), + reason: source.to_string(), + } + }) +} + fn serialize_env_context(context: &str, env: &EnvContextValues) -> Result { validate_env_context(context, env)?; serde_json::to_string(env).map_err(|source| StateError::InvalidEnvJson { diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 6d3c3109..10d9b155 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -17,9 +17,10 @@ pub use database::{ ProjectEnvAllocationContext, ProjectEnvObservedStateRecord, ProjectEnvObservedStatus, ProjectEnvObservedWarningInput, ProjectEnvObservedWarningRecord, ProjectEnvResourceContext, ProjectEnvStateContext, ProjectManagedResourceInput, ProjectManagedResourceRecord, - ProjectRecord, RUNTIME_PORT_FALLBACK_END, RUNTIME_PORT_FALLBACK_START, ResourceAllocationInput, - ResourceAllocationRecord, ResourceAllocationStatus, RuntimeObservedStateRecord, - RuntimeObservedStatus, RuntimeSubject, + ProjectPhpRuntimeInput, ProjectPhpRuntimeRecord, ProjectRecord, RUNTIME_PORT_FALLBACK_END, + RUNTIME_PORT_FALLBACK_START, ResourceAllocationInput, ResourceAllocationRecord, + ResourceAllocationStatus, RuntimeObservedStateRecord, RuntimeObservedStatus, RuntimeSubject, + php_runtime_key, }; pub use error::StateError; pub use paths::{PathSummaryEntry, PvPaths}; diff --git a/crates/state/src/migrations.rs b/crates/state/src/migrations.rs index 95ca1716..224d55be 100644 --- a/crates/state/src/migrations.rs +++ b/crates/state/src/migrations.rs @@ -14,6 +14,8 @@ const PROJECT_RESOURCE_REQUIREMENTS_SQL: &str = include_str!("sql/005_project_resource_requirements.sql"); const GLOBAL_PHP_DEFAULT_SQL: &str = include_str!("sql/006_global_php_default.sql"); const RESOURCE_PORT_ROLES_SQL: &str = include_str!("sql/007_resource_port_roles.sql"); +const PROJECT_PHP_RUNTIME_EXTENSIONS_SQL: &str = + include_str!("sql/008_project_php_runtime_extensions.sql"); pub(crate) const DEFAULT_MIGRATIONS: &[Migration] = &[ Migration::new(1, "core_state_schema", CORE_SCHEMA_SQL), @@ -35,6 +37,11 @@ pub(crate) const DEFAULT_MIGRATIONS: &[Migration] = &[ ), Migration::new(6, "global_php_default", GLOBAL_PHP_DEFAULT_SQL), Migration::new(7, "resource_port_roles", RESOURCE_PORT_ROLES_SQL), + Migration::new( + 8, + "project_php_runtime_extensions", + PROJECT_PHP_RUNTIME_EXTENSIONS_SQL, + ), ]; #[derive(Copy, Clone, Debug, Eq, PartialEq)] diff --git a/crates/state/src/paths.rs b/crates/state/src/paths.rs index ee8de517..1c66af00 100644 --- a/crates/state/src/paths.rs +++ b/crates/state/src/paths.rs @@ -146,14 +146,14 @@ impl PvPaths { self.config().join("gateway/projects") } - pub fn worker_root_config(&self, php_track: &str) -> Utf8PathBuf { + pub fn worker_root_config(&self, php_runtime: &str) -> Utf8PathBuf { self.config() - .join(format!("workers/php-{php_track}/Caddyfile")) + .join(format!("workers/php-{php_runtime}/Caddyfile")) } - pub fn worker_projects_config_dir(&self, php_track: &str) -> Utf8PathBuf { + pub fn worker_projects_config_dir(&self, php_runtime: &str) -> Utf8PathBuf { self.config() - .join(format!("workers/php-{php_track}/projects")) + .join(format!("workers/php-{php_runtime}/projects")) } pub fn resource_runtime_config(&self, resource_name: &str, track: &str) -> Utf8PathBuf { @@ -185,8 +185,8 @@ impl PvPaths { self.logs().join("gateway/error.log") } - pub fn worker_log(&self, php_track: &str) -> Utf8PathBuf { - self.logs().join(format!("workers/php-{php_track}.log")) + pub fn worker_log(&self, php_runtime: &str) -> Utf8PathBuf { + self.logs().join(format!("workers/php-{php_runtime}.log")) } pub fn resource_log(&self, resource_name: &str, track: &str) -> Utf8PathBuf { @@ -202,12 +202,12 @@ impl PvPaths { self.run().join("gateway.json") } - pub fn worker_pid(&self, php_track: &str) -> Utf8PathBuf { - self.run().join(format!("workers/php-{php_track}.pid")) + pub fn worker_pid(&self, php_runtime: &str) -> Utf8PathBuf { + self.run().join(format!("workers/php-{php_runtime}.pid")) } - pub fn worker_runtime_metadata(&self, php_track: &str) -> Utf8PathBuf { - self.run().join(format!("workers/php-{php_track}.json")) + pub fn worker_runtime_metadata(&self, php_runtime: &str) -> Utf8PathBuf { + self.run().join(format!("workers/php-{php_runtime}.json")) } pub fn resource_pid(&self, resource_name: &str, track: &str) -> Utf8PathBuf { diff --git a/crates/state/src/sql/008_project_php_runtime_extensions.sql b/crates/state/src/sql/008_project_php_runtime_extensions.sql new file mode 100644 index 00000000..a75465b8 --- /dev/null +++ b/crates/state/src/sql/008_project_php_runtime_extensions.sql @@ -0,0 +1,3 @@ +ALTER TABLE projects ADD COLUMN desired_php_requested_extensions_json TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE projects ADD COLUMN desired_php_loaded_extensions_json TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE projects ADD COLUMN desired_php_ignored_extensions_json TEXT NOT NULL DEFAULT '[]'; diff --git a/crates/state/tests/snapshots/state_foundation__database_runs_migrations_and_exposes_core_schema.snap b/crates/state/tests/snapshots/state_foundation__database_runs_migrations_and_exposes_core_schema.snap index 150bae73..45688382 100644 --- a/crates/state/tests/snapshots/state_foundation__database_runs_migrations_and_exposes_core_schema.snap +++ b/crates/state/tests/snapshots/state_foundation__database_runs_migrations_and_exposes_core_schema.snap @@ -14,6 +14,7 @@ DatabaseInspection { "005:project_resource_requirements", "006:global_php_default", "007:resource_port_roles", + "008:project_php_runtime_extensions", ], tables: [ "global_php_default_track", diff --git a/crates/state/tests/snapshots/state_foundation__linked_projects_preserve_ids_and_refresh_hostnames.snap b/crates/state/tests/snapshots/state_foundation__linked_projects_preserve_ids_and_refresh_hostnames.snap index ebb10068..30c2c33a 100644 --- a/crates/state/tests/snapshots/state_foundation__linked_projects_preserve_ids_and_refresh_hostnames.snap +++ b/crates/state/tests/snapshots/state_foundation__linked_projects_preserve_ids_and_refresh_hostnames.snap @@ -1,6 +1,5 @@ --- source: crates/state/tests/state_foundation.rs -assertion_line: 730 expression: "(created.status, updated.status, database.projects()?)" --- ( @@ -16,6 +15,14 @@ expression: "(created.status, updated.status, database.projects()?)" desired_php_track: Some( "8.3", ), + php_runtime: ProjectPhpRuntimeRecord { + track: Some( + "8.3", + ), + requested_extensions: [], + loaded_extensions: [], + ignored_extensions: [], + }, additional_hostnames: [ "admin.store.test", ], diff --git a/crates/state/tests/snapshots/state_foundation__migrated_project_resource_state_round_trips_through_public_apis.snap b/crates/state/tests/snapshots/state_foundation__migrated_project_resource_state_round_trips_through_public_apis.snap index b9d890cc..ab47e355 100644 --- a/crates/state/tests/snapshots/state_foundation__migrated_project_resource_state_round_trips_through_public_apis.snap +++ b/crates/state/tests/snapshots/state_foundation__migrated_project_resource_state_round_trips_through_public_apis.snap @@ -38,6 +38,14 @@ expression: "(before, after)" desired_php_track: Some( "8.4", ), + php_runtime: ProjectPhpRuntimeRecord { + track: Some( + "8.4", + ), + requested_extensions: [], + loaded_extensions: [], + ignored_extensions: [], + }, additional_hostnames: [ "api.acme.test", ], @@ -75,6 +83,14 @@ expression: "(before, after)" desired_php_track: Some( "8.4", ), + php_runtime: ProjectPhpRuntimeRecord { + track: Some( + "8.4", + ), + requested_extensions: [], + loaded_extensions: [], + ignored_extensions: [], + }, additional_hostnames: [ "api.acme.test", ], @@ -92,6 +108,14 @@ expression: "(before, after)" desired_php_track: Some( "8.4", ), + php_runtime: ProjectPhpRuntimeRecord { + track: Some( + "8.4", + ), + requested_extensions: [], + loaded_extensions: [], + ignored_extensions: [], + }, additional_hostnames: [ "api.acme.test", ], diff --git a/crates/state/tests/snapshots/state_foundation__php_worker_port_allocator_persists_one_port_per_track.snap b/crates/state/tests/snapshots/state_foundation__php_worker_port_allocator_persists_one_port_per_track.snap index efdedee0..c11356e0 100644 --- a/crates/state/tests/snapshots/state_foundation__php_worker_port_allocator_persists_one_port_per_track.snap +++ b/crates/state/tests/snapshots/state_foundation__php_worker_port_allocator_persists_one_port_per_track.snap @@ -1,25 +1,26 @@ --- source: crates/state/tests/state_foundation.rs +assertion_line: 2886 expression: "(assigned_php84, reused_php84, assigned_php83, database.assigned_ports()?)" --- ( PortAssignment { owner: PhpWorker { - php_track: "8.4", + php_runtime_key: "8.4", }, port: 45000, updated_at: "", }, PortAssignment { owner: PhpWorker { - php_track: "8.4", + php_runtime_key: "8.4", }, port: 45000, updated_at: "", }, PortAssignment { owner: PhpWorker { - php_track: "8.3", + php_runtime_key: "8.3", }, port: 45001, updated_at: "", @@ -27,14 +28,14 @@ expression: "(assigned_php84, reused_php84, assigned_php83, database.assigned_po [ PortAssignment { owner: PhpWorker { - php_track: "8.3", + php_runtime_key: "8.3", }, port: 45001, updated_at: "", }, PortAssignment { owner: PhpWorker { - php_track: "8.4", + php_runtime_key: "8.4", }, port: 45000, updated_at: "", diff --git a/crates/state/tests/snapshots/state_foundation__runtime_observed_state_round_trips_through_observed_states.snap b/crates/state/tests/snapshots/state_foundation__runtime_observed_state_round_trips_through_observed_states.snap index 76b79d2b..065ba047 100644 --- a/crates/state/tests/snapshots/state_foundation__runtime_observed_state_round_trips_through_observed_states.snap +++ b/crates/state/tests/snapshots/state_foundation__runtime_observed_state_round_trips_through_observed_states.snap @@ -1,8 +1,19 @@ --- source: crates/state/tests/state_foundation.rs +assertion_line: 1773 expression: database.runtime_observed_states()? --- [ + RuntimeObservedStateRecord { + subject: PhpRuntimeWorker { + php_runtime_key: "8.4+redis", + }, + status: Running, + message: Some( + "extension worker is ready", + ), + observed_at: "", + }, RuntimeObservedStateRecord { subject: Gateway, status: Degraded, diff --git a/crates/state/tests/state_foundation.rs b/crates/state/tests/state_foundation.rs index 068a5fde..496bec41 100644 --- a/crates/state/tests/state_foundation.rs +++ b/crates/state/tests/state_foundation.rs @@ -1710,6 +1710,13 @@ fn runtime_observed_state_round_trips_through_observed_states() -> Result<()> { RuntimeObservedStatus::Failed, Some("readiness timed out"), )?; + database.record_runtime_observed_snapshot( + RuntimeSubject::PhpRuntimeWorker { + php_runtime_key: "8.4+redis".to_string(), + }, + RuntimeObservedStatus::Running, + Some("extension worker is ready"), + )?; database.record_runtime_observed_snapshot( RuntimeSubject::Resource { name: "mailpit".to_string(), @@ -1741,12 +1748,26 @@ fn runtime_observed_state_round_trips_through_observed_states() -> Result<()> { assert_eq!(updated_gateway.status, RuntimeObservedStatus::Degraded); assert!(matches!( invalid, - Err(StateError::InvalidRuntimeSubject { kind: "php_track", value }) if value.is_empty() + Err(StateError::InvalidRuntimeSubject { kind: "php_runtime", value }) if value.is_empty() )); assert!(matches!( reserved, - Err(StateError::InvalidRuntimeSubject { kind: "php_track", value }) if value == "latest" + Err(StateError::InvalidRuntimeSubject { kind: "php_runtime", value }) if value == "latest" )); + let connection = Connection::open(paths.db())?; + let extension_worker_kind_count = connection.query_row( + "SELECT COUNT(*) FROM observed_states WHERE subject_kind = 'php_runtime_worker' AND subject_id = '8.4+redis'", + [], + |row| row.get::<_, i64>(0), + )?; + let rollback_unsafe_worker_count = connection.query_row( + "SELECT COUNT(*) FROM observed_states WHERE subject_kind = 'runtime' AND subject_id = 'php_worker:8.4+redis'", + [], + |row| row.get::<_, i64>(0), + )?; + + assert_eq!(extension_worker_kind_count, 1); + assert_eq!(rollback_unsafe_worker_count, 0); with_normalized_timestamps(|| { assert_debug_snapshot!(database.runtime_observed_states()?); @@ -2130,6 +2151,214 @@ fn linked_projects_refresh_desired_php_track_independently() -> Result<()> { Ok(()) } +#[test] +fn project_php_runtime_extensions_round_trip_through_state() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let mut database = Database::open(&paths)?; + let project = database.link_project(state::LinkProjectInput { + path: tempdir.path().join("acme"), + original_path: tempdir.path().join("acme"), + primary_hostname: "acme.test".to_string(), + config_path: tempdir.path().join("acme/pv.yml"), + desired_php_track: Some("8.4".to_string()), + additional_hostnames: Vec::new(), + })?; + + database.replace_project_php_runtime( + &project.project.id, + Some(&state::ProjectPhpRuntimeInput { + track: "8.4".to_string(), + requested_extensions: vec!["xdebug".to_string(), "redis".to_string()], + loaded_extensions: vec!["redis".to_string(), "xdebug".to_string()], + ignored_extensions: vec!["missing".to_string()], + }), + )?; + + let project = database + .project_by_id(&project.project.id)? + .ok_or_else(|| anyhow!("missing project"))?; + + assert_eq!(project.php_runtime.track.as_deref(), Some("8.4")); + assert_eq!( + project.php_runtime.requested_extensions, + ["xdebug", "redis"] + ); + assert_eq!(project.php_runtime.loaded_extensions, ["redis", "xdebug"]); + assert_eq!(project.php_runtime.ignored_extensions, ["missing"]); + assert_eq!( + state::php_runtime_key("8.4", &project.php_runtime.loaded_extensions)?, + "8.4+redis+xdebug" + ); + + Ok(()) +} + +#[test] +fn project_php_runtime_persists_non_identity_requested_and_ignored_extensions() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let mut database = Database::open(&paths)?; + let project = database.link_project(state::LinkProjectInput { + path: tempdir.path().join("acme"), + original_path: tempdir.path().join("acme"), + primary_hostname: "acme.test".to_string(), + config_path: tempdir.path().join("acme/pv.yml"), + desired_php_track: Some("8.4".to_string()), + additional_hostnames: Vec::new(), + })?; + + database.replace_project_php_runtime( + &project.project.id, + Some(&state::ProjectPhpRuntimeInput { + track: "8.4".to_string(), + requested_extensions: vec!["not-supported-yet".to_string()], + loaded_extensions: Vec::new(), + ignored_extensions: vec!["not-supported-yet".to_string()], + }), + )?; + + let project = database + .project_by_id(&project.project.id)? + .ok_or_else(|| anyhow!("missing project"))?; + + assert_eq!( + project.php_runtime.requested_extensions, + ["not-supported-yet"] + ); + assert!(project.php_runtime.loaded_extensions.is_empty()); + assert_eq!( + project.php_runtime.ignored_extensions, + ["not-supported-yet"] + ); + assert_eq!( + state::php_runtime_key("8.4", &project.php_runtime.loaded_extensions)?, + "8.4" + ); + + Ok(()) +} + +#[test] +fn project_php_runtime_rejects_non_identity_loaded_extensions() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let mut database = Database::open(&paths)?; + let project = database.link_project(state::LinkProjectInput { + path: tempdir.path().join("acme"), + original_path: tempdir.path().join("acme"), + primary_hostname: "acme.test".to_string(), + config_path: tempdir.path().join("acme/pv.yml"), + desired_php_track: Some("8.4".to_string()), + additional_hostnames: Vec::new(), + })?; + + let error = database.replace_project_php_runtime( + &project.project.id, + Some(&state::ProjectPhpRuntimeInput { + track: "8.4".to_string(), + requested_extensions: Vec::new(), + loaded_extensions: vec!["not-supported-yet".to_string()], + ignored_extensions: Vec::new(), + }), + ); + + assert!(matches!( + error, + Err(StateError::InvalidRuntimeSubject { kind: "php_extension", value }) + if value == "not-supported-yet" + )); + + Ok(()) +} + +#[test] +fn replace_project_php_runtime_uses_public_timestamp_format() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let mut database = Database::open(&paths)?; + let project = database.link_project(state::LinkProjectInput { + path: tempdir.path().join("acme"), + original_path: tempdir.path().join("acme"), + primary_hostname: "acme.test".to_string(), + config_path: tempdir.path().join("acme/pv.yml"), + desired_php_track: Some("8.4".to_string()), + additional_hostnames: Vec::new(), + })?; + + let updated = database.replace_project_php_runtime( + &project.project.id, + Some(&state::ProjectPhpRuntimeInput { + track: "8.4".to_string(), + requested_extensions: vec!["redis".to_string()], + loaded_extensions: vec!["redis".to_string()], + ignored_extensions: Vec::new(), + }), + )?; + + assert_public_timestamp(&updated.updated_at); + + Ok(()) +} + +#[test] +fn linked_project_scalar_php_update_clears_runtime_extensions() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let mut database = Database::open(&paths)?; + let project_path = tempdir.path().join("acme"); + let project = database.link_project(state::LinkProjectInput { + path: project_path.clone(), + original_path: project_path.clone(), + primary_hostname: "acme.test".to_string(), + config_path: project_path.join("pv.yml"), + desired_php_track: Some("8.4".to_string()), + additional_hostnames: Vec::new(), + })?; + + database.replace_project_php_runtime( + &project.project.id, + Some(&state::ProjectPhpRuntimeInput { + track: "8.4".to_string(), + requested_extensions: vec!["xdebug".to_string(), "redis".to_string()], + loaded_extensions: vec!["redis".to_string(), "xdebug".to_string()], + ignored_extensions: vec!["missing".to_string()], + }), + )?; + + let updated = database.link_project(state::LinkProjectInput { + path: project_path.clone(), + original_path: project_path.clone(), + primary_hostname: "acme.test".to_string(), + config_path: project_path.join("pv.yml"), + desired_php_track: Some("8.3".to_string()), + additional_hostnames: Vec::new(), + })?; + + assert_eq!(updated.project.php_runtime.track.as_deref(), Some("8.3")); + assert!(updated.project.php_runtime.requested_extensions.is_empty()); + assert!(updated.project.php_runtime.loaded_extensions.is_empty()); + assert!(updated.project.php_runtime.ignored_extensions.is_empty()); + + Ok(()) +} + +#[test] +fn php_runtime_key_sorts_loaded_extensions() -> Result<()> { + let loaded_extensions = vec![ + "xdebug".to_string(), + "redis".to_string(), + "redis".to_string(), + ]; + + assert_eq!( + state::php_runtime_key("8.4", &loaded_extensions)?, + "8.4+redis+xdebug" + ); + + Ok(()) +} + #[test] fn linked_projects_store_original_and_canonical_paths() -> Result<()> { let tempdir = tempdir()?; @@ -2632,7 +2861,7 @@ fn php_worker_port_allocator_persists_one_port_per_track() -> Result<()> { assert_eq!( assigned_php84.owner, PortOwner::PhpWorker { - php_track: "8.4".to_string() + php_runtime_key: "8.4".to_string() } ); assert_eq!(assigned_php84.port, 45000); @@ -2640,18 +2869,17 @@ fn php_worker_port_allocator_persists_one_port_per_track() -> Result<()> { assert_eq!( assigned_php83.owner, PortOwner::PhpWorker { - php_track: "8.3".to_string() + php_runtime_key: "8.3".to_string() } ); assert_eq!(assigned_php83.port, 45001); assert!(matches!( reserved_track, - Err(StateError::ReservedConcreteTrack { track }) if track == "latest" + Err(StateError::InvalidRuntimeSubject { kind: "php_runtime", value }) if value == "latest" )); assert!(matches!( invalid_track, - Err(StateError::InvalidManagedResourceIdentity { kind: "track", value }) - if value == "../8.4" + Err(StateError::InvalidRuntimeSubject { kind: "php_runtime", value }) if value == "../8.4" )); with_normalized_timestamps(|| { @@ -2667,6 +2895,32 @@ fn php_worker_port_allocator_persists_one_port_per_track() -> Result<()> { Ok(()) } +#[test] +fn php_worker_port_allocator_uses_runtime_identity() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let mut database = Database::open(&paths)?; + + let plain = database.assign_port( + PortRequest::php_worker("8.4", 45000, 45000, 45009), + |_port| true, + )?; + let redis = database.assign_port( + PortRequest::php_worker("8.4+redis", 45000, 45000, 45009), + |_port| true, + )?; + + assert_ne!(plain.port, redis.port); + assert_eq!( + redis.owner, + PortOwner::PhpWorker { + php_runtime_key: "8.4+redis".to_string() + } + ); + + Ok(()) +} + #[test] fn gateway_port_allocator_rolls_back_when_https_cannot_be_assigned() -> Result<()> { let tempdir = tempdir()?; @@ -2885,6 +3139,20 @@ fn env_context(values: &[(&str, &str)]) -> EnvContextValues { .collect() } +fn assert_public_timestamp(timestamp: &str) { + let bytes = timestamp.as_bytes(); + assert_eq!(bytes.len(), 20, "{timestamp}"); + for index in [0_usize, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18] { + assert!(matches!(bytes.get(index), Some(byte) if byte.is_ascii_digit())); + } + assert_eq!(bytes.get(4), Some(&b'-'), "{timestamp}"); + assert_eq!(bytes.get(7), Some(&b'-'), "{timestamp}"); + assert_eq!(bytes.get(10), Some(&b'T'), "{timestamp}"); + assert_eq!(bytes.get(13), Some(&b':'), "{timestamp}"); + assert_eq!(bytes.get(16), Some(&b':'), "{timestamp}"); + assert_eq!(bytes.get(19), Some(&b'Z'), "{timestamp}"); +} + fn set_managed_resource_env_json(database: &mut Database, env_json: &str) -> Result<()> { state::testing::transaction(database, |transaction| { transaction.execute( diff --git a/docs/adr/0006-fixed-extension-php-artifacts.md b/docs/adr/0006-fixed-extension-php-artifacts.md index c060ed98..bd7393ea 100644 --- a/docs/adr/0006-fixed-extension-php-artifacts.md +++ b/docs/adr/0006-fixed-extension-php-artifacts.md @@ -1,3 +1,5 @@ # Use fixed-extension PHP and FrankenPHP artifacts +Status: Superseded by [ADR 0014](0014-project-level-php-extension-opt-ins.md). + PV v1 will avoid PHP extension management and instead distribute prebuilt macOS PHP and FrankenPHP artifacts with a common extension set baked in. Standalone PHP and FrankenPHP are built as single-binary/static-style artifacts with fixed compiled-in extensions, no Homebrew dependency, and no support for dynamic extension loading, `phpize`, or PECL-installed extensions. This keeps local setup predictable and avoids building or configuring extensions per machine or per Project, at the cost of making unsupported extension needs outside v1 scope. diff --git a/docs/adr/0014-project-level-php-extension-opt-ins.md b/docs/adr/0014-project-level-php-extension-opt-ins.md new file mode 100644 index 00000000..2c65950b --- /dev/null +++ b/docs/adr/0014-project-level-php-extension-opt-ins.md @@ -0,0 +1,13 @@ +# Support Project-level PHP extension opt-ins + +PV will support Project-level PHP extension opt-ins through `php.extensions` while keeping PHP and FrankenPHP artifacts prebuilt and PV-owned. Existing scalar PHP config remains valid, and object-form config may specify `version` and `extensions`. + +Optional extensions are not named profiles or presets. A Project lists the extensions it wants directly. PV loads only bundled optional modules available in the installed PHP artifact, ignores unsupported names, and surfaces ignored names as non-blocking diagnostics. + +PV will keep the default loaded PHP extension set lean but Laravel-practical. The initial optional catalog is `redis`, `sqlsrv`, `pdo_sqlsrv`, `xdebug`, `apcu`, `pcov`, `imagick`, `mongodb`, and `yaml`. Future extensions should be added only when user demand justifies the build, smoke-test, license, and support burden. + +Optional modules will be bundled in the existing PHP/FrankenPHP track artifacts as disabled shared modules. PV will enable them by generating runtime-specific ini overlays rather than by installing separate extension artifacts in the first implementation. This makes track artifacts somewhat larger but avoids separate extension artifact resolution, ABI matching, install/update transactions, and manifest nesting until the catalog grows enough to justify that complexity. + +Project-serving FrankenPHP workers are grouped by PHP runtime identity: resolved PHP track plus sorted available optional extension names. Standalone PHP, Composer-through-PHP, and browser execution for a Project must use the same resolved runtime identity so CLI and browser behavior do not drift. + +PV still does not support arbitrary user-provided `.so` files, local PECL installs, `phpize`, `php-config`, custom per-Project PHP ini settings, or building every extension StaticPHP supports. diff --git a/docs/superpowers/plans/2026-06-22-php-extension-opt-ins.md b/docs/superpowers/plans/2026-06-22-php-extension-opt-ins.md new file mode 100644 index 00000000..f20ba7f2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-php-extension-opt-ins.md @@ -0,0 +1,1982 @@ +# PHP Extension Opt-Ins Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Project-level PHP extension opt-ins through `php.extensions`, with bundled optional shared modules loaded by PV-generated runtime overlays. + +**Architecture:** The config crate normalizes scalar and object PHP config into a single `PhpConfig` model. Release tooling publishes optional PHP extension metadata into manifests and an artifact-local `share/pv/php-extensions.json`, and runtime code reads the installed artifact metadata so daemon restarts and shims work offline. State stores the last valid PHP runtime assignment, and the daemon/CLI use a runtime identity of resolved PHP track plus sorted loaded extension names. + +**Tech Stack:** Rust, `yaml_serde`, `serde`, `rusqlite`, StaticPHP v3 recipe shell scripts, PV artifact manifests, `insta` snapshots, and `cargo nextest`. + +## Global Constraints + +- Keep existing scalar Project config valid: `php: 8.4`. +- Support object Project config: `php.version` and `php.extensions`. +- `php.extensions` must be a YAML array of strings. +- Unsupported extension names are not Project config errors. +- Ignored extension names must be visible as non-blocking diagnostics. +- No named profiles, presets, local PECL, `phpize`, `php-config`, arbitrary `.so`, or custom Project PHP ini. +- Default loaded extensions: `bcmath`, `ctype`, `curl`, `dom`, `fileinfo`, `filter`, `hash`, `iconv`, `intl`, `json`, `libxml`, `mbstring`, `openssl`, `pcntl`, `pcre`, `pdo`, `pdo_mysql`, `pdo_pgsql`, `pdo_sqlite`, `phar`, `posix`, `session`, `simplexml`, `sodium`, `sqlite3`, `tokenizer`, `xml`, `xmlreader`, `xmlwriter`, `zip`, `zlib`. +- Initial optional catalog: `redis`, `sqlsrv`, `pdo_sqlsrv`, `xdebug`, `apcu`, `pcov`, `imagick`, `mongodb`, `yaml`. +- Runtime identity is resolved PHP track plus sorted available loaded extension names. +- Standalone PHP, Composer-through-PHP, and FrankenPHP workers for a Project must use the same loaded extension set. +- PHP and FrankenPHP remain paired artifacts per PHP track; optional extension combinations do not create separate downloaded artifact flavors. + +--- + +## References + +- Approved spec: `docs/superpowers/specs/2026-06-22-php-extension-opt-ins-design.md` +- Canonical product design: `DESIGN.md`, section `Multi-version PHP` +- Superseding ADR: `docs/adr/0014-project-level-php-extension-opt-ins.md` +- Existing PHP defaults helper: `crates/resources/src/php_defaults.rs` +- Existing PHP runtime planner: `crates/daemon/src/gateway.rs` +- Existing PHP and Composer shims: `crates/cli/src/commands/php.rs`, `crates/cli/src/commands/composer.rs` +- Existing PHP recipe metadata: `release/artifacts/recipes/php/tracks.toml` + +## File Structure + +- Modify `crates/config/src/model.rs`: add `PhpConfig`, custom serialization, and helper accessors. +- Modify `crates/config/src/parser.rs`: parse scalar and object PHP config. +- Modify `crates/config/src/writer.rs`: preserve `php.extensions` while updating `php.version`. +- Modify `crates/config/src/error.rs`: add unknown PHP key errors if needed. +- Modify `crates/config/tests/project_config.rs`: cover config shapes and writer preservation. +- Create `crates/resources/src/php_extensions.rs`: artifact-local extension metadata, request resolution, ini overlay generation, and runtime env helpers. +- Modify `crates/resources/src/manifest.rs`: parse optional PHP extension metadata from artifact entries. +- Modify `crates/resources/src/runtime.rs`: require artifact-local extension metadata files when metadata advertises modules. +- Modify `crates/resources/src/lib.rs`: export PHP extension runtime helpers. +- Modify `crates/resources/tests/*`: add manifest and runtime adapter coverage. +- Modify `crates/pv-release/src/recipe.rs`: split default loaded extensions from optional shared extensions in PHP recipes. +- Modify `crates/pv-release/src/record.rs`, `record_writer.rs`, and `manifest.rs`: carry optional PHP extension metadata through release records and generated manifests. +- Modify `release/artifacts/recipes/php/tracks.toml`: define default and optional extension sets. +- Modify `release/artifacts/recipes/php/build.sh`: build optional modules shared, stage modules, write `share/pv/php-extensions.json`, and pass metadata to release records. +- Modify `release/artifacts/recipes/php/smoke.sh`: smoke default runtime without optional modules and smoke optional modules through a temporary scan dir. +- Modify `crates/state/src/sql/008_project_php_runtime_extensions.sql`: add persisted Project runtime extension fields. +- Modify `crates/state/src/migrations.rs`: register migration 8. +- Modify `crates/state/src/database.rs`: add `ProjectPhpRuntimeInput`, runtime key validation, PHP worker runtime identity support, and persisted extension fields. +- Modify `crates/state/src/paths.rs`: treat worker path arguments as runtime keys. +- Modify `crates/state/tests/state_foundation.rs`: cover migration, runtime keys, and port identities. +- Modify `crates/daemon/src/project_env.rs`: resolve requested/loaded/ignored extensions and persist last valid runtime. +- Modify `crates/daemon/src/gateway.rs`: group workers by runtime identity, generate overlays, and use runtime-key paths. +- Modify `crates/daemon/tests/project_env_reconciliation.rs` and `gateway_reconciliation.rs`: cover runtime persistence and worker grouping. +- Modify `crates/cli/src/commands/php.rs`: resolve Project runtime for the PHP shim and use overlay env. +- Modify `crates/cli/src/commands/composer.rs`: inherit the PHP shim runtime overlay. +- Modify `crates/cli/src/commands/project.rs` and `status.rs`: surface ignored-extension warnings. +- Modify `crates/cli/tests/php.rs`, `composer.rs`, `status.rs`, and `it/cli.rs`: snapshot CLI behavior. + +## Interfaces + +Task 1 produces: + +```rust +pub struct PhpConfig { + pub version: Option, + pub extensions: Vec, +} + +impl PhpConfig { + pub fn version(version: impl Into) -> Self; + pub fn version_selector(&self) -> Option<&str>; + pub fn requested_extensions(&self) -> &[String]; +} +``` + +Task 2 produces: + +```rust +pub enum PhpExtensionLoadKind { + Extension, + ZendExtension, +} + +pub struct PhpExtensionModule { + pub name: String, + pub load_kind: PhpExtensionLoadKind, + pub relative_path: Utf8PathBuf, +} + +pub struct PhpExtensionResolution { + pub requested: Vec, + pub loaded: Vec, + pub ignored: Vec, +} + +pub fn read_php_extension_metadata(artifact_root: &Utf8Path) -> Result>; +pub fn resolve_php_extension_request( + artifact_root: &Utf8Path, + requested: &[String], +) -> Result; +pub fn ensure_php_runtime_overlay( + paths: &PvPaths, + runtime_key: &str, + artifact_root: &Utf8Path, + modules: &[PhpExtensionModule], +) -> Result; +pub fn php_runtime_environment( + paths: &PvPaths, + track: &str, + runtime_key: &str, + artifact_root: &Utf8Path, + modules: &[PhpExtensionModule], +) -> Result>; +``` + +Task 4 produces: + +```rust +pub struct ProjectPhpRuntimeInput { + pub track: String, + pub requested_extensions: Vec, + pub loaded_extensions: Vec, + pub ignored_extensions: Vec, +} + +pub struct ProjectPhpRuntimeRecord { + pub track: Option, + pub requested_extensions: Vec, + pub loaded_extensions: Vec, + pub ignored_extensions: Vec, +} + +pub fn php_runtime_key(track: &str, loaded_extensions: &[String]) -> Result; +``` + +Task 6 consumes all prior interfaces and produces runtime plans keyed by `PhpWorkerRuntimePlan.runtime_key`. + +## Task 1: Project Config PHP Object Form + +**Files:** +- Modify: `crates/config/src/model.rs` +- Modify: `crates/config/src/parser.rs` +- Modify: `crates/config/src/writer.rs` +- Modify: `crates/config/src/lib.rs` +- Test: `crates/config/tests/project_config.rs` + +**Interfaces:** +- Produces: `PhpConfig`, `PhpConfig::version`, `PhpConfig::version_selector`, `PhpConfig::requested_extensions`. +- Consumes: existing `TrackSelector::parse`, existing `ProjectConfig::parse`, existing `write_project_php_track`. + +- [ ] **Step 1: Write failing parser tests** + +Add these tests near the existing PHP config tests in `crates/config/tests/project_config.rs`: + +```rust +#[test] +fn project_config_accepts_php_object_with_version_and_extensions() -> Result<()> { + let config = ProjectConfig::parse( + r#" +php: + version: 8.4 + extensions: + - redis + - xdebug +"#, + )?; + + let php = config.php.as_ref().ok_or_else(|| anyhow!("missing php config"))?; + assert_eq!(php.version_selector(), Some("8.4")); + assert_eq!(php.requested_extensions(), ["redis", "xdebug"]); + + Ok(()) +} + +#[test] +fn project_config_accepts_php_object_with_extensions_only() -> Result<()> { + let config = ProjectConfig::parse( + r#" +php: + extensions: + - xdebug +"#, + )?; + + let php = config.php.as_ref().ok_or_else(|| anyhow!("missing php config"))?; + assert_eq!(php.version_selector(), None); + assert_eq!(php.requested_extensions(), ["xdebug"]); + + Ok(()) +} + +#[test] +fn project_config_rejects_invalid_php_extensions_shape() -> Result<()> { + assert!(matches!( + ProjectConfig::parse("php:\n extensions: redis\n"), + Err(ConfigError::InvalidFieldType { field, .. }) if field == "php.extensions" + )); + assert!(matches!( + ProjectConfig::parse("php:\n extensions:\n - true\n"), + Err(ConfigError::InvalidFieldType { field, .. }) if field == "php.extensions" + )); + + Ok(()) +} +``` + +- [ ] **Step 2: Run parser tests to verify failure** + +Run: + +```bash +cargo nextest run -p config -E 'test(project_config_accepts_php_object_with_version_and_extensions) or test(project_config_accepts_php_object_with_extensions_only) or test(project_config_rejects_invalid_php_extensions_shape)' +``` + +Expected: FAIL to compile because `PhpConfig` and its accessors do not exist. + +- [ ] **Step 3: Implement `PhpConfig` model and serialization** + +In `crates/config/src/model.rs`, replace the `php: Option` field with `php: Option` and add: + +```rust +use serde::ser::SerializeMap; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PhpConfig { + pub version: Option, + pub extensions: Vec, +} + +impl PhpConfig { + pub fn version(version: impl Into) -> Self { + Self { + version: Some(version.into()), + extensions: Vec::new(), + } + } + + pub fn version_selector(&self) -> Option<&str> { + self.version.as_deref() + } + + pub fn requested_extensions(&self) -> &[String] { + &self.extensions + } +} + +impl Serialize for PhpConfig { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if self.extensions.is_empty() + && let Some(version) = &self.version + { + return version.serialize(serializer); + } + + let field_count = usize::from(self.version.is_some()) + usize::from(!self.extensions.is_empty()); + let mut map = serializer.serialize_map(Some(field_count))?; + if let Some(version) = &self.version { + map.serialize_entry("version", version)?; + } + if !self.extensions.is_empty() { + map.serialize_entry("extensions", &self.extensions)?; + } + map.end() + } +} +``` + +Update the public export in `crates/config/src/lib.rs`: + +```rust +pub use model::{AllocationConfig, PhpConfig, ProjectConfig, ProjectConfigFile, ResourceConfig}; +``` + +- [ ] **Step 4: Implement PHP object parsing** + +In `crates/config/src/parser.rs`, import `PhpConfig` and replace `php_track` with this shape: + +```rust +fn php_config(value: &Value) -> Result { + match value { + Value::Mapping(mapping) => php_config_mapping(mapping), + value => php_track(value).map(PhpConfig::version), + } +} + +fn php_config_mapping(mapping: &Mapping) -> Result { + let mut config = PhpConfig::default(); + + for (key, value) in mapping { + let key = string_key_ref(key)?; + match key.as_str() { + "version" => { + config.version = Some(php_track_field("php.version", value)?); + } + "extensions" => { + config.extensions = php_extensions(value)?; + } + _ => { + return Err(ConfigError::UnknownPhpKey { key }); + } + } + } + + Ok(config) +} + +fn php_track_field(field: &str, value: &Value) -> Result { + let track = non_empty_string_or_number(field, value)?; + TrackSelector::parse(track.clone()).map_err(|source| ConfigError::InvalidPhpTrack { + track: track.clone(), + reason: source.to_string(), + })?; + + Ok(track) +} + +fn php_extensions(value: &Value) -> Result, ConfigError> { + let sequence = match value { + Value::Null => return Ok(Vec::new()), + Value::Sequence(sequence) => sequence, + value => { + return Err(ConfigError::InvalidFieldType { + field: "php.extensions".to_string(), + expected: "a sequence", + found: value_type(value), + }); + } + }; + + sequence + .iter() + .map(|value| non_empty_string("php.extensions", value)) + .collect() +} +``` + +Change the top-level parser branch to: + +```rust +"php" => { + config.php = Some(php_config(&value)?); +} +``` + +Add the error variant to `crates/config/src/error.rs`: + +```rust +#[error("unknown Project config key `php.{key}`")] +UnknownPhpKey { key: String }, +``` + +- [ ] **Step 5: Preserve extensions in `php:use` writer** + +In `crates/config/src/writer.rs`, replace direct assignment with: + +```rust +let php = config_file + .config + .php + .get_or_insert_with(config::PhpConfig::default); +php.version = Some(track.to_string()); +``` + +Use `crate::PhpConfig::default` if the module cannot refer to `config::PhpConfig` internally. + +- [ ] **Step 6: Run parser and writer tests** + +Run: + +```bash +cargo nextest run -p config -E 'test(project_config_accepts_php_object_with_version_and_extensions) or test(project_config_accepts_php_object_with_extensions_only) or test(project_config_rejects_invalid_php_extensions_shape) or test(project_config_writer_updates_php_in_discovered_file)' +``` + +Expected: PASS. + +- [ ] **Step 7: Commit Task 1** + +```bash +git add crates/config/src/model.rs crates/config/src/parser.rs crates/config/src/writer.rs crates/config/src/lib.rs crates/config/src/error.rs crates/config/tests/project_config.rs +git commit -m "feat(config): parse PHP extension requests" +``` + +## Task 2: PHP Extension Metadata And Overlay Helpers + +**Files:** +- Create: `crates/resources/src/php_extensions.rs` +- Modify: `crates/resources/src/lib.rs` +- Modify: `crates/resources/src/manifest.rs` +- Modify: `crates/resources/src/runtime.rs` +- Test: `crates/resources/tests/php_extensions.rs` +- Test: `crates/resources/tests/manifest_foundation.rs` + +**Interfaces:** +- Consumes: `PvPaths`, installed artifact root paths, and requested extension names. +- Produces: `PhpExtensionModule`, `PhpExtensionLoadKind`, `PhpExtensionResolution`, `resolve_php_extension_request`, `ensure_php_runtime_overlay`, `php_runtime_environment`. + +- [ ] **Step 1: Write failing PHP extension metadata tests** + +Create `crates/resources/tests/php_extensions.rs`: + +```rust +use anyhow::Result; +use camino_tempfile::tempdir; +use resources::{ + PhpExtensionLoadKind, ensure_php_runtime_overlay, php_runtime_environment, + resolve_php_extension_request, +}; +use state::{PvPaths, fs}; + +#[test] +fn resolves_available_and_ignored_php_extensions_from_artifact_metadata() -> Result<()> { + let tempdir = tempdir()?; + let artifact = tempdir.path().join("php"); + fs::write_sensitive_file( + &artifact.join("share/pv/php-extensions.json"), + r#" +[ + {"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}, + {"name":"xdebug","load_kind":"zend_extension","path":"lib/php/extensions/xdebug.so"} +] +"#, + )?; + + let resolution = + resolve_php_extension_request(&artifact, &["xdebug".into(), "missing".into(), "redis".into()])?; + + assert_eq!(resolution.requested, ["xdebug", "missing", "redis"]); + assert_eq!( + resolution + .loaded + .iter() + .map(|module| module.name.as_str()) + .collect::>(), + ["redis", "xdebug"] + ); + assert_eq!(resolution.loaded[1].load_kind, PhpExtensionLoadKind::ZendExtension); + assert_eq!(resolution.ignored, ["missing"]); + + Ok(()) +} + +#[test] +fn writes_runtime_overlay_for_loaded_php_extensions() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let artifact = tempdir.path().join("php"); + fs::write_sensitive_file( + &artifact.join("share/pv/php-extensions.json"), + r#" +[ + {"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}, + {"name":"xdebug","load_kind":"zend_extension","path":"lib/php/extensions/xdebug.so"} +] +"#, + )?; + let resolution = resolve_php_extension_request(&artifact, &["redis".into(), "xdebug".into()])?; + + let overlay = ensure_php_runtime_overlay(&paths, "8.4+redis+xdebug", &artifact, &resolution.loaded)?; + let redis_ini = fs::read_to_string(&overlay.join("10-redis.ini"))?; + let xdebug_ini = fs::read_to_string(&overlay.join("20-xdebug.ini"))?; + let env = php_runtime_environment(&paths, "8.4", "8.4+redis+xdebug", &artifact, &resolution.loaded)?; + + assert!(redis_ini.contains("extension=")); + assert!(redis_ini.contains("redis.so")); + assert!(xdebug_ini.contains("zend_extension=")); + assert!(xdebug_ini.contains("xdebug.so")); + assert!(env["PHP_INI_SCAN_DIR"].contains("conf.d")); + assert!(env["PHP_INI_SCAN_DIR"].contains("php-runtimes/8.4+redis+xdebug/conf.d")); + + Ok(()) +} +``` + +- [ ] **Step 2: Run metadata tests to verify failure** + +Run: + +```bash +cargo nextest run -p resources -E 'test(resolves_available_and_ignored_php_extensions_from_artifact_metadata) or test(writes_runtime_overlay_for_loaded_php_extensions)' +``` + +Expected: FAIL to compile because the exported helper types and functions do not exist. + +- [ ] **Step 3: Implement `php_extensions.rs`** + +Create `crates/resources/src/php_extensions.rs`: + +```rust +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; + +use camino::{Utf8Path, Utf8PathBuf}; +use serde::Deserialize; +use state::{PvPaths, StateError, fs}; + +use crate::{ResourcesError, Result, php_track_environment}; + +pub const PHP_EXTENSION_METADATA_PATH: &str = "share/pv/php-extensions.json"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub enum PhpExtensionLoadKind { + Extension, + ZendExtension, +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub struct PhpExtensionModule { + pub name: String, + pub load_kind: PhpExtensionLoadKind, + pub relative_path: Utf8PathBuf, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PhpExtensionResolution { + pub requested: Vec, + pub loaded: Vec, + pub ignored: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawPhpExtensionModule { + name: String, + load_kind: String, + path: String, +} + +pub fn read_php_extension_metadata(artifact_root: &Utf8Path) -> Result> { + let path = artifact_root.join(PHP_EXTENSION_METADATA_PATH); + if !fs::path_entry_exists(&path)? { + return Ok(Vec::new()); + } + + let source = fs::read_to_string(&path)?; + let raw = serde_json::from_str::>(&source) + .map_err(|error| ResourcesError::InvalidArtifactLayout { + resource: "php".to_string(), + reason: format!("invalid PHP extension metadata: {error}"), + })?; + + raw.into_iter().map(PhpExtensionModule::from_raw).collect() +} + +pub fn resolve_php_extension_request( + artifact_root: &Utf8Path, + requested: &[String], +) -> Result { + let mut catalog = BTreeMap::new(); + for module in read_php_extension_metadata(artifact_root)? { + catalog.insert(module.name.clone(), module); + } + + let requested = requested.to_vec(); + let mut requested_unique = BTreeSet::new(); + let mut loaded = BTreeSet::new(); + let mut ignored = Vec::new(); + + for name in &requested { + if !requested_unique.insert(name.clone()) { + continue; + } + if let Some(module) = catalog.get(name) { + loaded.insert(module.clone()); + } else { + ignored.push(name.clone()); + } + } + + Ok(PhpExtensionResolution { + requested, + loaded: loaded.into_iter().collect(), + ignored, + }) +} + +pub fn ensure_php_runtime_overlay( + paths: &PvPaths, + runtime_key: &str, + artifact_root: &Utf8Path, + modules: &[PhpExtensionModule], +) -> Result { + let conf_dir = paths + .config() + .join("php-runtimes") + .join(runtime_key) + .join("conf.d"); + fs::ensure_user_dir(&conf_dir)?; + + for (index, module) in modules.iter().enumerate() { + let prefix = 10 + (index * 10); + let directive = match module.load_kind { + PhpExtensionLoadKind::Extension => "extension", + PhpExtensionLoadKind::ZendExtension => "zend_extension", + }; + let module_path = artifact_root.join(&module.relative_path); + let ini = format!("{directive}={module_path}\n"); + fs::write_sensitive_file(&conf_dir.join(format!("{prefix}-{}.ini", module.name)), &ini)?; + } + + Ok(conf_dir) +} + +pub fn php_runtime_environment( + paths: &PvPaths, + track: &str, + runtime_key: &str, + artifact_root: &Utf8Path, + modules: &[PhpExtensionModule], +) -> Result> { + let mut environment = php_track_environment(paths, track)?; + if !modules.is_empty() { + let overlay = ensure_php_runtime_overlay(paths, runtime_key, artifact_root, modules)?; + if let Some(scan_dir) = environment.get_mut("PHP_INI_SCAN_DIR") { + scan_dir.push(':'); + scan_dir.push_str(overlay.as_str()); + } + } + + Ok(environment) +} + +pub fn php_runtime_exec_environment( + paths: &PvPaths, + track: &str, + runtime_key: &str, + artifact_root: &Utf8Path, + modules: &[PhpExtensionModule], +) -> Result> { + Ok(php_runtime_environment(paths, track, runtime_key, artifact_root, modules)? + .into_iter() + .map(|(key, value)| (OsString::from(key), OsString::from(value))) + .collect()) +} + +impl PhpExtensionModule { + fn from_raw(raw: RawPhpExtensionModule) -> Result { + validate_extension_name(&raw.name)?; + let relative_path = validate_relative_path(raw.path)?; + let load_kind = match raw.load_kind.as_str() { + "extension" => PhpExtensionLoadKind::Extension, + "zend_extension" => PhpExtensionLoadKind::ZendExtension, + _ => { + return Err(ResourcesError::InvalidArtifactLayout { + resource: "php".to_string(), + reason: format!("invalid PHP extension load kind `{}`", raw.load_kind), + }); + } + }; + + Ok(Self { + name: raw.name, + load_kind, + relative_path, + }) + } +} + +fn validate_extension_name(name: &str) -> Result<()> { + let valid = !name.is_empty() + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'); + if valid { + return Ok(()); + } + + Err(ResourcesError::InvalidArtifactLayout { + resource: "php".to_string(), + reason: format!("invalid PHP extension name `{name}`"), + }) +} + +fn validate_relative_path(path: String) -> Result { + let path = Utf8PathBuf::from(path); + if path.is_absolute() || path.components().any(|component| component.as_str() == "..") { + return Err(ResourcesError::InvalidArtifactLayout { + resource: "php".to_string(), + reason: format!("invalid PHP extension path `{path}`"), + }); + } + + Ok(path) +} +``` + +- [ ] **Step 4: Export helpers and parse manifest metadata** + +In `crates/resources/src/lib.rs`: + +```rust +pub mod php_extensions; +pub use php_extensions::{ + PHP_EXTENSION_METADATA_PATH, PhpExtensionLoadKind, PhpExtensionModule, PhpExtensionResolution, + ensure_php_runtime_overlay, php_runtime_environment, php_runtime_exec_environment, + read_php_extension_metadata, resolve_php_extension_request, +}; +``` + +In `crates/resources/src/manifest.rs`, add a defaulted raw field and public accessor: + +```rust +#[derive(Debug, Deserialize)] +struct RawArtifact { + artifact_version: String, + upstream_version: String, + pv_build_revision: String, + platform: String, + url: String, + sha256: String, + size: u64, + published_at: String, + #[serde(default)] + php_extensions: Vec, + #[serde(default)] + revoked: bool, + #[serde(default)] + revocation_reason: Option, +} +``` + +Add manifest module structs mirroring `PhpExtensionModule`, then store `php_extensions: Vec` on `ManifestArtifact` and expose: + +```rust +pub fn php_extensions(&self) -> &[PhpExtensionModule] { + &self.php_extensions +} +``` + +- [ ] **Step 5: Run resources tests** + +Run: + +```bash +cargo nextest run -p resources -E 'test(resolves_available_and_ignored_php_extensions_from_artifact_metadata) or test(writes_runtime_overlay_for_loaded_php_extensions) or test(manifest_parses_registry_backed_resources_tracks_and_artifacts)' +``` + +Expected: PASS. + +- [ ] **Step 6: Commit Task 2** + +```bash +git add crates/resources/src/php_extensions.rs crates/resources/src/lib.rs crates/resources/src/manifest.rs crates/resources/src/runtime.rs crates/resources/tests/php_extensions.rs crates/resources/tests +git commit -m "feat(resources): add PHP extension metadata helpers" +``` + +## Task 3: Release Metadata And PHP Artifact Recipe Split + +**Files:** +- Modify: `release/artifacts/recipes/php/tracks.toml` +- Modify: `release/artifacts/recipes/php/build.sh` +- Modify: `release/artifacts/recipes/php/smoke.sh` +- Modify: `crates/pv-release/src/recipe.rs` +- Modify: `crates/pv-release/src/record.rs` +- Modify: `crates/pv-release/src/record_writer.rs` +- Modify: `crates/pv-release/src/manifest.rs` +- Test: `crates/pv-release/tests/recipe_metadata.rs` +- Test: `crates/pv-release/tests/release_records.rs` +- Test: `crates/pv-release/tests/manifest_generation.rs` +- Test: `crates/pv-release/tests/smoke.rs` + +**Interfaces:** +- Consumes: `PhpExtensionModule` JSON schema from Task 2. +- Produces: `php.default_extensions`, `php.optional_extensions`, `PV_DEFAULT_EXTENSIONS`, `PV_OPTIONAL_EXTENSIONS`, and manifest `php_extensions`. + +- [ ] **Step 1: Write failing recipe metadata test** + +In `crates/pv-release/tests/recipe_metadata.rs`, add: + +```rust +#[test] +fn php_recipe_splits_default_and_optional_extensions() -> Result<()> { + let tempdir = tempdir()?; + let php = write_php_recipe(&tempdir)?; + let env = php_recipe_env(&php, "php", "8.4", "darwin-arm64")?; + + assert!(env.contains("PV_DEFAULT_EXTENSIONS='bcmath,curl,intl,mbstring,openssl,pcntl,pdo_mysql,pdo_pgsql,pdo_sqlite,sodium,zip'")); + assert!(env.contains("PV_OPTIONAL_EXTENSIONS='redis,sqlsrv,pdo_sqlsrv,xdebug,apcu,pcov,imagick,mongodb,yaml'")); + assert!(env.contains("PV_EXPECTED_EXTENSIONS='bcmath,ctype,curl")); + assert!(!env.contains("PV_BUILD_EXTENSIONS='")); + + Ok(()) +} +``` + +- [ ] **Step 2: Run recipe metadata test to verify failure** + +Run: + +```bash +cargo nextest run -p pv-release -E 'test(php_recipe_splits_default_and_optional_extensions)' +``` + +Expected: FAIL because the recipe still has `build_extensions` only. + +- [ ] **Step 3: Update PHP recipe model** + +In `crates/pv-release/src/recipe.rs`, replace `build_extensions` in `RawPhpSettings` and `PhpSettings`: + +```rust +#[derive(Clone, Debug)] +pub struct PhpSettings { + deployment_target: String, + default_extensions: Vec, + optional_extensions: Vec, + expected_extensions: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawPhpSettings { + deployment_target: String, + default_extensions: Vec, + optional_extensions: Vec, + expected_extensions: Vec, +} +``` + +Update `PhpSettings::from_raw` to validate both lists: + +```rust +validate_expected_extensions(path, &raw.expected_extensions)?; +validate_build_extensions(path, &raw.default_extensions, &raw.expected_extensions)?; +validate_extension_list(path, "php.optional_extensions", &raw.optional_extensions)?; +``` + +Change `php_recipe_env` assignments: + +```rust +let default_extensions = recipe.default_extensions().join(","); +let optional_extensions = recipe.optional_extensions().join(","); +let build_extensions = if optional_extensions.is_empty() { + default_extensions.clone() +} else { + format!("{default_extensions},{optional_extensions}") +}; +``` + +Emit: + +```rust +("PV_DEFAULT_EXTENSIONS", "default_extensions", default_extensions.as_str()), +("PV_OPTIONAL_EXTENSIONS", "optional_extensions", optional_extensions.as_str()), +("PV_BUILD_EXTENSIONS", "build_extensions", build_extensions.as_str()), +("PV_EXPECTED_EXTENSIONS", "expected_extensions", expected_extensions.as_str()), +``` + +- [ ] **Step 4: Update `tracks.toml` extension split** + +In `release/artifacts/recipes/php/tracks.toml`, replace `[php].build_extensions` with: + +```toml +default_extensions = [ + "bcmath", + "ctype", + "curl", + "dom", + "fileinfo", + "filter", + "iconv", + "intl", + "libxml", + "mbstring", + "openssl", + "pcntl", + "pdo", + "pdo_mysql", + "pdo_pgsql", + "pdo_sqlite", + "phar", + "posix", + "session", + "simplexml", + "sodium", + "sqlite3", + "tokenizer", + "xml", + "xmlreader", + "xmlwriter", + "zip", + "zlib", +] +optional_extensions = [ + "redis", + "sqlsrv", + "pdo_sqlsrv", + "xdebug", + "apcu", + "pcov", + "imagick", + "mongodb", + "yaml", +] +``` + +Remove `redis`, `sqlsrv`, and `pdo_sqlsrv` from `expected_extensions`; keep implicit `hash`, `json`, and `pcre`. + +- [ ] **Step 5: Carry extension metadata through records and manifests** + +In `crates/pv-release/src/record.rs`, add defaulted fields: + +```rust +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct PhpExtensionRecord { + name: String, + load_kind: String, + path: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawReleaseRecord { + ... + #[serde(default)] + php_extensions: Vec, + provenance: Provenance, +} +``` + +Expose: + +```rust +pub fn php_extensions(&self) -> &[PhpExtensionRecord] { + &self.php_extensions +} +``` + +In `crates/pv-release/src/record_writer.rs`, add `php_extensions` to the JSON request and generated record. In `crates/pv-release/src/manifest.rs`, serialize `php_extensions` on each `ManifestArtifactJson` with `#[serde(skip_serializing_if = "Vec::is_empty")]`. + +- [ ] **Step 6: Update build script staging** + +In `release/artifacts/recipes/php/build.sh`, build shared optional modules by changing the StaticPHP command: + +```sh +optional_shared_args= +if [ -n "$PHP_OPTIONAL_EXTENSIONS" ]; then + optional_shared_args="--build-shared=$PHP_OPTIONAL_EXTENSIONS" +fi + +spc build:php "$PHP_BUILD_EXTENSIONS" \ + $optional_shared_args \ + --build-cli \ + --build-frankenphp \ + --enable-zts \ + --with-config-file-path=/var/empty/com.prvious.pv/php \ + --with-config-file-scan-dir=/var/empty/com.prvious.pv/php/conf.d \ + --dl-with-php="$PHP_PHP_VERSION" \ + --dl-retry=3 \ + --dl-custom-local "php-src:$php_source_dir" \ + --dl-custom-local "frankenphp:$frankenphp_source_dir" +``` + +After copying `bin/php` or `bin/frankenphp`, stage optional modules and metadata: + +```sh +stage_optional_php_extensions() { + root_dir=$1 + mkdir -p "$root_dir/lib/php/extensions" "$root_dir/share/pv" + metadata="$root_dir/share/pv/php-extensions.json" + printf '[' >"$metadata" + first=1 + old_ifs=$IFS + IFS=, + for extension in $PHP_OPTIONAL_EXTENSIONS; do + [ -n "$extension" ] || continue + module=$(find "$spc_work_dir/buildroot" -type f -name "$extension.so" | head -n 1) + [ -n "$module" ] || die "optional PHP extension $extension did not produce a shared module" + cp "$module" "$root_dir/lib/php/extensions/$extension.so" + load_kind=extension + [ "$extension" = "xdebug" ] && load_kind=zend_extension + [ "$first" -eq 1 ] || printf ',' >>"$metadata" + first=0 + printf '{"name":"%s","load_kind":"%s","path":"lib/php/extensions/%s.so"}' "$extension" "$load_kind" "$extension" >>"$metadata" + done + IFS=$old_ifs + printf ']\n' >>"$metadata" +} +``` + +Call it from `stage_artifact` after copying the binary: + +```sh +stage_optional_php_extensions "$root_dir" +``` + +- [ ] **Step 7: Update PHP smoke hook** + +In `release/artifacts/recipes/php/smoke.sh`, require default extensions only for the normal run. Add optional-module smoke: + +```sh +check_optional_extensions() { + metadata="$artifact_root/share/pv/php-extensions.json" + [ -f "$metadata" ] || return 0 + need python3 + scan_dir=$(mktemp -d) + python3 - "$metadata" "$artifact_root" "$scan_dir" <<'PY' +import json +import pathlib +import sys + +metadata = pathlib.Path(sys.argv[1]) +artifact_root = pathlib.Path(sys.argv[2]) +scan_dir = pathlib.Path(sys.argv[3]) +for index, module in enumerate(json.loads(metadata.read_text())): + directive = module["load_kind"] + path = artifact_root / module["path"] + prefix = 10 + index * 10 + (scan_dir / f"{prefix}-{module['name']}.ini").write_text(f"{directive}={path}\n") +PY + PHP_INI_SCAN_DIR="$scan_dir" check_extensions "$php_binary" -m + rm -rf "$scan_dir" +} +``` + +Call `check_optional_extensions` after the normal PHP CLI extension check when `bin/php` is present. + +- [ ] **Step 8: Run release tests** + +Run: + +```bash +cargo nextest run -p pv-release -E 'test(php_recipe_splits_default_and_optional_extensions) or test(release_record) or test(manifest_generator)' +``` + +Run shell syntax: + +```bash +shellcheck release/artifacts/recipes/php/build.sh release/artifacts/recipes/php/smoke.sh +``` + +Expected: PASS. + +- [ ] **Step 9: Commit Task 3** + +```bash +git add release/artifacts/recipes/php/tracks.toml release/artifacts/recipes/php/build.sh release/artifacts/recipes/php/smoke.sh crates/pv-release/src crates/pv-release/tests +git commit -m "feat(release): bundle optional PHP extension metadata" +``` + +## Task 4: Persist PHP Runtime Extension State + +**Files:** +- Create: `crates/state/src/sql/008_project_php_runtime_extensions.sql` +- Modify: `crates/state/src/migrations.rs` +- Modify: `crates/state/src/database.rs` +- Modify: `crates/state/src/lib.rs` +- Modify: `crates/state/src/paths.rs` +- Test: `crates/state/tests/state_foundation.rs` + +**Interfaces:** +- Consumes: loaded extension names from runtime resolution. +- Produces: `ProjectPhpRuntimeInput`, `ProjectPhpRuntimeRecord`, `php_runtime_key`, runtime-key-safe worker subjects and ports. + +- [ ] **Step 1: Write failing state tests** + +Add tests in `crates/state/tests/state_foundation.rs`: + +```rust +#[test] +fn project_php_runtime_extensions_round_trip_through_state() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let mut database = Database::open(&paths)?; + let project = database.link_project(state::LinkProjectInput { + path: tempdir.path().join("acme"), + original_path: tempdir.path().join("acme"), + primary_hostname: "acme.test".to_string(), + config_path: tempdir.path().join("acme/pv.yml"), + desired_php_track: Some("8.4".to_string()), + additional_hostnames: Vec::new(), + })?; + + database.replace_project_php_runtime( + &project.project.id, + Some(&state::ProjectPhpRuntimeInput { + track: "8.4".to_string(), + requested_extensions: vec!["xdebug".to_string(), "redis".to_string()], + loaded_extensions: vec!["redis".to_string(), "xdebug".to_string()], + ignored_extensions: vec!["missing".to_string()], + }), + )?; + + let project = database + .project_by_id(&project.project.id)? + .ok_or_else(|| anyhow!("missing project"))?; + + assert_eq!(project.php_runtime.track.as_deref(), Some("8.4")); + assert_eq!(project.php_runtime.loaded_extensions, ["redis", "xdebug"]); + assert_eq!(project.php_runtime.ignored_extensions, ["missing"]); + assert_eq!( + state::php_runtime_key("8.4", &project.php_runtime.loaded_extensions)?, + "8.4+redis+xdebug" + ); + + Ok(()) +} + +#[test] +fn php_worker_port_allocator_uses_runtime_identity() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let mut database = Database::open(&paths)?; + + let plain = database.assign_port( + PortRequest::php_worker("8.4", 45000, 45000, 45009), + |_port| true, + )?; + let redis = database.assign_port( + PortRequest::php_worker("8.4+redis", 45000, 45000, 45009), + |_port| true, + )?; + + assert_ne!(plain.port, redis.port); + assert_eq!( + redis.owner, + PortOwner::PhpWorker { + php_track: "8.4+redis".to_string() + } + ); + + Ok(()) +} +``` + +- [ ] **Step 2: Run state tests to verify failure** + +Run: + +```bash +cargo nextest run -p state -E 'test(project_php_runtime_extensions_round_trip_through_state) or test(php_worker_port_allocator_uses_runtime_identity)' +``` + +Expected: FAIL to compile because runtime extension state APIs do not exist. + +- [ ] **Step 3: Add migration 8** + +Create `crates/state/src/sql/008_project_php_runtime_extensions.sql`: + +```sql +ALTER TABLE projects ADD COLUMN desired_php_requested_extensions_json TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE projects ADD COLUMN desired_php_loaded_extensions_json TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE projects ADD COLUMN desired_php_ignored_extensions_json TEXT NOT NULL DEFAULT '[]'; +``` + +Register in `crates/state/src/migrations.rs`: + +```rust +const PROJECT_PHP_RUNTIME_EXTENSIONS_SQL: &str = + include_str!("sql/008_project_php_runtime_extensions.sql"); + +Migration::new( + 8, + "project_php_runtime_extensions", + PROJECT_PHP_RUNTIME_EXTENSIONS_SQL, +), +``` + +- [ ] **Step 4: Add runtime state structs and JSON helpers** + +In `crates/state/src/database.rs`, add: + +```rust +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ProjectPhpRuntimeRecord { + pub track: Option, + pub requested_extensions: Vec, + pub loaded_extensions: Vec, + pub ignored_extensions: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectPhpRuntimeInput { + pub track: String, + pub requested_extensions: Vec, + pub loaded_extensions: Vec, + pub ignored_extensions: Vec, +} +``` + +Add `pub php_runtime: ProjectPhpRuntimeRecord` to `ProjectRecord`. Keep `desired_php_track` for compatibility and existing callers. + +Add: + +```rust +pub fn php_runtime_key(track: &str, loaded_extensions: &[String]) -> Result { + validate_project_php_track(track)?; + for extension in loaded_extensions { + validate_php_extension_identity(extension)?; + } + if loaded_extensions.is_empty() { + return Ok(track.to_string()); + } + + Ok(format!("{track}+{}", loaded_extensions.join("+"))) +} +``` + +Add validators for extension identities using ASCII alphanumeric plus `_`. + +- [ ] **Step 5: Add `replace_project_php_runtime`** + +In `impl Database`, add: + +```rust +pub fn replace_project_php_runtime( + &mut self, + project_id: &str, + runtime: Option<&ProjectPhpRuntimeInput>, +) -> Result { + let (track, requested, loaded, ignored) = match runtime { + Some(runtime) => { + validate_project_php_track(&runtime.track)?; + validate_php_extension_list(&runtime.requested_extensions)?; + validate_php_extension_list(&runtime.loaded_extensions)?; + validate_php_extension_list(&runtime.ignored_extensions)?; + ( + Some(runtime.track.as_str()), + extension_json(&runtime.requested_extensions)?, + extension_json(&runtime.loaded_extensions)?, + extension_json(&runtime.ignored_extensions)?, + ) + } + None => (None, "[]".to_string(), "[]".to_string(), "[]".to_string()), + }; + + self.connection.execute( + "UPDATE projects + SET desired_php_track = ?2, + desired_php_requested_extensions_json = ?3, + desired_php_loaded_extensions_json = ?4, + desired_php_ignored_extensions_json = ?5, + updated_at = datetime('now') + WHERE id = ?1", + rusqlite::params![project_id, track, requested, loaded, ignored], + )?; + + self.project_by_id(project_id)?.ok_or_else(|| StateError::ProjectNotFound { + target: project_id.to_string(), + }) +} +``` + +Update `replace_project_desired_php_track` to call `replace_project_php_runtime` with empty extension lists. + +- [ ] **Step 6: Allow PHP worker ports and subjects to use runtime keys** + +Replace `validate_runtime_php_track` internals with runtime-key validation. `RuntimeSubject::PhpWorker { php_track }` can keep the existing field name for a smaller diff, but its value is now a runtime key. + +Update error kind strings from `"php_track"` to `"php_runtime"` in new tests, then update snapshots. + +- [ ] **Step 7: Run state tests** + +Run: + +```bash +cargo nextest run -p state -E 'test(project_php_runtime_extensions_round_trip_through_state) or test(php_worker_port_allocator_uses_runtime_identity) or test(runtime_observed_state_round_trips_through_observed_states) or test(database_runs_migrations_and_exposes_core_schema)' +``` + +Expected: PASS after accepting intentional snapshots: + +```bash +cargo insta test --accept --test-runner nextest -p state -- state_foundation +``` + +- [ ] **Step 8: Commit Task 4** + +```bash +git add crates/state/src/sql/008_project_php_runtime_extensions.sql crates/state/src/migrations.rs crates/state/src/database.rs crates/state/src/lib.rs crates/state/src/paths.rs crates/state/tests/state_foundation.rs crates/state/tests/snapshots +git commit -m "feat(state): persist PHP runtime extension identity" +``` + +## Task 5: Daemon Runtime Resolution And Worker Grouping + +**Files:** +- Modify: `crates/daemon/src/project_env.rs` +- Modify: `crates/daemon/src/gateway.rs` +- Test: `crates/daemon/tests/project_env_reconciliation.rs` +- Test: `crates/daemon/tests/gateway_reconciliation.rs` + +**Interfaces:** +- Consumes: `PhpConfig`, `ProjectPhpRuntimeInput`, `php_runtime_key`, `resolve_php_extension_request`, `php_runtime_environment`. +- Produces: Project reconciliation persists requested/loaded/ignored extensions; gateway groups by runtime key. + +- [ ] **Step 1: Write failing project env reconciliation test** + +In `crates/daemon/tests/project_env_reconciliation.rs`, add: + +```rust +#[tokio::test] +async fn project_env_reconciliation_persists_php_extension_runtime() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let project = link_project( + &paths, + &tempdir.path().join("project"), + "acme.test", + "php:\n version: \"8.4\"\n extensions: [redis, missing]\n", + )?; + let release = tempdir.path().join("php-release"); + state::fs::write_sensitive_file(&release.join("bin/php"), "#!/bin/sh\n")?; + state::fs::write_sensitive_file( + &release.join("share/pv/php-extensions.json"), + r#"[{"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}]"#, + )?; + { + let mut database = Database::open(&paths)?; + database.record_managed_resource_track_installed("php", "8.4", "8.4.8-pv1", &release)?; + } + + run_project_reconciliation(&paths, &project).await?; + + let database = Database::open(&paths)?; + let project = database + .project_by_id(&project.id)? + .ok_or_else(|| anyhow!("expected linked project"))?; + let observed = database + .project_env_observed_state(&project.id)? + .ok_or_else(|| anyhow!("expected observed project env state"))?; + + assert_eq!(project.php_runtime.track.as_deref(), Some("8.4")); + assert_eq!(project.php_runtime.requested_extensions, ["redis", "missing"]); + assert_eq!(project.php_runtime.loaded_extensions, ["redis"]); + assert_eq!(project.php_runtime.ignored_extensions, ["missing"]); + assert_eq!(observed.status, ProjectEnvObservedStatus::Warning); + assert_eq!(observed.warnings[0].kind, "ignored_php_extension"); + + Ok(()) +} +``` + +- [ ] **Step 2: Write failing gateway grouping test** + +In `crates/daemon/tests/gateway_reconciliation.rs`, add a plan-level test near existing `build_runtime_plan` coverage: + +```rust +#[test] +fn gateway_runtime_plan_groups_projects_by_php_track_and_extensions() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let acme = create_project_with_config( + tempdir.path(), + "acme", + "php:\n version: 8.4\n extensions: [redis]\n", + )?; + let api = create_project_with_config( + tempdir.path(), + "api", + "php:\n version: 8.4\n extensions: [xdebug, redis]\n", + )?; + let release = seed_installed_php_with_extensions(&paths, "8.4", &["redis", "xdebug"])?; + seed_installed_frankenphp_with_extensions(&paths, "8.4", &release, &["redis", "xdebug"])?; + link_project_record(&paths, &acme, "acme.test", Some("8.4"))?; + link_project_record(&paths, &api, "api.test", Some("8.4"))?; + + let plan = daemon::gateway::build_runtime_plan(&paths)?; + let runtime_keys = plan + .workers + .iter() + .map(|worker| worker.runtime_key.as_str()) + .collect::>(); + + assert_eq!(runtime_keys, ["8.4+redis", "8.4+redis+xdebug"]); + + Ok(()) +} +``` + +If the helper names in this snippet do not exist in the file, implement them locally next to the existing test helpers using the same fixture style already used in `gateway_reconciliation.rs`. + +- [ ] **Step 3: Run daemon tests to verify failure** + +Run: + +```bash +cargo nextest run -p daemon -E 'test(project_env_reconciliation_persists_php_extension_runtime) or test(gateway_runtime_plan_groups_projects_by_php_track_and_extensions)' +``` + +Expected: FAIL to compile because daemon runtime resolution still reads only `config.php.as_deref()` and `PhpWorkerRuntimePlan` has no `runtime_key`. + +- [ ] **Step 4: Add runtime resolver in `project_env.rs`** + +Add: + +```rust +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ResolvedPhpRuntime { + pub(crate) track: String, + pub(crate) runtime_key: String, + pub(crate) requested_extensions: Vec, + pub(crate) loaded_extensions: Vec, + pub(crate) ignored_extensions: Vec, + pub(crate) loaded_modules: Vec, +} +``` + +Implement: + +```rust +pub(crate) fn resolve_project_php_runtime( + paths: &PvPaths, + database: &Database, + project: &ProjectRecord, + php: Option<&config::PhpConfig>, +) -> Result { + let selector = php.and_then(config::PhpConfig::version_selector); + let track = resolve_project_php_track(paths, selector, project.desired_php_track.as_deref())?; + let requested_extensions = php + .map(|php| php.requested_extensions().to_vec()) + .unwrap_or_default(); + let release = installed_php_release(database, &track); + let resolution = match release { + Some(release) => resources::resolve_php_extension_request(&release, &requested_extensions)?, + None => resources::PhpExtensionResolution { + requested: requested_extensions.clone(), + loaded: Vec::new(), + ignored: requested_extensions.clone(), + }, + }; + let loaded_extensions = resolution + .loaded + .iter() + .map(|module| module.name.clone()) + .collect::>(); + let runtime_key = state::php_runtime_key(&track, &loaded_extensions)?; + + Ok(ResolvedPhpRuntime { + track, + runtime_key, + requested_extensions: resolution.requested, + loaded_extensions, + ignored_extensions: resolution.ignored, + loaded_modules: resolution.loaded, + }) +} +``` + +Add `installed_php_release` by scanning `database.managed_resource_tracks()?` for resource `php`, matching track, desired installed state, installed version, and current artifact path. + +- [ ] **Step 5: Persist runtime and ignored-extension warnings** + +In `reconcile_loaded_project`, replace `resolved_project_php_track_for_state` usage with `resolve_project_php_runtime` and write: + +```rust +database.replace_project_php_runtime( + &project.id, + Some(&ProjectPhpRuntimeInput { + track: runtime.track.clone(), + requested_extensions: runtime.requested_extensions.clone(), + loaded_extensions: runtime.loaded_extensions.clone(), + ignored_extensions: runtime.ignored_extensions.clone(), + }), +)?; +``` + +Build warning inputs: + +```rust +fn ignored_php_extension_warnings(runtime: &ResolvedPhpRuntime) -> Vec { + runtime + .ignored_extensions + .iter() + .map(|extension| ProjectEnvObservedWarningInput { + kind: "ignored_php_extension".to_string(), + message: format!("ignored unsupported PHP extension `{extension}`"), + }) + .collect() +} +``` + +Merge these warnings with existing `.env` warnings. When there are no env mappings but extension warnings exist, record `ProjectEnvObservedStatus::Warning` with message `"Project runtime has warnings"`. + +- [ ] **Step 6: Group gateway workers by runtime key** + +In `crates/daemon/src/gateway.rs`, change `PhpWorkerRuntimePlan`: + +```rust +pub struct PhpWorkerRuntimePlan { + pub php_track: String, + pub runtime_key: String, + pub loaded_modules: Vec, + pub port: u16, + pub projects: Vec, +} +``` + +Replace `projects_by_php_track` with `projects_by_runtime_key`. In valid config flow, call `resolve_project_php_runtime`; in invalid config fallback, use `project.php_runtime` fields and `state::php_runtime_key`. + +Update `append_runtime_project` to assign ports with: + +```rust +PortRequest::php_worker( + &runtime.runtime_key, + RUNTIME_PORT_FALLBACK_START, + RUNTIME_PORT_FALLBACK_START, + RUNTIME_PORT_FALLBACK_END, +) +``` + +- [ ] **Step 7: Use runtime overlays for workers** + +Change worker path calls to use `worker.runtime_key`. Change worker environment: + +```rust +fn frankenphp_worker_environment( + paths: &PvPaths, + worker: &PhpWorkerRuntimePlan, + artifact_root: &Utf8Path, +) -> Result, StateError> { + let mut environment = frankenphp_xdg_environment(paths); + environment.extend(resources::php_runtime_environment( + paths, + &worker.php_track, + &worker.runtime_key, + artifact_root, + &worker.loaded_modules, + )?); + + Ok(environment) +} +``` + +Use the installed FrankenPHP release path as `artifact_root` when preparing the worker process spec. + +- [ ] **Step 8: Run daemon tests** + +Run: + +```bash +cargo nextest run -p daemon -E 'test(project_env_reconciliation_persists_php_extension_runtime) or test(gateway_runtime_plan_groups_projects_by_php_track_and_extensions) or test(gateway_reconciliation_preserves_last_valid_runtime_when_project_config_breaks)' +``` + +Expected: PASS after updating snapshots: + +```bash +cargo insta test --accept --test-runner nextest -p daemon -- gateway_reconciliation +``` + +- [ ] **Step 9: Commit Task 5** + +```bash +git add crates/daemon/src/project_env.rs crates/daemon/src/gateway.rs crates/daemon/tests/project_env_reconciliation.rs crates/daemon/tests/gateway_reconciliation.rs crates/daemon/tests/snapshots +git commit -m "feat(daemon): group PHP workers by extension runtime" +``` + +## Task 6: PHP And Composer Shim Runtime Overlays + +**Files:** +- Modify: `crates/cli/src/commands/php.rs` +- Modify: `crates/cli/src/commands/composer.rs` +- Test: `crates/cli/tests/php.rs` +- Test: `crates/cli/tests/composer.rs` + +**Interfaces:** +- Consumes: `ProjectRecord.php_runtime`, `state::php_runtime_key`, `resources::php_runtime_exec_environment`. +- Produces: PHP and Composer shims using the Project runtime overlay inside linked Projects. + +- [ ] **Step 1: Write failing PHP shim test** + +In `crates/cli/tests/php.rs`, add: + +```rust +#[test] +fn php_shim_uses_project_extension_runtime_overlay() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let home = tempdir.path().join("home"); + let project = tempdir.path().join("acme"); + create_dir(&project)?; + write_file(&project.join("pv.yml"), "php:\n version: 8.4\n extensions: [redis]\n")?; + let project_record = register_project(&home, &project, "acme.test")?; + let release = record_installed_php(&home, "8.4", "8.4.8-pv1")?; + fs::write_sensitive_file( + &release.join("share/pv/php-extensions.json"), + r#"[{"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}]"#, + )?; + { + let mut database = Database::open(&pv_paths(&home))?; + database.replace_project_php_runtime( + &project_record.id, + Some(&state::ProjectPhpRuntimeInput { + track: "8.4".to_string(), + requested_extensions: vec!["redis".to_string()], + loaded_extensions: vec!["redis".to_string()], + ignored_extensions: Vec::new(), + }), + )?; + } + let environment = TestEnvironment::new(&home, &project_record.path, ScriptedClient::new()); + + let output = run_pv(&["shim:php", "-m"], &environment)?; + let exec_calls = environment.exec_calls(); + + assert_eq!(output.exit_code, ExitCode::SUCCESS); + assert!(exec_calls[0] + .env + .iter() + .any(|(key, value)| key == "PHP_INI_SCAN_DIR" && value.contains("php-runtimes/8.4+redis/conf.d"))); + + Ok(()) +} +``` + +- [ ] **Step 2: Run shim test to verify failure** + +Run: + +```bash +cargo nextest run -p cli -E 'test(php_shim_uses_project_extension_runtime_overlay)' +``` + +Expected: FAIL because the PHP shim only uses `php_track_exec_environment`. + +- [ ] **Step 3: Resolve shim runtime from Project state** + +In `crates/cli/src/commands/php.rs`, replace `resolve_php_track_for_shim` with `resolve_php_runtime_for_shim` returning: + +```rust +struct PhpShimRuntime { + track: String, + runtime_key: String, + loaded_extensions: Vec, +} +``` + +Inside linked Projects: + +```rust +if let Some(project) = database.nearest_project_for_path(¤t_dir)? + && let Some(track) = project.php_runtime.track.clone() +{ + let runtime_key = state::php_runtime_key(&track, &project.php_runtime.loaded_extensions)?; + return Ok(PhpShimRuntime { + track, + runtime_key, + loaded_extensions: project.php_runtime.loaded_extensions, + }); +} +``` + +Outside Projects, return the global/default track with `runtime_key = track` and an empty extension list. + +- [ ] **Step 4: Build shim env from installed artifact metadata** + +In `shim_with_args_and_env`, after finding the installed PHP record: + +```rust +let requested = runtime.loaded_extensions.clone(); +let resolution = resources::resolve_php_extension_request(installed.release_path(), &requested)?; +env.extend(resources::php_runtime_exec_environment( + &paths, + &runtime.track, + &runtime.runtime_key, + installed.release_path(), + &resolution.loaded, +)?); +``` + +Keep `resources::ensure_php_track_defaults(&paths, &runtime.track)?` before generating env. + +- [ ] **Step 5: Add Composer shim test** + +In `crates/cli/tests/composer.rs`, add a test mirroring the PHP shim test: + +```rust +#[test] +fn composer_shim_inherits_project_php_extension_runtime_overlay() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let home = tempdir.path().join("home"); + let project = tempdir.path().join("acme"); + create_dir(&project)?; + let project_record = register_project(&home, &project, "acme.test")?; + let php_release = record_installed_php(&home, "8.4", "8.4.8-pv1")?; + let composer_phar = record_installed_composer(&home)?; + fs::write_sensitive_file( + &php_release.join("share/pv/php-extensions.json"), + r#"[{"name":"redis","load_kind":"extension","path":"lib/php/extensions/redis.so"}]"#, + )?; + { + let mut database = Database::open(&pv_paths(&home))?; + database.replace_project_php_runtime( + &project_record.id, + Some(&state::ProjectPhpRuntimeInput { + track: "8.4".to_string(), + requested_extensions: vec!["redis".to_string()], + loaded_extensions: vec!["redis".to_string()], + ignored_extensions: Vec::new(), + }), + )?; + } + let environment = TestEnvironment::new(&home, &project_record.path, ScriptedClient::new()); + + let output = run_pv(&["shim:composer", "about"], &environment)?; + let exec_calls = environment.exec_calls(); + + assert_eq!(output.exit_code, ExitCode::SUCCESS); + assert_eq!(exec_calls[0].args[0], composer_phar.to_string()); + assert!(exec_calls[0] + .env + .iter() + .any(|(key, value)| key == "PHP_INI_SCAN_DIR" && value.contains("php-runtimes/8.4+redis/conf.d"))); + + Ok(()) +} +``` + +- [ ] **Step 6: Run CLI shim tests** + +Run: + +```bash +cargo nextest run -p cli -E 'test(php_shim_uses_project_extension_runtime_overlay) or test(composer_shim_inherits_project_php_extension_runtime_overlay) or test(php_shim_execs_resolved_project_track) or test(composer_shim_execs_installed_composer_through_php)' +``` + +Expected: PASS after updating snapshots: + +```bash +cargo insta test --accept --test-runner nextest -p cli -- php_shim composer_shim +``` + +- [ ] **Step 7: Commit Task 6** + +```bash +git add crates/cli/src/commands/php.rs crates/cli/src/commands/composer.rs crates/cli/tests/php.rs crates/cli/tests/composer.rs crates/cli/tests/snapshots +git commit -m "feat(cli): load PHP extension overlays in shims" +``` + +## Task 7: CLI Diagnostics For Ignored Extensions + +**Files:** +- Modify: `crates/cli/src/commands/project.rs` +- Modify: `crates/cli/src/commands/status.rs` +- Test: `it/cli.rs` +- Test: `crates/cli/tests/status.rs` + +**Interfaces:** +- Consumes: `ProjectEnvObservedWarningRecord` with kind `ignored_php_extension`. +- Produces: user-visible ignored-extension warnings in Project list/status output. + +- [ ] **Step 1: Write failing integration list test** + +In `it/cli.rs`, add: + +```rust +#[test] +fn project_list_reports_ignored_php_extensions() -> Result<()> { + let tempdir = tempdir()?; + let home = tempdir.path().join("home"); + let project = tempdir.path().join("Acme Store"); + create_dir(&project)?; + write_file(&project.join("pv.yml"), "php:\n extensions: [redis, missing]\n")?; + + let link = run_pv_in_dir_with_home(&["link"], &project, &home)?; + let paths = PvPaths::for_home(home.clone()); + let mut database = Database::open(&paths)?; + let linked_project = database + .projects()? + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("missing linked project"))?; + database.record_project_env_observed_snapshot( + &linked_project.id, + ProjectEnvObservedStatus::Warning, + Some("Project runtime has warnings"), + &[ProjectEnvObservedWarningInput { + kind: "ignored_php_extension".to_string(), + message: "ignored unsupported PHP extension `missing`".to_string(), + }], + )?; + + let list = run_pv_in_dir_with_home(&["list"], &project, &home)?; + + let mut settings = insta::Settings::clone_current(); + settings.add_filter(tempdir.path().as_str(), ""); + settings.add_filter("/private", ""); + settings.bind(|| { + assert_debug_snapshot!((link, list)); + }); + + Ok(()) +} +``` + +- [ ] **Step 2: Run list test to verify failure or snapshot drift** + +Run: + +```bash +cargo nextest run --test cli -E 'test(project_list_reports_ignored_php_extensions)' +``` + +Expected: FAIL with a new snapshot or missing warning detail. + +- [ ] **Step 3: Add compact warning detail** + +In `crates/cli/src/commands/project.rs`, extend `project_env_observed_warning_summary`: + +```rust +fn project_env_observed_warning_summary(observed: &ProjectEnvObservedStateRecord) -> String { + let ignored = observed + .warnings + .iter() + .filter(|warning| warning.kind == "ignored_php_extension") + .map(|warning| warning.message.as_str()) + .collect::>(); + if ignored.len() == 1 { + return format!("warning: {}", ignored[0]); + } + if ignored.len() > 1 { + return format!("warning: {} ignored PHP extensions", ignored.len()); + } + + match observed.warnings.as_slice() { + [warning] => format!("warning: {}", warning.message), + [] => observed + .message + .as_ref() + .map(|message| format!("warning: {message}")) + .unwrap_or_else(|| "warning".to_string()), + warnings => format!("warning: {} warnings", warnings.len()), + } +} +``` + +- [ ] **Step 4: Add status JSON/plain visibility** + +In `crates/cli/src/commands/status.rs`, include warning text in `ProjectStatus.message` when status is warning: + +```rust +let message = if observed.status == ProjectEnvObservedStatus::Warning { + observed + .warnings + .first() + .map(|warning| warning.message.clone()) + .or(observed.message) +} else { + observed.message +}; +``` + +- [ ] **Step 5: Run diagnostics tests** + +Run: + +```bash +cargo insta test --accept --test-runner nextest --test cli -- project_list_reports_ignored_php_extensions +cargo nextest run -p cli -E 'test(status_reports_warning_project_env_as_success)' +``` + +Expected: PASS. + +- [ ] **Step 6: Commit Task 7** + +```bash +git add crates/cli/src/commands/project.rs crates/cli/src/commands/status.rs it/cli.rs it/snapshots crates/cli/tests/status.rs crates/cli/tests/snapshots +git commit -m "feat(cli): report ignored PHP extensions" +``` + +## Task 8: Final Verification And Documentation Sweep + +**Files:** +- Modify if needed: `docs/user/README.md` +- Modify if needed: `DESIGN.md` +- Modify if needed: `release/artifacts/README.md` + +**Interfaces:** +- Consumes: all completed implementation tasks. +- Produces: verified feature branch and user-facing docs updates. + +- [ ] **Step 1: Update user docs with config examples** + +Add this section to `docs/user/README.md` near Project config documentation: + +```markdown +### PHP Extensions + +The `php` key may be a scalar version or an object: + +```yaml +php: + version: 8.4 + extensions: + - redis + - xdebug +``` + +If `version` is omitted, PV uses the configured default PHP track: + +```yaml +php: + extensions: + - xdebug +``` + +PV loads bundled optional extensions that are available in the installed PHP artifact. Unknown extension names are ignored and reported as warnings. +``` + +- [ ] **Step 2: Run focused test suite** + +Run: + +```bash +cargo nextest run -p config -p resources -p state -p daemon -p cli -E 'test(php) or test(extension) or test(gateway_runtime_plan) or test(project_env_reconciliation)' +``` + +Expected: PASS. + +- [ ] **Step 3: Run release tooling tests** + +Run: + +```bash +cargo nextest run -p pv-release -E 'test(php_recipe) or test(release_record) or test(manifest_generator) or test(php_smoke)' +``` + +Expected: PASS. + +- [ ] **Step 4: Run formatting** + +Run: + +```bash +cargo fmt --all +``` + +Expected: no output and exit 0. + +- [ ] **Step 5: Run clippy** + +Run: + +```bash +cargo clippy --workspace --all-targets --all-features --locked -- -D warnings +``` + +Expected: PASS. + +- [ ] **Step 6: Run shellcheck** + +Run: + +```bash +shellcheck release/artifacts/recipes/common.sh release/artifacts/recipes/php/build.sh release/artifacts/recipes/php/smoke.sh +``` + +Expected: PASS. + +- [ ] **Step 7: Commit final docs and cleanup** + +```bash +git add docs/user/README.md DESIGN.md release/artifacts/README.md +git commit -m "docs: document PHP extension opt-ins" +``` + +If no docs changed in this task, skip the commit and record that all required docs were already current. + +- [ ] **Step 8: Inspect final branch state** + +Run: + +```bash +git status --short +git log --oneline --max-count=8 +``` + +Expected: working tree clean, with one commit per completed task. + +## Plan Self-Review + +- Spec coverage: covered config shape, curated default/optional split, artifact metadata, no arbitrary `.so`, runtime grouping, CLI/Composer parity, unsupported-name warnings, state fallback, release recipe smoke tests, and docs. +- Placeholder scan: clean for blocked-work markers and unresolved placeholders. +- Type consistency: `PhpConfig`, `PhpExtensionModule`, `PhpExtensionResolution`, `ProjectPhpRuntimeInput`, and `php_runtime_key` are introduced before dependent tasks consume them. +- Scope check: artifact packaging and app runtime are both required for the feature to work end-to-end; they are split into independently testable tasks inside one implementation plan. diff --git a/docs/superpowers/specs/2026-06-22-php-extension-opt-ins-design.md b/docs/superpowers/specs/2026-06-22-php-extension-opt-ins-design.md new file mode 100644 index 00000000..66d2f906 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-php-extension-opt-ins-design.md @@ -0,0 +1,329 @@ +# PHP Extension Opt-Ins Design + +## Summary + +PV will support Project-level PHP extension opt-ins without named profiles or local extension compilation. The default PHP runtime remains lean and Laravel-practical. Projects that need optional extensions list them under `php.extensions`; PV loads only bundled optional modules that are available in the installed PHP artifact and ignores unsupported names with a non-blocking warning. + +Optional modules are bundled in PV's PHP/FrankenPHP track artifacts as shared modules, but they are disabled by default. This keeps the first implementation inside the existing PHP artifact lifecycle while preserving the existing boundary: users cannot load arbitrary `.so` files, run `phpize`, or install PECL extensions locally through PV. + +## Goals + +- Keep existing scalar PHP config valid. +- Add object-form PHP config with optional `version` and `extensions` keys. +- Allow `php.extensions` to request extension names without named presets or profiles. +- Keep default runtime extensions lean but still practical for Laravel apps. +- Bundle the first optional catalog inside PHP/FrankenPHP artifacts as disabled shared modules. +- Load optional modules through PV-generated runtime `conf.d` overlays. +- Group Project-serving FrankenPHP workers by PHP track plus loaded extension set. +- Keep standalone PHP, Composer-through-PHP, and browser execution aligned for a Project. +- Treat unsupported extension names as ignored requests, not invalid Project config. +- Surface ignored names through status/list/log diagnostics so typos are visible. + +## Non-Goals + +- Do not add named extension profiles, presets, or profile inheritance. +- Do not infer PHP extensions from `composer.json`. +- Do not support arbitrary user-provided shared modules. +- Do not support local PECL, `phpize`, `php-config`, or per-machine extension builds. +- Do not add broad Project-level custom PHP ini settings. +- Do not split optional extensions into separate Managed Resource artifacts in the first version. +- Do not build and ship every extension supported by StaticPHP v3. +- Do not add extensions outside the first curated optional catalog until user demand justifies them. + +## Project Config + +The current scalar form remains valid: + +```yaml +php: 8.4 +``` + +The object form accepts the same version values plus an extension list: + +```yaml +php: + version: 8.4 + extensions: + - redis + - xdebug +``` + +The version may be omitted. In that case PV resolves the PHP track through the same default flow used when `php` is absent: + +```yaml +php: + extensions: + - xdebug +``` + +An empty extension list is valid and means no optional extensions: + +```yaml +php: + version: 8.4 + extensions: [] +``` + +`extensions` must be a YAML array of strings. Invalid shapes remain Project config errors because PV cannot interpret them safely. Extension support is not a config validity rule: unsupported strings are accepted, ignored at runtime, and reported as warnings. + +The parser must normalize both scalar and object forms into one internal model: + +```text +PhpConfig { + version: Option, + requested_extensions: Vec, +} +``` + +Duplicate extension names must not create distinct runtimes. The runtime resolver deduplicates requested names after parsing. + +## Default And Optional Extensions + +The default loaded extension set is Laravel-practical but avoids app-specific service drivers and debugging tools: + +```text +bcmath +ctype +curl +dom +fileinfo +filter +hash +iconv +intl +json +libxml +mbstring +openssl +pcntl +pcre +pdo +pdo_mysql +pdo_pgsql +pdo_sqlite +phar +posix +session +simplexml +sodium +sqlite3 +tokenizer +xml +xmlreader +xmlwriter +zip +zlib +``` + +The initial optional catalog is: + +```text +redis +sqlsrv +pdo_sqlsrv +xdebug +apcu +pcov +imagick +mongodb +yaml +``` + +This moves `redis`, `sqlsrv`, and `pdo_sqlsrv` out of the current always-loaded set. `xdebug`, `apcu`, `pcov`, `imagick`, `mongodb`, and `yaml` are new opt-in candidates. + +Future extensions should be added only when users ask for them and PV can build, smoke-test, license, and support them across the intended PHP track/platform matrix. + +## Artifact Packaging + +PV continues publishing paired PHP and FrankenPHP artifacts per PHP track. Each track artifact includes: + +- the standalone `php` binary, +- the matched `frankenphp` binary for the same PHP patch version, +- default PHP runtime files, +- default compiled/static extensions, +- bundled optional shared modules for the curated catalog. + +Bundled optional modules are disabled by default. PV enables them by writing generated `.ini` files into a runtime-specific `conf.d` overlay. Normal extensions use `extension=...`; Zend extensions such as Xdebug use `zend_extension=...`. + +The PHP artifact manifest or artifact metadata must expose the optional catalog for each artifact, including at least: + +- extension name, +- load kind: `extension` or `zend_extension`, +- module path relative to the active artifact root, +- whether the module is available for the current platform/artifact. + +Keeping the catalog in artifact metadata lets PV add optional bundled modules in future PHP artifact releases without requiring a PV app release, as long as the installed PV version understands the manifest schema. + +If a PHP artifact does not advertise optional extension metadata, PV treats it as having no optional bundled extensions. Projects still serve with the default runtime and ignored-extension warnings. + +## Runtime Resolution + +PV resolves each linked Project to a PHP runtime identity: + +```text +PHP track + sorted available extension names +``` + +Examples: + +```text +8.4 -> default 8.4 runtime +8.4 + redis -> 8.4 runtime with redis +8.4 + redis + xdebug -> 8.4 runtime with redis and xdebug +8.5 + redis -> 8.5 runtime with redis +``` + +The requested extension order in YAML does not affect runtime identity. These are equivalent: + +```yaml +extensions: [redis, xdebug] +``` + +```yaml +extensions: [xdebug, redis] +``` + +Unsupported requested extensions are excluded from the runtime identity. A Project that requests `redis` and `fake_extension` uses the same runtime as a Project that requests only `redis`, with `fake_extension` reported as ignored. + +## FrankenPHP Workers + +Project-serving workers are grouped by PHP runtime identity, not by PHP track alone. Projects share a worker only when both the track and the loaded optional extension set match. + +This preserves extension startup semantics. PHP extensions are loaded when the PHP runtime starts, so PV cannot safely serve Projects with different extension sets from the same FrankenPHP worker. + +Worker config, pid files, runtime metadata, log paths, observed runtime subjects, and port ownership need to use the runtime identity rather than only the PHP track. Implementations may use a readable slug for short identities and a stable hash if the identity becomes too long for paths or subjects. + +When a Project changes only its optional extension set, PV reassigns it to the matching worker, starts that worker if needed, and stops the old worker if no Projects remain. Unrelated PHP runtimes are not touched. + +## CLI And Composer + +The `php` shim must resolve the current Project's PHP runtime identity when executed inside a linked Project. It should set `PHPRC` and `PHP_INI_SCAN_DIR` so the standalone CLI process sees the same default ini plus generated optional-extension overlay as the Project's browser runtime. + +The `composer` shim already runs through PV's PHP selection path. It should inherit the same Project PHP runtime identity and extension overlay as direct `php` commands. This avoids CLI/browser drift for Composer scripts that depend on loaded extensions. + +Outside a linked Project, PHP and Composer use the global/default PHP track with no Project-level optional extensions. + +## Ini Overlay + +PV keeps track-level defaults under the existing mutable track defaults directory: + +```text +~/.pv/resources/php//etc/php.ini +~/.pv/resources/php//etc/conf.d/ +``` + +For extension opt-ins, PV adds generated runtime overlays under PV-owned config storage, for example: + +```text +~/.pv/config/php-runtimes//conf.d/ +``` + +Runtime processes use both scan directories: + +```text +PHP_INI_SCAN_DIR=: +``` + +Generated extension ini files are owned by PV and replaced wholesale during reconciliation. Users must not edit them. User-editable track defaults remain separate. + +The overlay includes only available optional extensions requested by at least one Project using that runtime. It does not include unsupported names. + +## Unsupported Extensions + +Unsupported extension names are not Project config errors. PV must: + +1. Parse and accept the config when `extensions` is an array of strings. +2. Resolve the requested names against the artifact's optional catalog. +3. Load available names. +4. Ignore unavailable names. +5. Report ignored names in diagnostics. + +Example: + +```yaml +php: + extensions: + - redis + - fake_extension +``` + +If `redis` is available, the Project runs with `redis`; `fake_extension` is ignored. The Project should continue serving. + +Ignored-extension reporting must appear in at least one user-visible place such as `pv list`, `pv status`, or structured Project diagnostics. It is non-blocking and does not mark the Project config invalid. + +## State And Manifest Impact + +State that currently stores only a desired PHP track for each Project needs to preserve enough information to recover the last valid runtime when the config later becomes invalid. The stored runtime must include: + +- resolved concrete PHP track, +- requested extension names, +- available loaded extension names, +- ignored extension names. + +Runtime observed subjects and port owners should identify workers by runtime identity. Existing track-only subjects such as `php_worker:8.4` need a compatible replacement that can distinguish `8.4` from `8.4+redis`. + +Manifest parsing should grow optional PHP extension metadata. Older manifests or artifacts without the metadata are interpreted as supporting no optional extensions. New artifacts that rely on extension metadata should raise `minimum_pv_version` when needed so old PV versions do not incorrectly claim support for a feature they cannot apply. + +## Artifact Recipe Impact + +The PHP recipe must split extension settings into default loaded extensions and optional bundled shared extensions. + +The recipe build must: + +- build default extensions into the PHP/FrankenPHP runtime as today, +- build optional catalog entries as shared modules when StaticPHP supports that mode, +- package optional modules in a stable artifact-relative location, +- emit metadata describing each optional module's load kind and path, +- smoke-test the default runtime without optional modules loaded, +- smoke-test each optional module by loading it through a generated ini overlay for both standalone PHP and FrankenPHP where supported. + +StaticPHP v3 extension caveats still apply. Extensions that are not compatible with PV's macOS/ZTS/FrankenPHP requirements should not enter the PV optional catalog until proven. + +## Error Handling + +Invalid PHP config shape remains blocking: + +- `php` object with unknown shape, +- `extensions` not an array, +- extension entries that are not strings. + +Unsupported extension names are non-blocking warnings. + +If an extension is advertised by artifact metadata but the shared module file is missing, that is an artifact/runtime error. PV must fail the affected runtime readiness or mark it degraded rather than silently serving without an advertised extension. + +If an optional extension causes the worker to fail readiness, the failure is scoped to that PHP runtime identity. Other PHP runtimes continue serving. + +## Testing + +Prefer integration tests and snapshots where practical. + +Config tests should cover: + +- scalar `php` remains valid, +- object `php.version` resolves like scalar `php`, +- object `php.extensions` with omitted version resolves through the default track, +- `extensions: []` is valid, +- invalid extension shapes are config errors, +- unsupported extension strings are accepted. + +Runtime planning tests should cover: + +- Projects with the same track and same extension set share a worker, +- extension order does not create different workers, +- unsupported extension names do not create distinct workers, +- Projects with different available extension sets use different workers, +- invalid Project config preserves the last valid runtime assignment. + +Shim tests should cover: + +- PHP shim environment includes the runtime overlay inside a linked Project, +- Composer inherits the same PHP runtime overlay, +- outside linked Projects, shims use the global/default track without Project extensions. + +Artifact/release tests should cover: + +- recipe metadata distinguishes default loaded extensions from optional bundled extensions, +- generated manifests expose optional PHP extension metadata, +- archive validation requires advertised optional module files, +- smoke tests verify default runtimes do not load optional modules, +- smoke tests verify each optional module can load through the generated overlay. diff --git a/docs/user/README.md b/docs/user/README.md index 6ad67f78..8d2c889a 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -120,6 +120,28 @@ redis: Project config accepts YAML anchors, aliases, and merge keys as YAML syntax. PV resolves them before validating keys and values. Unknown keys that remain after YAML resolution fail validation. +### PHP Extensions + +The `php` key may be a scalar version or an object: + +```yaml +php: + version: 8.4 + extensions: + - redis + - xdebug +``` + +If `version` is omitted, PV uses the configured default PHP track: + +```yaml +php: + extensions: + - xdebug +``` + +PV loads bundled optional extensions that are available in the installed PHP artifact. Unknown extension names are ignored and reported as warnings. + Preview rendered environment values without editing `.env`: ```shell diff --git a/it/cli.rs b/it/cli.rs index c2aa66cf..feefefd8 100644 --- a/it/cli.rs +++ b/it/cli.rs @@ -584,6 +584,53 @@ fn project_list_reports_env_observed_status() -> Result<()> { Ok(()) } +#[test] +fn project_list_reports_ignored_php_extensions() -> Result<()> { + let tempdir = tempdir()?; + let home = tempdir.path().join("home"); + let project = tempdir.path().join("Acme Store"); + create_dir(&project)?; + write_file( + &project.join("pv.yml"), + "php:\n extensions: [redis, missing]\n", + )?; + + let link = run_pv_in_dir_with_home(&["link"], &project, &home)?; + let paths = PvPaths::for_home(home.clone()); + let mut database = Database::open(&paths)?; + let linked_project = database + .projects()? + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("missing linked project"))?; + database.record_project_env_observed_snapshot( + &linked_project.id, + ProjectEnvObservedStatus::Warning, + Some("Project runtime has warnings"), + &[ + ProjectEnvObservedWarningInput { + kind: "ignored_php_extension".to_string(), + message: "ignored unsupported PHP extension `missing`".to_string(), + }, + ProjectEnvObservedWarningInput { + kind: "ignored_php_extension".to_string(), + message: "ignored unsupported PHP extension `typo`".to_string(), + }, + ], + )?; + + let list = run_pv_in_dir_with_home(&["list"], &project, &home)?; + + let mut settings = insta::Settings::clone_current(); + settings.add_filter(tempdir.path().as_str(), ""); + settings.add_filter("/private", ""); + settings.bind(|| { + assert_debug_snapshot!((link, list)); + }); + + Ok(()) +} + #[test] fn project_list_clears_stale_env_status_without_mappings() -> Result<()> { let tempdir = tempdir()?; diff --git a/it/snapshots/cli__project_list_reports_ignored_php_extensions.snap b/it/snapshots/cli__project_list_reports_ignored_php_extensions.snap new file mode 100644 index 00000000..c6f3e00a --- /dev/null +++ b/it/snapshots/cli__project_list_reports_ignored_php_extensions.snap @@ -0,0 +1,21 @@ +--- +source: it/cli.rs +assertion_line: 628 +expression: "(link, list)" +--- +( + CommandOutput { + code: Some( + 0, + ), + stdout: "Linked acme-store.test -> /Acme Store\nwarning: PV daemon is not running; reconciliation will run after `pv setup` starts it\n", + stderr: "", + }, + CommandOutput { + code: Some( + 0, + ), + stdout: "Hostname PHP Status Resources Env Path\nacme-store.test default unknown unknown warning /Acme Store\n env: warning: ignored unsupported PHP extension `missing`; ignored unsupported PHP extension `typo`\n", + stderr: "", + }, +) diff --git a/release/artifacts/README.md b/release/artifacts/README.md index 5b467b57..e663dca0 100644 --- a/release/artifacts/README.md +++ b/release/artifacts/README.md @@ -36,7 +36,7 @@ cargo run -p pv-release -- validate-archive \ Recipe TOML files use a shared `[recipe]` plus `[[tracks]]` schema. Resource-specific sections are only used when the resource family needs extra build metadata. -`recipes/php/tracks.toml` is the data source for paired PHP and FrankenPHP artifact builds. Each selected PHP track/platform is built once with StaticPHP v3, producing both the standalone `php` binary and the matched `frankenphp` binary from the same buildroot. The recipe pins PHP tracks `8.3`, `8.4`, and `8.5`; source URLs; checksums; the expected extension set; the macOS deployment target; and the FrankenPHP source version used by the pair. +`recipes/php/tracks.toml` is the data source for paired PHP and FrankenPHP artifact builds. Each selected PHP track/platform is built once with StaticPHP v3, producing both the standalone `php` binary and the matched `frankenphp` binary from the same buildroot. The recipe pins PHP tracks `8.3`, `8.4`, and `8.5`; source URLs; checksums; the default loaded extension set; the optional bundled extension catalog; the expected runtime extension set; the macOS deployment target; and the FrankenPHP source version used by the pair. Generated release records and manifests include optional PHP extension metadata so PV can load bundled modules through runtime ini overlays. `recipes/composer/composer.toml` is the data source for Composer track `2`. Composer is packaged as a `platform: "any"` artifact. diff --git a/release/artifacts/recipes/php/build.sh b/release/artifacts/recipes/php/build.sh index 1274c7fa..1dac0c3c 100755 --- a/release/artifacts/recipes/php/build.sh +++ b/release/artifacts/recipes/php/build.sh @@ -237,6 +237,8 @@ print_php_env php "$php_env_file" PHP_SOURCE_URL=$PV_SOURCE_URL PHP_SOURCE_SHA256=$PV_SOURCE_SHA256 PHP_PHP_VERSION=$PV_PHP_VERSION + PHP_DEFAULT_EXTENSIONS=$PV_DEFAULT_EXTENSIONS + PHP_OPTIONAL_EXTENSIONS=$PV_OPTIONAL_EXTENSIONS PHP_BUILD_EXTENSIONS=$PV_BUILD_EXTENSIONS PHP_EXPECTED_EXTENSIONS=$PV_EXPECTED_EXTENSIONS PHP_DEPLOYMENT_TARGET=$PV_DEPLOYMENT_TARGET @@ -261,6 +263,8 @@ print_php_env frankenphp "$frankenphp_env_file" # shellcheck disable=SC2153 { [ "$PV_PHP_VERSION" = "$PHP_PHP_VERSION" ] || die "PHP pair metadata mismatch: php env has $PHP_PHP_VERSION but frankenphp env has $PV_PHP_VERSION" + [ "$PV_DEFAULT_EXTENSIONS" = "$PHP_DEFAULT_EXTENSIONS" ] || die "PHP pair metadata mismatch: default extension sets differ" + [ "$PV_OPTIONAL_EXTENSIONS" = "$PHP_OPTIONAL_EXTENSIONS" ] || die "PHP pair metadata mismatch: optional extension sets differ" [ "$PV_BUILD_EXTENSIONS" = "$PHP_BUILD_EXTENSIONS" ] || die "PHP pair metadata mismatch: extension build sets differ" [ "$PV_EXPECTED_EXTENSIONS" = "$PHP_EXPECTED_EXTENSIONS" ] || die "PHP pair metadata mismatch: expected extension sets differ" [ "$PV_DEPLOYMENT_TARGET" = "$PHP_DEPLOYMENT_TARGET" ] || die "PHP pair metadata mismatch: deployment targets differ" @@ -275,7 +279,13 @@ prepare_staticphp_php83_frankenphp_patch_context "$php_source_dir" "$frankenphp_ ( cd "$spc_work_dir" # StaticPHP dependency downloads default to no retries; GNU mirrors can return transient 5xxs. + optional_shared_args= + if [ -n "$PHP_OPTIONAL_EXTENSIONS" ]; then + optional_shared_args="--build-shared=$PHP_OPTIONAL_EXTENSIONS" + fi + # shellcheck disable=SC2086 spc build:php "$PHP_BUILD_EXTENSIONS" \ + $optional_shared_args \ --build-cli \ --build-frankenphp \ --enable-zts \ @@ -290,6 +300,32 @@ prepare_staticphp_php83_frankenphp_patch_context "$php_source_dir" "$frankenphp_ [ -f "$spc_work_dir/buildroot/bin/php" ] || die "StaticPHP pair build did not produce buildroot/bin/php" [ -f "$spc_work_dir/buildroot/bin/frankenphp" ] || die "StaticPHP pair build did not produce buildroot/bin/frankenphp" +stage_optional_php_extensions() { + root_dir=$1 + mkdir -p "$root_dir/lib/php/extensions" "$root_dir/share/pv" + metadata="$root_dir/share/pv/php-extensions.json" + printf '[' >"$metadata" + first=1 + old_ifs=$IFS + IFS=, + for extension in $PHP_OPTIONAL_EXTENSIONS; do + [ -n "$extension" ] || continue + module=$(find "$spc_work_dir/buildroot" -type f -name "$extension.so" | head -n 1) + [ -n "$module" ] || die "optional PHP extension $extension did not produce a shared module" + staged_module="$root_dir/lib/php/extensions/$extension.so" + cp "$module" "$staged_module" + delete_known_stale_macho_rpaths "$staged_module" + validate_macho_binary "$staged_module" + load_kind=extension + [ "$extension" = "xdebug" ] && load_kind=zend_extension + [ "$first" -eq 1 ] || printf ',' >>"$metadata" + first=0 + printf '{"name":"%s","load_kind":"%s","path":"lib/php/extensions/%s.so"}' "$extension" "$load_kind" "$extension" >>"$metadata" + done + IFS=$old_ifs + printf ']\n' >>"$metadata" +} + stage_artifact() { resource=$1 upstream_version=$2 @@ -304,6 +340,7 @@ stage_artifact() { mkdir -p "$root_dir/bin" cp "$spc_work_dir/buildroot/bin/$binary_name" "$root_dir/bin/$binary_name" [ -f "$root_dir/bin/$binary_name" ] || die "$resource artifact did not produce bin/$binary_name" + stage_optional_php_extensions "$root_dir" delete_known_stale_macho_rpaths "$root_dir/bin/$binary_name" validate_macho_binary "$root_dir/bin/$binary_name" @@ -342,6 +379,17 @@ write_staged_artifact() { mkdir -p "$(dirname "$archive")" COPYFILE_DISABLE=1 tar -czf "$archive" -C "$work_dir" "$artifact_basename" + + old_ifs=$IFS + IFS=, + for extension in $PHP_OPTIONAL_EXTENSIONS; do + [ -n "$extension" ] || continue + load_kind=extension + [ "$extension" = "xdebug" ] && load_kind=zend_extension + set -- "$@" --php-extension "$extension" "$load_kind" "lib/php/extensions/$extension.so" + done + IFS=$old_ifs + write_record "$record" "$resource" "$TRACK" "$upstream_version" "$pv_build_revision" "$PLATFORM" "$object_key" "$archive" "$source_url" "$source_sha256" release/artifacts/recipes/php/build.sh "$PV_COMMIT" "$BUILD_RUN_ID" "$minimum_pv_version" "$@" PV_EXPECTED_EXTENSIONS="$expected_extensions" \ diff --git a/release/artifacts/recipes/php/smoke.sh b/release/artifacts/recipes/php/smoke.sh index b778489c..43192187 100755 --- a/release/artifacts/recipes/php/smoke.sh +++ b/release/artifacts/recipes/php/smoke.sh @@ -13,7 +13,10 @@ need() { } check_extensions() { - expected_extensions_sorted=$(printf '%s' "$expected_extensions" | tr ',' '\n' | awk ' + required_extensions=$1 + shift + + expected_extensions_sorted=$(printf '%s' "$required_extensions" | tr ',' '\n' | awk ' { sub(/^[[:space:]]+/, "") sub(/[[:space:]]+$/, "") @@ -54,6 +57,42 @@ check_extensions() { IFS=$old_ifs } +check_optional_extensions() { + metadata="$artifact_root/share/pv/php-extensions.json" + [ -f "$metadata" ] || return 0 + need python3 + scan_dir=$(mktemp -d) + optional_extensions=$(python3 - "$metadata" "$artifact_root" "$scan_dir" <<'PY' +import json +import pathlib +import sys + +metadata = pathlib.Path(sys.argv[1]) +artifact_root = pathlib.Path(sys.argv[2]) +scan_dir = pathlib.Path(sys.argv[3]) +extension_names = [] +for index, module in enumerate(json.loads(metadata.read_text())): + extension_names.append(module["name"]) + directive = module["load_kind"] + path = artifact_root / module["path"] + prefix = 10 + index * 10 + (scan_dir / f"{prefix}-{module['name']}.ini").write_text(f"{directive}={path}\n") +print(",".join(extension_names)) +PY +) + if ( + PHP_INI_SCAN_DIR=$scan_dir + export PHP_INI_SCAN_DIR + check_extensions "$optional_extensions" "$@" + ); then + rm -rf "$scan_dir" + else + status=$? + rm -rf "$scan_dir" + return "$status" + fi +} + available_port() { python3 - <<'PY' import socket @@ -85,7 +124,8 @@ expected_version=${upstream_version%%-frankenphp*} if [ -x "$artifact_root/bin/frankenphp" ]; then frankenphp_binary="$artifact_root/bin/frankenphp" "$frankenphp_binary" php-cli -r 'printf("PHP %s\n", PHP_VERSION);' | grep -F "PHP $expected_version" >/dev/null - check_extensions "$frankenphp_binary" php-cli -r "foreach (get_loaded_extensions() as \$extension) { echo \$extension, PHP_EOL; }" + check_extensions "$expected_extensions" "$frankenphp_binary" php-cli -r "foreach (get_loaded_extensions() as \$extension) { echo \$extension, PHP_EOL; }" + check_optional_extensions "$frankenphp_binary" php-cli -r "foreach (get_loaded_extensions() as \$extension) { echo \$extension, PHP_EOL; }" need python3 site_dir=$(mktemp -d) @@ -116,7 +156,8 @@ fi if [ -x "$artifact_root/bin/php" ]; then php_binary="$artifact_root/bin/php" "$php_binary" -v | grep -F "PHP $expected_version" >/dev/null - check_extensions "$php_binary" -m + check_extensions "$expected_extensions" "$php_binary" -m + check_optional_extensions "$php_binary" -m if "$php_binary" --ini 2>&1 | grep -F '/usr/local/etc/php' >/dev/null; then printf '%s\n' "PHP artifact reports unsafe /usr/local/etc/php ini fallback" >&2 exit 46 diff --git a/release/artifacts/recipes/php/tracks.toml b/release/artifacts/recipes/php/tracks.toml index bfb8c763..a8028a9b 100644 --- a/release/artifacts/recipes/php/tracks.toml +++ b/release/artifacts/recipes/php/tracks.toml @@ -9,7 +9,7 @@ notice_files = ["NOTICE"] [php] deployment_target = "13.0" -build_extensions = [ +default_extensions = [ "bcmath", "ctype", "curl", @@ -26,15 +26,13 @@ build_extensions = [ "pdo_mysql", "pdo_pgsql", "pdo_sqlite", - "pdo_sqlsrv", "phar", "posix", - "redis", "session", "simplexml", + "sockets", "sodium", "sqlite3", - "sqlsrv", "tokenizer", "xml", "xmlreader", @@ -42,6 +40,17 @@ build_extensions = [ "zip", "zlib", ] +optional_extensions = [ + "redis", + "sqlsrv", + "pdo_sqlsrv", + "xdebug", + "apcu", + "pcov", + "imagick", + "mongodb", + "yaml", +] expected_extensions = [ "bcmath", "ctype", @@ -62,15 +71,13 @@ expected_extensions = [ "pdo_mysql", "pdo_pgsql", "pdo_sqlite", - "pdo_sqlsrv", "phar", "posix", - "redis", "session", "simplexml", + "sockets", "sodium", "sqlite3", - "sqlsrv", "tokenizer", "xml", "xmlreader",