diff --git a/src/Container.php b/src/Container.php index a2b5adf..1c181a9 100644 --- a/src/Container.php +++ b/src/Container.php @@ -77,6 +77,7 @@ static function (ContainerInterface $container): Build { static function (ContainerInterface $container): Ini\SetupIniApproach { return new Ini\PickBestSetupIniApproach([ $container->get(Ini\PreCheckExtensionAlreadyLoaded::class), + $container->get(Ini\OndrejPhpenmod::class), $container->get(Ini\DockerPhpExtEnable::class), $container->get(Ini\StandardAdditionalPhpIniDirectory::class), $container->get(Ini\StandardSinglePhpIni::class), diff --git a/src/Installing/Ini/OndrejPhpenmod.php b/src/Installing/Ini/OndrejPhpenmod.php new file mode 100644 index 0000000..051dfb6 --- /dev/null +++ b/src/Installing/Ini/OndrejPhpenmod.php @@ -0,0 +1,194 @@ +phpenmodPath() !== null; + } + + public function setup( + TargetPlatform $targetPlatform, + DownloadedPackage $downloadedPackage, + BinaryFile $binaryFile, + OutputInterface $output, + ): bool { + $phpenmodPath = $this->phpenmodPath(); + + /** In practice, this shouldn't happen since {@see canBeUsed()} checks this */ + if ($phpenmodPath === null) { + return false; + } + + // the Ondrej repo uses an additional php.ini directory, if this isn't set, we may not actually be using Ondrej repo for this particular PHP install + $additionalPhpIniPath = $targetPlatform->phpBinaryPath->additionalIniDirectory(); + + if ($additionalPhpIniPath === null) { + $output->writeln( + 'Additional INI file path was not set - may not be Ondrej PHP repo', + OutputInterface::VERBOSITY_VERBOSE, + ); + + return false; + } + + // Cursory check for the expected PHP INI directory; this is another indication we're using the Ondrej repo + if (preg_match('#/etc/php/\d\.\d/[a-z-_]+/conf.d#', $additionalPhpIniPath) !== 1) { + $output->writeln( + sprintf( + 'Warning: additional INI file path was not in the expected format (/etc/php/{version}/{sapi}/conf.d). Path was: %s', + $additionalPhpIniPath, + ), + OutputInterface::VERBOSITY_VERY_VERBOSE, + ); + } + + $expectedModsAvailablePath = sprintf($this->modsAvailablePath, $targetPlatform->phpBinaryPath->majorMinorVersion()); + + if (! file_exists($expectedModsAvailablePath)) { + $output->writeln( + sprintf( + 'Mods available path %s does not exist', + $expectedModsAvailablePath, + ), + OutputInterface::VERBOSITY_VERBOSE, + ); + + return false; + } + + if (! is_dir($expectedModsAvailablePath)) { + $output->writeln( + sprintf( + 'Mods available path %s is not a directory', + $expectedModsAvailablePath, + ), + OutputInterface::VERBOSITY_VERBOSE, + ); + + return false; + } + + if (! is_writable($expectedModsAvailablePath)) { + $output->writeln( + sprintf( + 'Mods available path %s is not writable', + $expectedModsAvailablePath, + ), + OutputInterface::VERBOSITY_VERBOSE, + ); + + return false; + } + + $expectedIniFile = sprintf( + '%s%s%s.ini', + rtrim($expectedModsAvailablePath, DIRECTORY_SEPARATOR), + DIRECTORY_SEPARATOR, + $downloadedPackage->package->extensionName->name(), + ); + + $pieCreatedTheIniFile = false; + if (! file_exists($expectedIniFile)) { + $output->writeln( + sprintf( + 'Creating new INI file based on extension priority: %s', + $expectedIniFile, + ), + OutputInterface::VERBOSITY_VERY_VERBOSE, + ); + $pieCreatedTheIniFile = true; + touch($expectedIniFile); + } + + $addingExtensionWasSuccessful = ($this->checkAndAddExtensionToIniIfNeeded)( + $expectedIniFile, + $targetPlatform, + $downloadedPackage, + $output, + static function () use ($phpenmodPath, $targetPlatform, $downloadedPackage, $output): bool { + try { + Process::run([ + $phpenmodPath, + '-v', + $targetPlatform->phpBinaryPath->majorMinorVersion(), + '-s', + 'ALL', + $downloadedPackage->package->extensionName->name(), + ]); + + return true; + } catch (ProcessFailedException $processFailedException) { + $output->writeln( + sprintf( + 'Failed to use %s to enable %s for PHP %s: %s', + $phpenmodPath, + $downloadedPackage->package->extensionName->name(), + $targetPlatform->phpBinaryPath->majorMinorVersion(), + $processFailedException->getMessage(), + ), + OutputInterface::VERBOSITY_VERBOSE, + ); + + return false; + } + }, + ); + + if (! $addingExtensionWasSuccessful && $pieCreatedTheIniFile) { + unlink($expectedIniFile); + } + + return $addingExtensionWasSuccessful; + } + + /** @return non-empty-string|null */ + private function phpenmodPath(): string|null + { + if (Platform::isWindows()) { + return null; + } + + try { + $phpenmodPath = Process::run(['which', $this->phpenmod]); + + return $phpenmodPath !== '' ? $phpenmodPath : null; + } catch (ProcessFailedException) { + return null; + } + } +} diff --git a/test/assets/phpenmod/bad b/test/assets/phpenmod/bad new file mode 100755 index 0000000..33514cf --- /dev/null +++ b/test/assets/phpenmod/bad @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +echo "something bad happened" +exit 1 diff --git a/test/assets/phpenmod/good b/test/assets/phpenmod/good new file mode 100755 index 0000000..5e2e73e --- /dev/null +++ b/test/assets/phpenmod/good @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +echo "hi" +exit 0 diff --git a/test/unit/Installing/Ini/OndrejPhpenmodTest.php b/test/unit/Installing/Ini/OndrejPhpenmodTest.php new file mode 100644 index 0000000..4fcecb4 --- /dev/null +++ b/test/unit/Installing/Ini/OndrejPhpenmodTest.php @@ -0,0 +1,465 @@ +output = new BufferedOutput(BufferedOutput::VERBOSITY_VERBOSE); + + $this->mockPhpBinary = $this->createMock(PhpBinaryPath::class); + /** + * @psalm-suppress PossiblyNullFunctionCall + * @psalm-suppress UndefinedThisPropertyAssignment + */ + (fn () => $this->phpBinaryPath = '/path/to/php') + ->bindTo($this->mockPhpBinary, PhpBinaryPath::class)(); + + $this->checkAndAddExtensionToIniIfNeeded = $this->createMock(CheckAndAddExtensionToIniIfNeeded::class); + + $this->targetPlatform = new TargetPlatform( + OperatingSystem::NonWindows, + OperatingSystemFamily::Linux, + $this->mockPhpBinary, + Architecture::x86_64, + ThreadSafetyMode::ThreadSafe, + 1, + null, + ); + + $this->downloadedPackage = DownloadedPackage::fromPackageAndExtractedPath( + new Package( + $this->createMock(CompletePackageInterface::class), + ExtensionType::PhpModule, + ExtensionName::normaliseFromString('foobar'), + 'foo/bar', + '1.2.3', + null, + [], + true, + true, + null, + null, + null, + 99, + ), + '/path/to/extracted/source', + ); + + $this->binaryFile = new BinaryFile('/path/to/compiled/extension.so', 'fake checksum'); + } + + #[RequiresOperatingSystemFamily('Windows')] + public function testCanBeUsedReturnsFalseOnWindows(): void + { + self::assertFalse( + (new OndrejPhpenmod( + $this->checkAndAddExtensionToIniIfNeeded, + self::GOOD_PHPENMOD, + self::NON_EXISTENT_MODS_AVAILABLE_PATH, + ))->canBeUsed($this->targetPlatform), + ); + } + + #[RequiresOperatingSystemFamily('Linux')] + public function testCanBeUsedReturnsFalseWhenPhpenmodNotInPath(): void + { + self::assertFalse( + (new OndrejPhpenmod( + $this->checkAndAddExtensionToIniIfNeeded, + self::NON_EXISTENT_PHPENMOD, + self::NON_EXISTENT_MODS_AVAILABLE_PATH, + ))->canBeUsed($this->targetPlatform), + ); + } + + #[RequiresOperatingSystemFamily('Linux')] + public function testCanBeUsedReturnsTrueWhenPhpenmodInPath(): void + { + self::assertTrue( + (new OndrejPhpenmod( + $this->checkAndAddExtensionToIniIfNeeded, + self::GOOD_PHPENMOD, + self::NON_EXISTENT_MODS_AVAILABLE_PATH, + ))->canBeUsed($this->targetPlatform), + ); + } + + #[RequiresOperatingSystemFamily('Windows')] + public function testSetupReturnsFalseOnWindows(): void + { + $this->checkAndAddExtensionToIniIfNeeded + ->expects(self::never()) + ->method('__invoke'); + + self::assertFalse( + (new OndrejPhpenmod( + $this->checkAndAddExtensionToIniIfNeeded, + self::GOOD_PHPENMOD, + self::NON_EXISTENT_MODS_AVAILABLE_PATH, + ))->setup( + $this->targetPlatform, + $this->downloadedPackage, + $this->binaryFile, + $this->output, + ), + ); + } + + #[RequiresOperatingSystemFamily('Linux')] + public function testSetupReturnsFalseWhenPhpenmodNotInPath(): void + { + $this->checkAndAddExtensionToIniIfNeeded + ->expects(self::never()) + ->method('__invoke'); + + self::assertFalse( + (new OndrejPhpenmod( + $this->checkAndAddExtensionToIniIfNeeded, + self::NON_EXISTENT_PHPENMOD, + self::NON_EXISTENT_MODS_AVAILABLE_PATH, + ))->setup( + $this->targetPlatform, + $this->downloadedPackage, + $this->binaryFile, + $this->output, + ), + ); + } + + #[RequiresOperatingSystemFamily('Linux')] + public function testSetupReturnsFalseWhenAdditionalPhpIniPathNotSet(): void + { + $this->mockPhpBinary + ->expects(self::once()) + ->method('additionalIniDirectory') + ->willReturn(null); + + $this->checkAndAddExtensionToIniIfNeeded + ->expects(self::never()) + ->method('__invoke'); + + self::assertFalse( + (new OndrejPhpenmod( + $this->checkAndAddExtensionToIniIfNeeded, + self::GOOD_PHPENMOD, + self::NON_EXISTENT_MODS_AVAILABLE_PATH, + ))->setup( + $this->targetPlatform, + $this->downloadedPackage, + $this->binaryFile, + $this->output, + ), + ); + + self::assertStringContainsString( + 'Additional INI file path was not set - may not be Ondrej PHP repo', + $this->output->fetch(), + ); + } + + #[RequiresOperatingSystemFamily('Linux')] + public function testSetupReturnsFalseWhenModsAvailablePathDoesNotExist(): void + { + $this->mockPhpBinary + ->method('additionalIniDirectory') + ->willReturn('/value/does/not/matter'); + + $this->checkAndAddExtensionToIniIfNeeded + ->expects(self::never()) + ->method('__invoke'); + + self::assertFalse( + (new OndrejPhpenmod( + $this->checkAndAddExtensionToIniIfNeeded, + self::GOOD_PHPENMOD, + self::NON_EXISTENT_MODS_AVAILABLE_PATH, + ))->setup( + $this->targetPlatform, + $this->downloadedPackage, + $this->binaryFile, + $this->output, + ), + ); + + self::assertStringContainsString( + 'Mods available path ' . self::NON_EXISTENT_MODS_AVAILABLE_PATH . ' does not exist', + $this->output->fetch(), + ); + } + + #[RequiresOperatingSystemFamily('Linux')] + public function testSetupReturnsFalseWhenModsAvailablePathNotADirectory(): void + { + $this->mockPhpBinary + ->method('additionalIniDirectory') + ->willReturn('/value/does/not/matter'); + + $this->checkAndAddExtensionToIniIfNeeded + ->expects(self::never()) + ->method('__invoke'); + + self::assertFalse( + (new OndrejPhpenmod( + $this->checkAndAddExtensionToIniIfNeeded, + self::GOOD_PHPENMOD, + __FILE__, + ))->setup( + $this->targetPlatform, + $this->downloadedPackage, + $this->binaryFile, + $this->output, + ), + ); + + self::assertStringContainsString( + 'Mods available path ' . __FILE__ . ' is not a directory', + $this->output->fetch(), + ); + } + + #[RequiresOperatingSystemFamily('Linux')] + public function testSetupReturnsFalseWhenModsAvailablePathNotWritable(): void + { + if (TargetPlatform::isRunningAsRoot()) { + self::markTestSkipped('Test cannot be run as root, as root can always write files'); + } + + $modsAvailablePath = tempnam(sys_get_temp_dir(), 'pie_test_mods_available_path'); + unlink($modsAvailablePath); + mkdir($modsAvailablePath, 000, true); + + $this->mockPhpBinary + ->method('additionalIniDirectory') + ->willReturn('/value/does/not/matter'); + + $this->checkAndAddExtensionToIniIfNeeded + ->expects(self::never()) + ->method('__invoke'); + + self::assertFalse( + (new OndrejPhpenmod( + $this->checkAndAddExtensionToIniIfNeeded, + self::GOOD_PHPENMOD, + $modsAvailablePath, + ))->setup( + $this->targetPlatform, + $this->downloadedPackage, + $this->binaryFile, + $this->output, + ), + ); + + self::assertStringContainsString( + 'Mods available path ' . $modsAvailablePath . ' is not writable', + $this->output->fetch(), + ); + + rmdir($modsAvailablePath); + } + + #[RequiresOperatingSystemFamily('Linux')] + public function testSetupReturnsFalseAndRemovesPieCreatedIniFileWhenPhpenmodAdditionalStepFails(): void + { + $modsAvailablePath = tempnam(sys_get_temp_dir(), 'pie_test_mods_available_path'); + unlink($modsAvailablePath); + mkdir($modsAvailablePath, recursive: true); + + $expectedIniFile = $modsAvailablePath . DIRECTORY_SEPARATOR . 'foobar.ini'; + + $this->mockPhpBinary + ->method('additionalIniDirectory') + ->willReturn('/value/does/not/matter'); + + $this->checkAndAddExtensionToIniIfNeeded + ->expects(self::once()) + ->method('__invoke') + ->with( + $expectedIniFile, + $this->targetPlatform, + $this->downloadedPackage, + $this->output, + self::isType(IsType::TYPE_CALLABLE), + ) + ->willReturnCallback( + /** @param callable():bool $additionalEnableStep */ + static function ( + string $iniFile, + TargetPlatform $targetPlatform, + DownloadedPackage $downloadedPackage, + OutputInterface $output, + callable $additionalEnableStep, + ): bool { + return $additionalEnableStep(); + }, + ); + + self::assertFalse( + (new OndrejPhpenmod( + $this->checkAndAddExtensionToIniIfNeeded, + self::BAD_PHPENMOD, + $modsAvailablePath, + ))->setup( + $this->targetPlatform, + $this->downloadedPackage, + $this->binaryFile, + $this->output, + ), + ); + + self::assertFileDoesNotExist($expectedIniFile); + + self::assertStringContainsString( + 'something bad happened', + $this->output->fetch(), + ); + + rmdir($modsAvailablePath); + } + + #[RequiresOperatingSystemFamily('Linux')] + public function testSetupReturnsFalseAndRemovesPieCreatedIniFileWhenCheckAndAddExtensionFails(): void + { + $modsAvailablePath = tempnam(sys_get_temp_dir(), 'pie_test_mods_available_path'); + unlink($modsAvailablePath); + mkdir($modsAvailablePath, recursive: true); + + $expectedIniFile = $modsAvailablePath . DIRECTORY_SEPARATOR . 'foobar.ini'; + + $this->mockPhpBinary + ->method('additionalIniDirectory') + ->willReturn('/value/does/not/matter'); + + $this->checkAndAddExtensionToIniIfNeeded + ->expects(self::once()) + ->method('__invoke') + ->with( + $expectedIniFile, + $this->targetPlatform, + $this->downloadedPackage, + $this->output, + self::isType(IsType::TYPE_CALLABLE), + ) + ->willReturn(false); + + self::assertFalse( + (new OndrejPhpenmod( + $this->checkAndAddExtensionToIniIfNeeded, + self::GOOD_PHPENMOD, + $modsAvailablePath, + ))->setup( + $this->targetPlatform, + $this->downloadedPackage, + $this->binaryFile, + $this->output, + ), + ); + + self::assertFileDoesNotExist($expectedIniFile); + + rmdir($modsAvailablePath); + } + + #[RequiresOperatingSystemFamily('Linux')] + public function testSetupReturnsTrueWhenExtensionIsEnabled(): void + { + $modsAvailablePath = tempnam(sys_get_temp_dir(), 'pie_test_mods_available_path'); + unlink($modsAvailablePath); + mkdir($modsAvailablePath, recursive: true); + + $expectedIniFile = $modsAvailablePath . DIRECTORY_SEPARATOR . 'foobar.ini'; + + $this->mockPhpBinary + ->method('additionalIniDirectory') + ->willReturn('/value/does/not/matter'); + + $this->checkAndAddExtensionToIniIfNeeded + ->expects(self::once()) + ->method('__invoke') + ->with( + $expectedIniFile, + $this->targetPlatform, + $this->downloadedPackage, + $this->output, + self::isType(IsType::TYPE_CALLABLE), + ) + ->willReturnCallback( + /** @param callable():bool $additionalEnableStep */ + static function ( + string $iniFile, + TargetPlatform $targetPlatform, + DownloadedPackage $downloadedPackage, + OutputInterface $output, + callable $additionalEnableStep, + ): bool { + return $additionalEnableStep(); + }, + ); + + self::assertTrue( + (new OndrejPhpenmod( + $this->checkAndAddExtensionToIniIfNeeded, + self::GOOD_PHPENMOD, + $modsAvailablePath, + ))->setup( + $this->targetPlatform, + $this->downloadedPackage, + $this->binaryFile, + $this->output, + ), + ); + + self::assertFileExists($expectedIniFile); + + unlink($expectedIniFile); + rmdir($modsAvailablePath); + } +}