diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5798bbd19..4aeff319a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ env: HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }} HEROKU_API_USER: ${{ secrets.HEROKU_API_USER }} GIT_HTTP_LOW_SPEED_LIMIT: 1000 - GIT_HTTP_LOW_SPEED_TIME: 60 + GIT_HTTP_LOW_SPEED_TIME: 300 jobs: integration-test: diff --git a/bin/compile b/bin/compile index 9b6763ad6..ec4010d48 100755 --- a/bin/compile +++ b/bin/compile @@ -429,12 +429,14 @@ status "Installing platform packages..." export export_file_path=$bp_dir/export export profile_dir_path=$build_dir/.profile.d export providedextensionslog_file_path=$(mktemp -t "provided-extensions.log.XXXXXX" -u) -# we make a new file descriptor for the installer plugin to log "human readable" display output to, duplicated to stdout (via indent) -exec {PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_FDNO}> >(indent) +# we make a new file descriptor for the installer plugin to log "human readable" display output to, duplicated to stdout +exec {PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_FDNO}>&1 # the installer picks up the FD number in this env var, and writes a "display output" log to it # meanwhile, the "raw" output of 'composer install' goes to install.log in case we need it later export PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_FDNO -if composer install -d "$build_dir/.heroku/php" ${HEROKU_PHP_INSTALL_DEV-"--no-dev"} > $build_dir/.heroku/php/install.log 2>&1; then +export PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_INDENT=7 +# NO_COLOR=1 suppresses the download progress meter whose ANSI cursor codes trip up output in Heroku Dashboard +if NO_COLOR=1 composer install -d "$build_dir/.heroku/php" ${HEROKU_PHP_INSTALL_DEV-"--no-dev"} > $build_dir/.heroku/php/install.log 2>&1; then : else code=$? @@ -510,14 +512,10 @@ else EOF fi -# close display output FD (we make new ones for each iteration next) -exec {PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_FDNO}>&- - -export -n providedextensionslog_file_path # export no longer needed +export -n providedextensionslog_file_path # export no longer needed (and we don't want more entries written by the plugin even if it encounters them) if [[ -s "$providedextensionslog_file_path" ]]; then notice_inline "detected userland polyfill packages for PHP extensions" notice_inline "now attempting to install native extension packages" - current_install_log=$(mktemp -t "currentinstall.log.XXXXXX" -u) # for capturing the single "composer install" output # the platform installer recorded userland packages that "provide" a PHP extension # for each occurrence (source package providing other package(s)) in the list, # we will now attempt to install the real native extension packages (rest of the line) @@ -528,28 +526,23 @@ if [[ -s "$providedextensionslog_file_path" ]]; then for extension in "${provides[@]:1}"; do # remaining words in line are native extensions it declares "provide"d ext_package=$(cut -d":" -f1 <<< "$extension") # extract name from "ext-foo:1.2.3" string ext_name=${ext_package#"heroku-sys/"} # no "heroku-sys/" prefix for human output - echo -n "- ${ext_name}... " | indent # run a `composer require`, confined to the package we're attempting, allowing any version - # we're appending to install.log using tee, and using another new "display output" FD to redirect to a new single-step log (tee truncates for us) - # this log is checked later to see if a package install actually happened, or if it was already enabled (since both exit status 0) - # we're letting the indents begin the line with a \r character, so that the above `echo -n` gets overwritten - exec {PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_FDNO}> >(tee "$current_install_log" | indent "" $'\r ') - if composer require -d "$build_dir/.heroku/php" "${ext_package}.native:*" >> $build_dir/.heroku/php/install.log 2>&1; then - # package added successfully - [[ -s "$current_install_log" ]] || { echo -e "- ${ext_name} (already enabled)" | indent "" $'\r '; } # if nothing was actually installed above, that means the dependency was already satisfied (i.e. extension is statically built into PHP); we need to echo something to that effect - else + # (the .native "replace"d variant in each extension matches the regular version, so the constraint for that regular version is enough) + # NO_COLOR=1 suppresses the download progress meter whose ANSI cursor codes trip up output in Heroku Dashboard + if ! NO_COLOR=1 composer require -d "$build_dir/.heroku/php" "${ext_package}.native:*" >> $build_dir/.heroku/php/install.log 2>&1; then # composer did not succeed; this means no package that matches all existing package's requirements was found - echo -n -e "\r" # reset the line notice_inline "no suitable native version of ${ext_name} available" fi - exec {PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_FDNO}>&- done done {fd_num}< "$providedextensionslog_file_path" # use bash 4.1+ automatic file descriptor allocation (better than hardcoding e.g. "3<"), otherwise this loop's stdin (the lines of the file) will be consumed by programs called inside the loop - rm "$current_install_log" exec {fd_num}>&- fi -unset PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_FDNO +# clean up installer variables and files +# we want to keep export_file_path and profile_dir_path for newrelic and blackfire attempts at the end though +rm -f "$providedextensionslog_file_path" +unset providedextensionslog_file_path +unset PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_FDNO PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_INDENT # log number of installed platform packages mmeasure "platform.count" $(composer show -d "$build_dir/.heroku/php" --installed "heroku-sys/*" 2> /dev/null | wc -l) diff --git a/support/installer/composer.json b/support/installer/composer.json index cfde8842e..10741cfcf 100644 --- a/support/installer/composer.json +++ b/support/installer/composer.json @@ -1,7 +1,7 @@ { "type": "composer-plugin", "name": "heroku/installer-plugin", - "version": "1.7.3", + "version": "1.7.5", "autoload": { "psr-4": { "Heroku\\Buildpack\\PHP\\": "src/" diff --git a/support/installer/src/ComposerInstaller.php b/support/installer/src/ComposerInstaller.php index 231e0ac25..fc1bbfd7d 100644 --- a/support/installer/src/ComposerInstaller.php +++ b/support/installer/src/ComposerInstaller.php @@ -16,7 +16,9 @@ public function getInstallPath(PackageInterface $package) public static function formatHerokuSysName(string $name): string { - return sscanf($name, "heroku-sys/%s")[0] ?? $name; + // strip a "heroku-sys/" prefix if it exists, and in that case, also a ".native" postfix + // this turns our internal "heroku-sys/ext-foobar.native" or "heroku-sys/php" names into "ext-foobar" or "php" for display output + return preg_replace('#^(heroku-sys/)(.+?)(?(1).native)?$#', '$2', $name); } /** diff --git a/support/installer/src/ComposerInstallerPlugin.php b/support/installer/src/ComposerInstallerPlugin.php index 480c04b8e..c48923257 100644 --- a/support/installer/src/ComposerInstallerPlugin.php +++ b/support/installer/src/ComposerInstallerPlugin.php @@ -5,14 +5,12 @@ use Composer\Composer; use Composer\Factory; use Composer\IO\{IOInterface, ConsoleIO, NullIO}; -use Symfony\Component\Console\Formatter\OutputFormatter; -use Symfony\Component\Console\Helper\HelperSet; +use Symfony\Component\Console\Helper\{HelperSet,ProgressBar}; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\StreamOutput; -use Composer\Plugin\PluginInterface; +use Composer\Plugin\{PluginEvents,PluginInterface,PreFileDownloadEvent,PostFileDownloadEvent,PrePoolCreateEvent}; use Composer\EventDispatcher\EventSubscriberInterface; -use Composer\Installer\PackageEvent; -use Composer\Installer\PackageEvents; +use Composer\Installer\{InstallerEvent,InstallerEvents,PackageEvent,PackageEvents}; use Composer\Package\PackageInterface; use Composer\Util\Filesystem; @@ -33,19 +31,40 @@ class ComposerInstallerPlugin implements PluginInterface, EventSubscriberInterfa protected $profileCounter = 10; protected $configCounter = 10; + protected $requestedPackages = []; protected $allPlatformRequirements = null; + protected $progressBar; + public function activate(Composer $composer, IOInterface $io) { $this->composer = $composer; $this->io = $io; + // we were supplied with a file descriptor to write "display output" to + // this can be used by a calling buildpack to get a clean progress bar for downloads, followed by a list of package installs as they happen + // for this, we make a ConsoleIO instance to be passed to the downloaders for install() output, and a progress bar for our download event listeners if($fdno = getenv("PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_FDNO")) { - $styles = Factory::createAdditionalStyles(); - $formatter = new OutputFormatter(false, $styles); + // a new tag that can be used in output to prefix a line using the specified indentation + // this way the progress bar, downloaders, etc, do not have to handle the indentation each + $styles = [ + 'indent' => new IndentedOutputFormatterStyle(intval(getenv('PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_INDENT'))) + ]; + // special formatter that ignores colors if false is passed as first arg, we want that initially + $formatter = new NoColorsOutputFormatter(false, $styles); $input = new ArrayInput([]); $input->setInteractive(false); - $output = new StreamOutput(fopen("php://fd/{$fdno}", "w"), StreamOutput::VERBOSITY_NORMAL, null, $formatter); + // obey NO_COLOR to control whether or not we want a progress bar + // (unfortunately, we cannot get e.g. the --no-progress or --no-ansi options from the Composer command invocation) + // (using $io->isDecorated() does not help either, as regular stdout/stderr might be redirected, but not our display output FD) + $output = new StreamOutput(fopen("php://fd/{$fdno}", "w"), StreamOutput::VERBOSITY_NORMAL, !getenv('NO_COLOR'), $formatter); + if($output->isDecorated()) { + $this->progressBar = new ProgressBar($output); + $progressBarFormat = ProgressBar::getFormatDefinition('normal'); + $this->progressBar->setFormat(sprintf("Downloaded%s", $progressBarFormat)); + } + // we force ANSI output to on here for the indentation output formatter style to work + $output->setDecorated(true); $this->displayIo = new ConsoleIO($input, $output, new HelperSet()); } else { $this->displayIo = new NullIO(); @@ -102,9 +121,117 @@ public function uninstall(Composer $composer, IOInterface $io) public static function getSubscribedEvents() { - return [PackageEvents::POST_PACKAGE_INSTALL => 'onPostPackageInstall']; + return [ + PluginEvents::PRE_POOL_CREATE => 'onPrePoolCreate', + InstallerEvents::PRE_OPERATIONS_EXEC => 'onPreOperationsExec', + PluginEvents::PRE_FILE_DOWNLOAD => 'onPreFileDownload', + PluginEvents::POST_FILE_DOWNLOAD => 'onPostFileDownload', + PackageEvents::PRE_PACKAGE_INSTALL => 'onPrePackageInstall', + PackageEvents::POST_PACKAGE_INSTALL => 'onPostPackageInstall', + ]; + } + + // This does not fire on initial install, as the plugin gets installed as part of that, but the event fires before the plugin install. + // Just what we want, since the logic in here is for the "ext-foobar.native" install attempts after the main packages installation. + // For those invocations, the plugin is already enabled, and this event handler fires. + public function onPrePoolCreate(PrePoolCreateEvent $event) + { + // the list of explicitly requested packages from e.g. a 'composer require ext-foobar.native:*' + // we remember this for later, so we can output a message about already-enabled extensions + // this will be e.g. ["heroku-sys/ext-mbstring.native"] + $this->requestedPackages = $event->getRequest()->getUpdateAllowList(); + } + + // This does not fire on initial install, as the plugin gets installed as part of that, but the event fires before the plugin install. + // Just what we want, since the logic in here is for the "ext-foobar.native" install attempts after the main packages installation. + // For those invocations, the plugin is already enabled, and this event handler fires. + public function onPreOperationsExec(InstallerEvent $event) + { + // From the list of operations, we are getting all packages due for install. + // For each package, we check the "replaces" declarations. + // For instance, "heroku-sys/ext-mbstring" will declare that it replaces "heroku-sys/ext-mbstring.native". + // The "replaces" array is keyed by "replacement destination", so it's e.g.: + // {"heroku-sys/ext-mbstring.native": {Composer\Package\Link(source="heroku-sys/ext-mbstring",target="heroku-sys/ext-mbstring.native")}} + // For any package found here that is in the requestAllowList made in onPrePoolCreate, this means a regular installation, + // the Downloader will print install progress in this case. + // What we are really looking for, though, is packages in requestAllowList that are not in our list of operations, + // that means the extension is already enabled (either installed previously, or enabled in PHP by default). + // Because no installer event will fire in that case (nothing gets installed, after all), we want to output a message here. + $installs = []; + foreach($event->getTransaction()->getOperations() as $operation) { + // add the package itself, just for completeness + if ($operation->getOperationType() == "install") { + $installs[] = $operation->getPackage()->getPrettyName(); + } + // add all "replace" declarations from the package + $installs = array_merge($installs, array_keys($operation->getPackage()->getReplaces())); + } + foreach(array_diff($this->requestedPackages, $installs) as $requestedPackageNotInstalled) { + $this->displayIo->write(sprintf('- %s (already enabled)', ComposerInstaller::formatHerokuSysName($requestedPackageNotInstalled))); + } + } + + // Because our plugin declares "plugin-modifies-downloads", Composer installs it first. + // After that, all other package downloads trigger this event. + public function onPreFileDownload(PreFileDownloadEvent $event) + { + $package = $event->getContext(); + if($event->getType() != 'package' || $package->getDistType() != 'heroku-sys-tar') { + return; + } + // downloads happen in parallel, so marking progress here already would be useless + // but we can update the number of expected downloads on the progress bar + if($this->progressBar) { + $downloadCount = $this->progressBar->getMaxSteps(); + if(!$downloadCount++) { // post-increment operator + // first invocation, we want to initialize the progress bar with a start time + $this->progressBar->start($downloadCount); + // however, our maximum step count will now increase with each onPreFileDownload + // that looks a little confusing if we print every time, so we clear the progress bar again immediately + // also useful in case our caller printed something on the same line, before the install + $this->progressBar->clear(); + } else { + $this->progressBar->setMaxSteps($downloadCount); + } + } + } + + // Because our plugin declares "plugin-modifies-downloads", Composer installs it first. + // After that, all other package downloads trigger this event. + // We use it to output progress info when a download finishes + // (Downloader::download returns a promise for parallel downloads, so would be as useless as onPreFileDownload above) + public function onPostFileDownload(PostFileDownloadEvent $event) + { + if($event->getType() != 'package' || $event->getContext()->getDistType() != 'heroku-sys-tar') { + return; + } + if($this->progressBar) { + $this->progressBar->advance(); // this will re-draw for us + } + } + + // Because our plugin declares "plugin-modifies-install-path", Composer installs it first. + // After that, all other package installs trigger this event. + // Nothing to do for us here except to clear a progress bar if it exists + public function onPrePackageInstall(PackageEvent $event) + { + // clear our progress bar, since we're done with downloads + // the actual package installs are printed via the Downloaders, just like Composer does it + if($this->progressBar) { + if($this->displayIo->isDecorated()) { + // display output is ANSI capable, we can clear the progress bar + $this->progressBar->clear(); + } else { + // display output is not ANSI capable, we need a line break after the progress bar + $this->displayIo->write(""); + } + $this->progressBar = null; + } } + // Because our plugin declares "plugin-modifies-install-path", Composer installs it first. + // After that, all other package installs trigger this event. + // Here we do a lot of the "heavy lifting" public function onPostPackageInstall(PackageEvent $event) { // first, load all platform requirements from all operations diff --git a/support/installer/src/Downloader.php b/support/installer/src/Downloader.php index 320a6110b..3da51d5c5 100644 --- a/support/installer/src/Downloader.php +++ b/support/installer/src/Downloader.php @@ -59,7 +59,8 @@ public function install(PackageInterface $package, string $path, bool $output = $fn = str_replace('/', '$', $package->getPrettyName()); $marker = "$path/$fn.extracted"; $this->addCleanupPath($package, $marker); - $this->displayIo->write(sprintf('- %s (%s)', ComposerInstaller::formatHerokuSysName($package->getPrettyName()), $package->getFullPrettyVersion())); + # our indent style can't be nested together with other styling tags + $this->displayIo->write(sprintf("- %s (%s)", ComposerInstaller::formatHerokuSysName($package->getPrettyName()), $package->getFullPrettyVersion())); return parent::install($package, $path, $output); } } diff --git a/support/installer/src/IndentedOutputFormatterStyle.php b/support/installer/src/IndentedOutputFormatterStyle.php new file mode 100644 index 000000000..277081da5 --- /dev/null +++ b/support/installer/src/IndentedOutputFormatterStyle.php @@ -0,0 +1,21 @@ +prefix = str_repeat(" ", $indent); + parent::__construct($foreground, $background, $options); + } + + public function apply(string $text) + { + return sprintf("%s%s", $this->prefix, $text); + } +} diff --git a/support/installer/src/NoColorsOutputFormatter.php b/support/installer/src/NoColorsOutputFormatter.php new file mode 100644 index 000000000..0b9839f35 --- /dev/null +++ b/support/installer/src/NoColorsOutputFormatter.php @@ -0,0 +1,22 @@ +setStyle('error', new NullOutputFormatterStyle()); + $this->setStyle('info', new NullOutputFormatterStyle()); + $this->setStyle('comment', new NullOutputFormatterStyle()); + $this->setStyle('question', new NullOutputFormatterStyle()); + } + } +} diff --git a/support/installer/src/NoopDownloader.php b/support/installer/src/NoopDownloader.php index 30740dfd6..c79ceabec 100644 --- a/support/installer/src/NoopDownloader.php +++ b/support/installer/src/NoopDownloader.php @@ -40,7 +40,8 @@ public function prepare(string $type, PackageInterface $package, string $path, ? public function install(PackageInterface $package, string $path): PromiseInterface { - $this->displayIo->write(sprintf("- %s", ($this->humanMessageFormatter)($package, $path))); + # our indent style can't be nested together with other styling tags + $this->displayIo->write(sprintf("- %s", ($this->humanMessageFormatter)($package, $path))); $this->io->writeError(sprintf(" - %s", ($this->installMessageFormatter)($package, $path))); return \React\Promise\resolve(null); } diff --git a/test/fixtures/platform/installer/polyfills-nodowngrade/README.md b/test/fixtures/platform/installer/polyfills-nodowngrade/README.md new file mode 100644 index 000000000..4b447e268 --- /dev/null +++ b/test/fixtures/platform/installer/polyfills-nodowngrade/README.md @@ -0,0 +1,5 @@ +This test case asserts that a native extension install attempt for a polyfilled extension will not change existing locked dependencies. + +It used to test using `ext-xmlrpc`, which was only bundled with PHP 7. The initial `"php": "*"` requirement would pick PHP 8 (which no longer had `ext-xmlrpc`) due to an available polyfill for it, and we don't want to downgrade PHP to version 7 once the `ext-xmlrpc.native` install attempt is made. + +We don't have PHP 7 anymore, and the only recently retired extension is `ext-imap` (since PHP 8.4), but that is available through PECL and we offer it, so we have to use specific versions of extensions. A suitable candidate therefore is `ext-newrelic`, which is only available in version 11 for PHP 8.4, so our polyfill provides `ext-newrelic` version 10, and `composer.json` also depends on version 10. diff --git a/test/fixtures/platform/installer/polyfills-nodowngrade/composer.json b/test/fixtures/platform/installer/polyfills-nodowngrade/composer.json index 5597abf61..72ddd90e3 100644 --- a/test/fixtures/platform/installer/polyfills-nodowngrade/composer.json +++ b/test/fixtures/platform/installer/polyfills-nodowngrade/composer.json @@ -3,14 +3,14 @@ { "type": "package", "package": { - "name": "dummypak/ext-xmlrpc-polyfill", - "version": "1.0.3", + "name": "dummypak/ext-newrelic10-polyfill", + "version": "10.22.0.12", "type": "metapackage", "require": { - "php": "^7.0 | ^8.0" + "php": "^8.0" }, "provide": { - "ext-xmlrpc": "8.1.0" + "ext-newrelic": "10.22.0.12" } } }, @@ -32,13 +32,13 @@ "require": { "php": "*", "dummypak/ext-pq-polyfill": "^1.0", - "dummypak/ext-xmlrpc-polyfill": "^1.0", + "dummypak/ext-newrelic10-polyfill": "^10.0", "symfony/polyfill-ctype": "^1.24.0", "symfony/polyfill-mbstring": "^1.24.0", "symfony/polyfill-uuid": "^1.24.0", "ext-ctype": "*", "ext-pq": "*", "ext-uuid": "*", - "ext-xmlrpc": "*" + "ext-newrelic": "10.*" } } diff --git a/test/fixtures/platform/installer/polyfills-nodowngrade/composer.lock b/test/fixtures/platform/installer/polyfills-nodowngrade/composer.lock index 69e1e40bd..65028d1f1 100644 --- a/test/fixtures/platform/installer/polyfills-nodowngrade/composer.lock +++ b/test/fixtures/platform/installer/polyfills-nodowngrade/composer.lock @@ -4,46 +4,46 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e3b46e9d44539b00a50d3340c1882871", + "content-hash": "090f8ec868590f10707b2acfc875e899", "packages": [ { - "name": "dummypak/ext-pq-polyfill", - "version": "1.1.7", + "name": "dummypak/ext-newrelic10-polyfill", + "version": "10.22.0.12", "require": { "php": "^7.0 | ^8.0" }, "provide": { - "ext-pq": "*" + "ext-newrelic": "10.22.0.12" }, "type": "metapackage" }, { - "name": "dummypak/ext-xmlrpc-polyfill", - "version": "1.0.3", + "name": "dummypak/ext-pq-polyfill", + "version": "1.1.7", "require": { "php": "^7.0 | ^8.0" }, "provide": { - "ext-xmlrpc": "8.1.0" + "ext-pq": "*" }, "type": "metapackage" }, { "name": "symfony/polyfill-ctype", - "version": "v1.24.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "30885182c981ab175d4d034db0f6f469898070ab" + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/30885182c981ab175d4d034db0f6f469898070ab", - "reference": "30885182c981ab175d4d034db0f6f469898070ab", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "provide": { "ext-ctype": "*" @@ -53,21 +53,18 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.23-dev" - }, "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - }, "files": [ "bootstrap.php" - ] + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -92,7 +89,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.24.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" }, "funding": [ { @@ -108,24 +105,25 @@ "type": "tidelift" } ], - "time": "2021-10-20T20:35:02+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.24.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "0abb51d2f102e00a4eefcf46ba7fec406d245825" + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/0abb51d2f102e00a4eefcf46ba7fec406d245825", - "reference": "0abb51d2f102e00a4eefcf46ba7fec406d245825", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", "shasum": "" }, "require": { - "php": ">=7.1" + "ext-iconv": "*", + "php": ">=7.2" }, "provide": { "ext-mbstring": "*" @@ -135,21 +133,18 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.23-dev" - }, "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - }, "files": [ "bootstrap.php" - ] + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -175,7 +170,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.24.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" }, "funding": [ { @@ -191,24 +186,24 @@ "type": "tidelift" } ], - "time": "2021-11-30T18:21:41+00:00" + "time": "2024-12-23T08:48:59+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.24.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "7529922412d23ac44413d0f308861d50cf68d3ee" + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/7529922412d23ac44413d0f308861d50cf68d3ee", - "reference": "7529922412d23ac44413d0f308861d50cf68d3ee", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "provide": { "ext-uuid": "*" @@ -218,12 +213,9 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.23-dev" - }, "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -257,7 +249,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.24.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.32.0" }, "funding": [ { @@ -273,13 +265,13 @@ "type": "tidelift" } ], - "time": "2021-10-20T20:35:02+00:00" + "time": "2024-09-09T11:45:10+00:00" } ], "packages-dev": [], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -287,8 +279,8 @@ "ext-ctype": "*", "ext-pq": "*", "ext-uuid": "*", - "ext-xmlrpc": "*" + "ext-newrelic": "10.*" }, - "platform-dev": [], - "plugin-api-version": "2.2.0" + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/test/fixtures/platform/installer/polyfills/composer.json b/test/fixtures/platform/installer/polyfills/composer.json index 063273534..1a49afc31 100644 --- a/test/fixtures/platform/installer/polyfills/composer.json +++ b/test/fixtures/platform/installer/polyfills/composer.json @@ -37,7 +37,9 @@ "symfony/polyfill-mbstring": "^1.32.0", "symfony/polyfill-uuid": "^1.24.0", "ext-ctype": "*", + "ext-gd": "*", "ext-imap": "*", + "ext-newrelic": "*", "ext-pq": "*", "ext-uuid": "*" } diff --git a/test/fixtures/platform/installer/polyfills/composer.lock b/test/fixtures/platform/installer/polyfills/composer.lock index 5895efd1b..999b9616f 100644 --- a/test/fixtures/platform/installer/polyfills/composer.lock +++ b/test/fixtures/platform/installer/polyfills/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dabf63f39109eba8917b8358fbaca9a0", + "content-hash": "ebd1349869856ecd6ad0896df768f7b0", "packages": [ { "name": "dummypak/ext-imap-polyfill", @@ -277,7 +277,9 @@ "platform": { "php": "8.3.*", "ext-ctype": "*", + "ext-gd": "*", "ext-imap": "*", + "ext-newrelic": "*", "ext-pq": "*", "ext-uuid": "*" }, diff --git a/test/spec/platform_spec.rb b/test/spec/platform_spec.rb index 4d59d1982..d930fe395 100644 --- a/test/spec/platform_spec.rb +++ b/test/spec/platform_spec.rb @@ -87,25 +87,68 @@ describe "Composer Plugin" do before(:all) do - @install_tmpdir = Dir.mktmpdir(nil, generator_fixtures_subdir) # this needs to be on the same level as the source fixture so the relative path references to the installer plugin inside composer.json work + fixture = "test/fixtures/platform/installer/polyfills" + stack_with_arch = stack = ENV["STACK"] || "heroku-24" + stack_with_arch = "#{stack}-amd64" unless stack == "heroku-22" + + @install_tmpdir = Dir.mktmpdir("install") + Dir.chdir(fixture) do |cwd| + bp_root = File.expand_path([".."].cycle(fixture.count("/")+1).to_a.join("/")) # use right "../.." sequence to get us back to the root of the buildpack + stdout, status = Open3.capture2( + {"STACK" => stack}, # env vars + "php", + "#{bp_root}/bin/util/platform.php", + "#{bp_root}/support/installer", + "https://lang-php.s3.us-east-1.amazonaws.com/dist-#{stack_with_arch}-stable/packages.json" + ) + raise unless status.success? + File.open("#{@install_tmpdir}/composer.json", "w") { |file| file.write(stdout) } + end + @export_tmpfile = Tempfile.new("export") @humanlog_tmpfile = Tempfile.new("humanlog") @profiled_tmpdir = Dir.mktmpdir("profile.d") - FileUtils.cp("#{generator_fixtures_subdir}/base/expected_platform_composer.json", "#{@install_tmpdir}/composer.json") + @providedextensionslog_tmpfile = Tempfile.new("providedextensionslog") Dir.chdir(@install_tmpdir) do - cmd = "exec {PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_FDNO}> #{@humanlog_tmpfile.path}; export PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_FDNO; export_file_path=#{@export_tmpfile.path} profile_dir_path=#{@profiled_tmpdir} composer install --no-dev" + # regular install first + cmd = <<~EOF + exec {PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_FDNO}> #{Shellwords.escape(@humanlog_tmpfile.path)} + export PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_FDNO + export PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_INDENT=4 + export export_file_path=#{Shellwords.escape(@export_tmpfile.path)} + export profile_dir_path=#{Shellwords.escape(@profiled_tmpdir)} + export providedextensionslog_file_path=#{Shellwords.escape(@providedextensionslog_tmpfile.path)} + composer install --no-dev + EOF @stdout, @stderr, @status = Open3.capture3("bash -c #{Shellwords.escape(cmd)}") + + # the fixture has polyfill packages, where a userland package "provide"s a native extension + # emulate a force-install for such a native extension, like bin/compile would + @humanlog_native_tmpfile = Tempfile.new("humanlog_native") + cmd = <<~EOF + exec {PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_FDNO}> #{Shellwords.escape(@humanlog_native_tmpfile.path)} + export PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_FDNO + export PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_INDENT=4 + export export_file_path=#{Shellwords.escape(@export_tmpfile.path)} + export profile_dir_path=#{Shellwords.escape(@profiled_tmpdir)} + composer require 'heroku-sys/ext-pq.native:*' + EOF + @stdout_native, @stderr_native, @status_native = Open3.capture3("bash -c #{Shellwords.escape(cmd)}") end end after(:all) do - FileUtils.remove_entry(@install_tmpdir) - FileUtils.remove_entry(@profiled_tmpdir) - @export_tmpfile.unlink + FileUtils.remove_entry(@install_tmpdir) if @install_tmpdir + FileUtils.remove_entry(@profiled_tmpdir) if @profiled_tmpdir + @export_tmpfile.unlink if @export_tmpfile + @humanlog_tmpfile.unlink if @humanlog_tmpfile + @humanlog_native_tmpfile.unlink if @humanlog_native_tmpfile + @providedextensionslog_tmpfile.unlink if @providedextensionslog_tmpfile end it "performs an installation successfully" do expect(@status.exitstatus).to eq(0), "composer install failed, stderr: #{@stderr}, stdout: #{@stdout}" + expect(@status_native.exitstatus).to eq(0), "composer require ext-pq.native failed, stderr: #{@stderr_native}, stdout: #{@stdout_native}" end it "installs multiple packages into the same directory structure" do @@ -133,34 +176,51 @@ it "writes extension configs so they get loaded in the order of package installs" do # fetch all extension INI files and sort them (PHP reads them in alnum sort order on startup, but Ruby's Dir has no guaranteed order) extensionconfigs = Dir.each_child("#{@install_tmpdir}/etc/php/conf.d").sort - # remember from installer outout if ext-blackfire was installed first or ext-redis - order_to_check = @stderr.index("Installing heroku-sys/ext-blackfire ") > @stderr.index("Installing heroku-sys/ext-redis ") + # remember from installer output if ext-gd was installed first or ext-newrelic + order_to_check = @stderr.index("Enabling heroku-sys/ext-gd ") > @stderr.index("Installing heroku-sys/ext-newrelic ") # now expect the same order in `conf.d/` for the two extensions' configs - expect(extensionconfigs.index {|f| f.include?("ext-blackfire.ini")} > extensionconfigs.index {|f| f.include?("ext-redis.ini")}).to eq(order_to_check) + expect(extensionconfigs.index {|f| f.include?("ext-gd.ini")} > extensionconfigs.index {|f| f.include?("ext-newrelic.ini")}).to eq(order_to_check) + # also check ext-raphf came before ext-pq + expect(@stderr_native.index("Installing heroku-sys/ext-raphf ")).to be < @stderr_native.index("Installing heroku-sys/ext-pq ") + expect(extensionconfigs.index {|f| f.include?("ext-raphf.ini")}).to be < extensionconfigs.index {|f| f.include?("ext-pq.ini")} end it "enables shared extensions bundled with PHP if necessary" do - expect(@stderr).to match("Enabling heroku-sys/ext-mbstring") - expect(Dir.entries("#{@install_tmpdir}/etc/php/conf.d").any? {|f| f.include?("ext-mbstring.ini")}).to eq(true) + expect(@stderr).to match("Enabling heroku-sys/ext-gd") + expect(Dir.entries("#{@install_tmpdir}/etc/php/conf.d").any? {|f| f.include?("ext-gd.ini")}).to eq(true) end - it "writes a human-readable log to a given file descriptor" do - version_triple = /\(\d+\.\d+\.\d+(\+[^)]+)?\)/ # 1.2.3 or 1.2.3+build2 + it "writes a log of userland polyfills that provide native extensions for subsequent install attempts" do + expect(@providedextensionslog_tmpfile.read) + .to include("symfony/polyfill-uuid heroku-sys/ext-uuid:*") + .and include("dummypak/ext-pq-polyfill heroku-sys/ext-pq:*") + .and include("dummypak/ext-imap-polyfill heroku-sys/ext-imap:8.3.0") + .and include("symfony/polyfill-ctype heroku-sys/ext-ctype:*") + end + + it "writes a human-readable log (with the expected indentation) to a given file descriptor" do + version_triple = /\(\d+\.\d+\.\d+(\.\d+)?(\+[^)]+)?\)/ # 1.2.3 or 1.2.3+build2, optionally a fourth version dot bundled = /\(bundled with php\)/ + # the download progress indicator is written using ANSI cursors: + # " Downloaded 0/8 [>---------------------------] 0%\e[1G\e[2K Downloaded 1/8 [===>------------------------] 12%\e[1G\e[2K Downloaded 2/8 [=======>--------------------] 25%\e[1G\e[2K Downloaded 4/8 [==============>-------------] 50%\e[1G\e[2K Downloaded 5/8 [=================>----------] 62%\e[1G\e[2K Downloaded 6/8 [=====================>------] 75%\e[1G\e[2K Downloaded 7/8 [========================>---] 87%\e[1G\e[2K Downloaded 8/8 [============================] 100%\e[1G\e[2K - php (8.4.6)\n - ext-sqlite3 (bundled with php)\n..." + # we want to check that the download progress is actually printed, and then removed using ANSI codes + # so we just match on the last progress report (since we're not always guaranteed to get one for every step depending on speed) expect(@humanlog_tmpfile.read).to match Regexp.new(<<~EOF) - ^- php #{version_triple.source}$ - ^- ext-sqlite3 #{bundled.source}$ - ^- ext-redis #{version_triple.source}$ - ^- ext-mbstring #{bundled.source}$ - ^- ext-ldap #{bundled.source}$ - ^- ext-intl #{bundled.source}$ - ^- ext-imap #{version_triple.source}$ - ^- ext-gmp #{bundled.source}$ - ^- blackfire #{version_triple.source}$ - ^- ext-blackfire #{version_triple.source}$ - ^- apache #{version_triple.source}$ - ^- composer #{version_triple.source}$ - ^- nginx #{version_triple.source}$ + Downloaded 5/5 \\[============================\\] 100%\ + \\e\\[1G\\e\\[2K\ + - php #{version_triple.source} + - ext-newrelic #{version_triple.source} + - ext-gd #{bundled.source} + - apache #{version_triple.source} + - composer #{version_triple.source} + - nginx #{version_triple.source} + EOF + + expect(@humanlog_native_tmpfile.read).to match Regexp.new(<<~EOF) + Downloaded 2/2 \\[============================\\] 100%\ + \\e\\[1G\\e\\[2K\ + - ext-raphf #{version_triple.source} + - ext-pq #{version_triple.source} EOF end @@ -299,13 +359,13 @@ expect(app.output).to include("detected userland polyfill packages for PHP extensions") expect(app.output).not_to include("- ext-mbstring") # ext not required by any dependency, so should not be installed or even attempted ("- ext-mbstring...") out_before_polyfills, out_after_polyfills = app.output.split("detected userland polyfill packages for PHP extensions", 2) - expect(out_before_polyfills).to include("- php (8") + expect(out_before_polyfills).to include("- php (8.4.") expect(out_after_polyfills).to include("- ext-ctype (already enabled)") expect(out_after_polyfills).to include("- ext-raphf (") # ext-pq, which we required, depends on it expect(out_after_polyfills).to include("- ext-pq (") expect(out_after_polyfills).to include("- ext-uuid (") - expect(out_after_polyfills).not_to include("- ext-xmlrpc (") - expect(out_after_polyfills).to include("no suitable native version of ext-xmlrpc available") + expect(out_after_polyfills).not_to include("- ext-newrelic (") + expect(out_after_polyfills).to include("no suitable native version of ext-newrelic available") end end it "ignores a polyfill for an extension that another extension depends upon" do