From 43b9ebf61ced6a9845ff96e8b3bf3ec966719d00 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 1 Oct 2024 11:48:44 +0200 Subject: [PATCH] Add DotenvConfigurator, for .env.etc files --- src/Configurator.php | 1 + src/Configurator/DotenvConfigurator.php | 50 ++++ src/Configurator/EnvConfigurator.php | 28 +- tests/Configurator/DotenvConfiguratorTest.php | 279 ++++++++++++++++++ 4 files changed, 353 insertions(+), 5 deletions(-) create mode 100644 src/Configurator/DotenvConfigurator.php create mode 100644 tests/Configurator/DotenvConfiguratorTest.php diff --git a/src/Configurator.php b/src/Configurator.php index 444abb86a..dbffb109a 100644 --- a/src/Configurator.php +++ b/src/Configurator.php @@ -39,6 +39,7 @@ public function __construct(Composer $composer, IOInterface $io, Options $option 'copy-from-recipe' => Configurator\CopyFromRecipeConfigurator::class, 'copy-from-package' => Configurator\CopyFromPackageConfigurator::class, 'env' => Configurator\EnvConfigurator::class, + 'dotenv' => Configurator\DotenvConfigurator::class, 'container' => Configurator\ContainerConfigurator::class, 'makefile' => Configurator\MakefileConfigurator::class, 'composer-scripts' => Configurator\ComposerScriptsConfigurator::class, diff --git a/src/Configurator/DotenvConfigurator.php b/src/Configurator/DotenvConfigurator.php new file mode 100644 index 000000000..1ee1271e9 --- /dev/null +++ b/src/Configurator/DotenvConfigurator.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Flex\Configurator; + +use Symfony\Flex\Lock; +use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; + +class DotenvConfigurator extends AbstractConfigurator +{ + public function configure(Recipe $recipe, $vars, Lock $lock, array $options = []) + { + foreach ($vars as $suffix => $vars) { + $configurator = new EnvConfigurator($this->composer, $this->io, $this->options, $suffix); + $configurator->configure($recipe, $vars, $lock, $options); + } + } + + public function unconfigure(Recipe $recipe, $vars, Lock $lock) + { + foreach ($vars as $suffix => $vars) { + $configurator = new EnvConfigurator($this->composer, $this->io, $this->options, $suffix); + $configurator->unconfigure($recipe, $vars, $lock); + } + } + + public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void + { + foreach ($originalConfig as $suffix => $vars) { + $configurator = new EnvConfigurator($this->composer, $this->io, $this->options, $suffix); + $configurator->update($recipeUpdate, $vars, $newConfig[$suffix] ?? []); + } + + foreach ($newConfig as $suffix => $vars) { + if (!isset($originalConfig[$suffix])) { + $configurator = new EnvConfigurator($this->composer, $this->io, $this->options, $suffix); + $configurator->update($recipeUpdate, [], $vars); + } + } + } +} diff --git a/src/Configurator/EnvConfigurator.php b/src/Configurator/EnvConfigurator.php index 4c252fd2f..baddec951 100644 --- a/src/Configurator/EnvConfigurator.php +++ b/src/Configurator/EnvConfigurator.php @@ -11,7 +11,10 @@ namespace Symfony\Flex\Configurator; +use Composer\Composer; +use Composer\IO\IOInterface; use Symfony\Flex\Lock; +use Symfony\Flex\Options; use Symfony\Flex\Recipe; use Symfony\Flex\Update\RecipeUpdate; @@ -20,11 +23,24 @@ */ class EnvConfigurator extends AbstractConfigurator { + private string $suffix; + + public function __construct(Composer $composer, IOInterface $io, Options $options, string $suffix = '') + { + parent::__construct($composer, $io, $options); + $this->suffix = $suffix; + } + public function configure(Recipe $recipe, $vars, Lock $lock, array $options = []) { - $this->write('Adding environment variable defaults'); + $this->write('Adding environment variable defaults'.('' === $this->suffix ? '' : ' ('.$this->suffix.')')); $this->configureEnvDist($recipe, $vars, $options['force'] ?? false); + + if ('' !== $this->suffix) { + return; + } + if (!file_exists($this->options->get('root-dir').'/'.($this->options->get('runtime')['dotenv_path'] ?? '.env').'.test')) { $this->configurePhpUnit($recipe, $vars, $options['force'] ?? false); } @@ -50,8 +66,9 @@ public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array private function configureEnvDist(Recipe $recipe, $vars, bool $update) { $dotenvPath = $this->options->get('runtime')['dotenv_path'] ?? '.env'; + $files = '' === $this->suffix ? [$dotenvPath.'.dist', $dotenvPath] : [$dotenvPath.'.'.$this->suffix]; - foreach ([$dotenvPath.'.dist', $dotenvPath] as $file) { + foreach ($files as $file) { $env = $this->options->get('root-dir').'/'.$file; if (!is_file($env)) { continue; @@ -136,8 +153,9 @@ private function configurePhpUnit(Recipe $recipe, $vars, bool $update) private function unconfigureEnvFiles(Recipe $recipe, $vars) { $dotenvPath = $this->options->get('runtime')['dotenv_path'] ?? '.env'; + $files = '' === $this->suffix ? [$dotenvPath, $dotenvPath.'.dist'] : [$dotenvPath.'.'.$this->suffix]; - foreach ([$dotenvPath, $dotenvPath.'.dist'] as $file) { + foreach ($files as $file) { $env = $this->options->get('root-dir').'/'.$file; if (!file_exists($env)) { continue; @@ -205,7 +223,7 @@ private function generateRandomBytes($length = 16) private function getContentsAfterApplyingRecipe(string $rootDir, Recipe $recipe, array $vars): array { $dotenvPath = $this->options->get('runtime')['dotenv_path'] ?? '.env'; - $files = [$dotenvPath, $dotenvPath.'.dist', 'phpunit.xml.dist', 'phpunit.xml']; + $files = '' === $this->suffix ? [$dotenvPath, $dotenvPath.'.dist', 'phpunit.xml.dist', 'phpunit.xml'] : [$dotenvPath.'.'.$this->suffix]; if (0 === \count($vars)) { return array_fill_keys($files, null); @@ -222,7 +240,7 @@ private function getContentsAfterApplyingRecipe(string $rootDir, Recipe $recipe, true ); - if (!file_exists($rootDir.'/'.$dotenvPath.'.test')) { + if ('' === $this->suffix && !file_exists($rootDir.'/'.$dotenvPath.'.test')) { $this->configurePhpUnit( $recipe, $vars, diff --git a/tests/Configurator/DotenvConfiguratorTest.php b/tests/Configurator/DotenvConfiguratorTest.php new file mode 100644 index 000000000..744ff5beb --- /dev/null +++ b/tests/Configurator/DotenvConfiguratorTest.php @@ -0,0 +1,279 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Flex\Tests\Configurator; + +use Composer\Composer; +use Composer\IO\IOInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Flex\Configurator\DotenvConfigurator; +use Symfony\Flex\Lock; +use Symfony\Flex\Options; +use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; + +class DotenvConfiguratorTest extends TestCase +{ + public function testConfigure() + { + @mkdir(FLEX_TEST_DIR); + $configurator = new DotenvConfigurator( + $this->getMockBuilder(Composer::class)->getMock(), + $this->getMockBuilder(IOInterface::class)->getMock(), + new Options(['root-dir' => FLEX_TEST_DIR]) + ); + $lock = $this->getMockBuilder(Lock::class)->disableOriginalConstructor()->getMock(); + + $recipe = $this->getMockBuilder(Recipe::class)->disableOriginalConstructor()->getMock(); + $recipe->expects($this->any())->method('getName')->willReturn('FooBundle'); + + $env = FLEX_TEST_DIR.'/.env.local'; + @unlink($env); + touch($env); + + $configurator->configure($recipe, [ + 'local' => [ + 'APP_ENV' => 'test bar', + 'APP_DEBUG' => '0', + 'APP_PARAGRAPH' => "foo\n\"bar\"\\t", + 'DATABASE_URL' => 'mysql://root@127.0.0.1:3306/symfony?charset=utf8mb4&serverVersion=5.7', + 'MAILER_URL' => 'null://localhost', + 'MAILER_USER' => 'fabien', + '#1' => 'Comment 1', + '#2' => 'Comment 3', + '#TRUSTED_SECRET' => 's3cretf0rt3st"<>', + 'APP_SECRET' => 's3cretf0rt3st"<>', + ], + ], $lock); + + $envContents = << FooBundle ### +APP_ENV="test bar" +APP_DEBUG=0 +APP_PARAGRAPH="foo\\n\\"bar\\"\\\\t" +DATABASE_URL="mysql://root@127.0.0.1:3306/symfony?charset=utf8mb4&serverVersion=5.7" +MAILER_URL=null://localhost +MAILER_USER=fabien +# Comment 1 +# Comment 3 +#TRUSTED_SECRET="s3cretf0rt3st\"<>" +APP_SECRET="s3cretf0rt3st\"<>" +###< FooBundle ### + +EOF; + + $this->assertStringEqualsFile($env, $envContents); + + $configurator->configure($recipe, [ + 'local' => [ + 'APP_ENV' => 'test', + 'APP_DEBUG' => '0', + '#1' => 'Comment 1', + '#2' => 'Comment 3', + '#TRUSTED_SECRET' => 's3cretf0rt3st', + 'APP_SECRET' => 's3cretf0rt3st', + ], + ], $lock); + + $this->assertStringEqualsFile($env, $envContents); + + $configurator->unconfigure($recipe, [ + 'local' => [ + 'APP_ENV' => 'test', + 'APP_DEBUG' => '0', + '#1' => 'Comment 1', + '#2' => 'Comment 3', + '#TRUSTED_SECRET' => 's3cretf0rt3st', + 'APP_SECRET' => 's3cretf0rt3st', + ], + ], $lock); + + $this->assertStringEqualsFile( + $env, + <<getMockBuilder(Composer::class)->getMock(), + $this->getMockBuilder(IOInterface::class)->getMock(), + new Options(['root-dir' => FLEX_TEST_DIR]) + ); + $lock = $this->getMockBuilder(Lock::class)->disableOriginalConstructor()->getMock(); + + $recipe = $this->getMockBuilder(Recipe::class)->disableOriginalConstructor()->getMock(); + $recipe->expects($this->any())->method('getName')->willReturn('FooBundle'); + + $env = FLEX_TEST_DIR.'/.env.local'; + @unlink($env); + touch($env); + + $configurator->configure($recipe, [ + 'local' => [ + '#TRUSTED_SECRET_1' => '%generate(secret,32)%', + '#TRUSTED_SECRET_2' => '%generate(secret, 32)%', + '#TRUSTED_SECRET_3' => '%generate(secret, 32)%', + 'APP_SECRET' => '%generate(secret)%', + ], + ], $lock); + + $envContents = file_get_contents($env); + $this->assertMatchesRegularExpression('/#TRUSTED_SECRET_1=[a-z0-9]{64}/', $envContents); + $this->assertMatchesRegularExpression('/#TRUSTED_SECRET_2=[a-z0-9]{64}/', $envContents); + $this->assertMatchesRegularExpression('/#TRUSTED_SECRET_3=[a-z0-9]{64}/', $envContents); + $this->assertMatchesRegularExpression('/APP_SECRET=[a-z0-9]{32}/', $envContents); + @unlink($env); + } + + public function testConfigureForce() + { + @mkdir(FLEX_TEST_DIR); + $configurator = new DotenvConfigurator( + $this->getMockBuilder(Composer::class)->getMock(), + $this->getMockBuilder(IOInterface::class)->getMock(), + new Options(['root-dir' => FLEX_TEST_DIR]) + ); + + $recipe = $this->getMockBuilder(Recipe::class)->disableOriginalConstructor()->getMock(); + $recipe->expects($this->any())->method('getName')->willReturn('FooBundle'); + + $env = FLEX_TEST_DIR.'/.env.local'; + @unlink($env); + touch($env); + file_put_contents($env, "# preexisting content\n"); + + $envContentsConfigure = << FooBundle ### +FOO=bar +###< FooBundle ### + +# new content + +EOT; + $envContentsForce = << FooBundle ### +FOO=bar +OOF=rab +###< FooBundle ### + +# new content + +EOT; + + $lock = $this->getMockBuilder(Lock::class)->disableOriginalConstructor()->getMock(); + + $configurator->configure($recipe, [ + 'local' => [ + 'FOO' => 'bar', + ], + ], $lock); + + file_put_contents($env, "\n# new content\n", \FILE_APPEND); + + $this->assertStringEqualsFile($env, $envContentsConfigure); + + $configurator->configure($recipe, [ + 'local' => [ + 'FOO' => 'bar', + 'OOF' => 'rab', + ], + ], $lock, [ + 'force' => true, + ]); + + $this->assertStringEqualsFile($env, $envContentsForce); + + @unlink($env); + } + + public function testUpdate() + { + $configurator = new DotenvConfigurator( + $this->createMock(Composer::class), + $this->createMock(IOInterface::class), + new Options(['root-dir' => FLEX_TEST_DIR]) + ); + + $recipe = $this->createMock(Recipe::class); + $recipe->method('getName') + ->willReturn('symfony/foo-bundle'); + $recipeUpdate = new RecipeUpdate( + $recipe, + $recipe, + $this->createMock(Lock::class), + FLEX_TEST_DIR + ); + + @mkdir(FLEX_TEST_DIR); + file_put_contents( + FLEX_TEST_DIR.'/.env.local', + << symfony/foo-bundle ### +APP_ENV="test bar" +APP_SECRET=EXISTING_SECRET_VALUE +APP_DEBUG=0 +###< symfony/foo-bundle ### +###> symfony/baz-bundle ### +OTHER_VAR=1 +###< symfony/baz-bundle ### +EOF + ); + + $configurator->update( + $recipeUpdate, + // %generate(secret)% should not regenerate a new value + ['local' => ['APP_ENV' => 'original', 'APP_SECRET' => '%generate(secret)%', 'APP_DEBUG' => 0, 'EXTRA_VAR' => 'apple']], + ['local' => ['APP_ENV' => 'updated', 'APP_SECRET' => '%generate(secret)%', 'APP_DEBUG' => 0, 'NEW_VAR' => 'orange']] + ); + + $this->assertSame(['.env.local' => << symfony/foo-bundle ### +APP_ENV=original +APP_SECRET=EXISTING_SECRET_VALUE +APP_DEBUG=0 +EXTRA_VAR=apple +###< symfony/foo-bundle ### +###> symfony/baz-bundle ### +OTHER_VAR=1 +###< symfony/baz-bundle ### +EOF + ], $recipeUpdate->getOriginalFiles()); + + $this->assertSame(['.env.local' => << symfony/foo-bundle ### +APP_ENV=updated +APP_SECRET=EXISTING_SECRET_VALUE +APP_DEBUG=0 +NEW_VAR=orange +###< symfony/foo-bundle ### +###> symfony/baz-bundle ### +OTHER_VAR=1 +###< symfony/baz-bundle ### +EOF + ], $recipeUpdate->getNewFiles()); + } +}