diff --git a/src/Configurator.php b/src/Configurator.php index ba69cb485..80a83b1ab 100644 --- a/src/Configurator.php +++ b/src/Configurator.php @@ -41,6 +41,8 @@ public function __construct(Composer $composer, IOInterface $io, Options $option 'makefile' => Configurator\MakefileConfigurator::class, 'composer-scripts' => Configurator\ComposerScriptsConfigurator::class, 'gitignore' => Configurator\GitignoreConfigurator::class, + 'dockerfile' => Configurator\DockerfileConfigurator::class, + 'docker-compose' => Configurator\DockerComposeConfigurator::class, ]; } diff --git a/src/Configurator/DockerComposeConfigurator.php b/src/Configurator/DockerComposeConfigurator.php new file mode 100644 index 000000000..47ce13359 --- /dev/null +++ b/src/Configurator/DockerComposeConfigurator.php @@ -0,0 +1,128 @@ + + * + * 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; + +/** + * Adds services and volumes to docker-compose.yml file. + * + * @author Kévin Dunglas + */ +class DockerComposeConfigurator extends AbstractConfigurator +{ + public function configure(Recipe $recipe, $config, Lock $lock, array $options = []) + { + $installDocker = $this->composer->getPackage()->getExtra()['symfony']['docker'] ?? false; + if (!$installDocker) { + return; + } + + $rootDir = $this->options->get('root-dir'); + if ( + ( + !file_exists($dockerComposeFile = $rootDir.'/docker-compose.yml') && + !file_exists($dockerComposeFile = $rootDir.'/docker-compose.yaml') + ) || $this->isFileMarked($recipe, $dockerComposeFile) + ) { + return; + } + + $this->write('Adding Docker Compose entries'); + + $offset = 8; + $node = null; + $endAt = []; + $lines = []; + foreach (file($dockerComposeFile) as $i => $line) { + $lines[] = $line; + $ltrimedLine = ltrim($line, ' '); + + // Skip blank lines and comments + if (('' !== $ltrimedLine && 0 === strpos($ltrimedLine, '#')) || '' === trim($line)) { + continue; + } + + // Extract Docker Compose keys (usually "services" and "volumes") + if (!preg_match('/^[\'"]?([a-zA-Z0-9]+)[\'"]?:\s*$/', $line, $matches)) { + // Detect indentation to use + $offestLine = \strlen($line) - \strlen($ltrimedLine); + if ($offset > $offestLine && 0 !== $offestLine) { + $offset = $offestLine; + } + continue; + } + + // Keep end in memory (check break line on previous line) + $endAt[$node] = '' !== trim($lines[$i - 1]) ? $i : $i - 1; + $node = $matches[1]; + } + $endAt[$node] = \count($lines) + 1; + + foreach ($config as $key => $value) { + if (isset($endAt[$key])) { + array_splice($lines, $endAt[$key], 0, $this->markData($recipe, $this->parse(1, $offset, $value))); + continue; + } + + $lines[] = sprintf("\n%s:", $key); + $lines[] = $this->markData($recipe, $this->parse(1, $offset, $value)); + } + + file_put_contents($dockerComposeFile, implode('', $lines)); + } + + public function unconfigure(Recipe $recipe, $config, Lock $lock) + { + $rootDir = $this->options->get('root-dir'); + if (!file_exists($dockerCompose = $rootDir.'/docker-compose.yml') && + !file_exists($dockerCompose = $rootDir.'/docker-compose.yaml') + ) { + return; + } + + $name = $recipe->getName(); + // Remove recipe and add break line + $contents = preg_replace(sprintf('{%s+###> %s ###.*?###< %s ###%s+}s', "\n", $name, $name, "\n"), PHP_EOL.PHP_EOL, file_get_contents($dockerCompose), -1, $count); + if (!$count) { + return; + } + + foreach ($config as $key => $value) { + if (0 === preg_match(sprintf('{^%s:[ \t\r\n]*([ \t]+\w)}m', $key), $contents, $matches)) { + $contents = preg_replace(sprintf('{\n?^%s:[ \t\r\n]*}sm', $key), '', $contents, -1, $count); + } + } + + $this->write(sprintf('Removing Docker Compose entries from %s', $dockerCompose)); + file_put_contents($dockerCompose, ltrim($contents, "\n")); + } + + private function parse($level, $indent, $services): string + { + $line = ''; + foreach ($services as $key => $value) { + $line .= str_repeat(' ', $indent * $level); + if (!\is_array($value)) { + if (\is_string($key)) { + $line .= sprintf('%s:', $key); + } + $line .= sprintf("%s\n", $value); + continue; + } + $line .= sprintf("%s:\n", $key).$this->parse($level + 1, $indent, $value); + } + + return $line; + } +} diff --git a/src/Configurator/DockerfileConfigurator.php b/src/Configurator/DockerfileConfigurator.php new file mode 100644 index 000000000..5f8643d0e --- /dev/null +++ b/src/Configurator/DockerfileConfigurator.php @@ -0,0 +1,66 @@ + + * + * 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; + +/** + * Adds commands to a Dockerfile. + * + * @author Kévin Dunglas + */ +class DockerfileConfigurator extends AbstractConfigurator +{ + public function configure(Recipe $recipe, $config, Lock $lock, array $options = []) + { + $installDocker = $this->composer->getPackage()->getExtra()['symfony']['docker'] ?? false; + if (!$installDocker) { + return; + } + + $dockerfile = $this->options->get('root-dir').'/Dockerfile'; + if (!file_exists($dockerfile) || $this->isFileMarked($recipe, $dockerfile)) { + return; + } + + $this->write('Adding Dockerfile entries'); + + $lines = []; + foreach (file($dockerfile) as $line) { + $lines[] = $line; + if (!preg_match('/^###> recipes ###$/', $line)) { + continue; + } + + $lines[] = ltrim($this->markData($recipe, implode("\n", $config)), "\n"); + } + + file_put_contents($dockerfile, implode('', $lines)); + } + + public function unconfigure(Recipe $recipe, $config, Lock $lock) + { + if (!file_exists($dockerfile = $this->options->get('root-dir').'/Dockerfile')) { + return; + } + + $name = $recipe->getName(); + $contents = preg_replace(sprintf('{%s+###> %s ###.*?###< %s ###%s+}s', "\n", $name, $name, "\n"), "\n", file_get_contents($dockerfile), -1, $count); + if (!$count) { + return; + } + + $this->write('Removing Dockerfile entries'); + file_put_contents($dockerfile, ltrim($contents, "\n")); + } +} diff --git a/tests/Configurator/DockerComposeConfiguratorTest.php b/tests/Configurator/DockerComposeConfiguratorTest.php new file mode 100644 index 000000000..e6aa950ad --- /dev/null +++ b/tests/Configurator/DockerComposeConfiguratorTest.php @@ -0,0 +1,325 @@ + + * + * 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 Composer\Package\Package; +use PHPUnit\Framework\TestCase; +use Symfony\Flex\Configurator\DockerComposeConfigurator; +use Symfony\Flex\Lock; +use Symfony\Flex\Options; +use Symfony\Flex\Recipe; + +/** + * @author Kévin Dunglas + */ +class DockerComposeConfiguratorTest extends TestCase +{ + const ORIGINAL_CONTENT = <<<'YAML' +version: '3.4' + +services: + app: + build: + context: . + target: symfony_docker_php + args: + SYMFONY_VERSION: ${SYMFONY_VERSION:-} + STABILITY: ${STABILITY:-stable} + volumes: + # Comment out the next line in production + - ./:/srv/app:rw,cached + # If you develop on Linux, comment out the following volumes to just use bind-mounted project directory from host + - /srv/app/var/ + - /srv/app/var/cache/ + - /srv/app/var/logs/ + - /srv/app/var/sessions/ + environment: + - SYMFONY_VERSION + + nginx: + build: + context: . + target: symfony_docker_nginx + depends_on: + - app + volumes: + # Comment out the next line in production + - ./docker/nginx/conf.d:/etc/nginx/conf.d:ro + - ./public:/srv/app/public:ro + ports: + - '80:80' + + # This HTTP/2 proxy is not secure: it should only be used in dev + h2-proxy: + build: + context: . + target: symfony_docker_h2-proxy + depends_on: + - nginx + volumes: + - ./docker/h2-proxy/default.conf:/etc/nginx/conf.d/default.conf:ro + ports: + - '443:443' + +YAML; + + const CONFIG_DB = [ + 'services' => [ + 'db:', + ' image: mariadb:10.3', + ' environment:', + ' - MYSQL_DATABASE=symfony', + ' # You should definitely change the password in production', + ' - MYSQL_PASSWORD=password', + ' - MYSQL_RANDOM_ROOT_PASSWORD=true', + ' - MYSQL_USER=symfony', + ' volumes:', + ' - db-data:/var/lib/mysql:rw', + ' # You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data!', + ' # - ./docker/db/data:/var/lib/mysql:rw', + ], + 'volumes' => ['db-data: {}'], + ]; + + /** @var Recipe|\PHPUnit\Framework\MockObject\MockObject */ + private $recipeDb; + + /** @var Lock|\PHPUnit\Framework\MockObject\MockObject */ + private $lock; + + /** @var DockerComposeConfigurator */ + private $configurator; + + public function setUp(string $name = null, array $data = [], string $dataName = '') + { + @mkdir(FLEX_TEST_DIR); + + // Recipe + $this->recipeDb = $this->getMockBuilder(Recipe::class)->disableOriginalConstructor()->getMock(); + $this->recipeDb->method('getName')->willReturn('doctrine/doctrine-bundle'); + + // Lock + $this->lock = $this->getMockBuilder(Lock::class)->disableOriginalConstructor()->getMock(); + + // Configurator + $package = new Package('dummy/dummy', '1.0.0', '1.0.0'); + $package->setExtra(['symfony' => ['docker' => true]]); + + $composer = $this->getMockBuilder(Composer::class)->getMock(); + $composer->method('getPackage')->willReturn($package); + + $this->configurator = new DockerComposeConfigurator( + $composer, + $this->getMockBuilder(IOInterface::class)->getMock(), + new Options(['config-dir' => 'config', 'root-dir' => FLEX_TEST_DIR]) + ); + } + + protected function tearDown() + { + @unlink(FLEX_TEST_DIR.'/docker-compose.yml'); + @unlink(FLEX_TEST_DIR.'/docker-compose.yaml'); + } + + public function testConfigure() + { + $dockerComposeFile = FLEX_TEST_DIR.'/docker-compose.yaml'; + file_put_contents($dockerComposeFile, self::ORIGINAL_CONTENT); + + $this->configurator->configure($this->recipeDb, self::CONFIG_DB, $this->lock); + + $this->assertEquals(self::ORIGINAL_CONTENT.<<<'YAML' + +###> doctrine/doctrine-bundle ### + db: + image: mariadb:10.3 + environment: + - MYSQL_DATABASE=symfony + # You should definitely change the password in production + - MYSQL_PASSWORD=password + - MYSQL_RANDOM_ROOT_PASSWORD=true + - MYSQL_USER=symfony + volumes: + - db-data:/var/lib/mysql:rw + # You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data! + # - ./docker/db/data:/var/lib/mysql:rw +###< doctrine/doctrine-bundle ### + +volumes: +###> doctrine/doctrine-bundle ### + db-data: {} +###< doctrine/doctrine-bundle ### + +YAML + , file_get_contents($dockerComposeFile)); + + $this->configurator->unconfigure($this->recipeDb, self::CONFIG_DB, $this->lock); + $this->assertEquals(self::ORIGINAL_CONTENT, file_get_contents($dockerComposeFile)); + } + + public function testConfigureFileWithExistingVolumes() + { + $originalContent = self::ORIGINAL_CONTENT.<<<'YAML' + +volumes: + my-data: {} + +YAML; + + $dockerComposeFile = FLEX_TEST_DIR.'/docker-compose.yaml'; + file_put_contents($dockerComposeFile, $originalContent); + + $this->configurator->configure($this->recipeDb, self::CONFIG_DB, $this->lock); + + $this->assertEquals(self::ORIGINAL_CONTENT.<<<'YAML' + +###> doctrine/doctrine-bundle ### + db: + image: mariadb:10.3 + environment: + - MYSQL_DATABASE=symfony + # You should definitely change the password in production + - MYSQL_PASSWORD=password + - MYSQL_RANDOM_ROOT_PASSWORD=true + - MYSQL_USER=symfony + volumes: + - db-data:/var/lib/mysql:rw + # You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data! + # - ./docker/db/data:/var/lib/mysql:rw +###< doctrine/doctrine-bundle ### + +volumes: + my-data: {} + +###> doctrine/doctrine-bundle ### + db-data: {} +###< doctrine/doctrine-bundle ### + +YAML + , file_get_contents($dockerComposeFile)); + + $this->configurator->unconfigure($this->recipeDb, self::CONFIG_DB, $this->lock); + // Not the same original, we have an extra breaks line + $this->assertEquals($originalContent.<<<'YAML' + + +YAML + , file_get_contents($dockerComposeFile)); + } + + public function testConfigureFileWithExistingMarks() + { + $originalContent = self::ORIGINAL_CONTENT.<<<'YAML' + +###> doctrine/doctrine-bundle ### + db: + image: postgres:11-alpine + environment: + - POSTGRES_DB=symfony + - POSTGRES_USER=symfony + # You should definitely change the password in production + - POSTGRES_PASSWORD=!ChangeMe! + volumes: + - db-data:/var/lib/postgresql/data:rw + # You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data! + # - ./docker/db/data:/var/lib/postgresql/data:rw + ports: + - "5432:5432" +###< doctrine/doctrine-bundle ### + +volumes: +###> doctrine/doctrine-bundle ### + db-data: {} +###< doctrine/doctrine-bundle ### + +YAML; + + $recipe = $this->getMockBuilder(Recipe::class)->disableOriginalConstructor()->getMock(); + $recipe->method('getName')->willReturn('symfony/mercure-bundle'); + + $dockerComposeFile = FLEX_TEST_DIR.'/docker-compose.yml'; + file_put_contents($dockerComposeFile, $originalContent); + + /** @var Recipe|\PHPUnit\Framework\MockObject\MockObject $recipe */ + $recipe = $this->getMockBuilder(Recipe::class)->disableOriginalConstructor()->getMock(); + $recipe->method('getName')->willReturn('symfony/mercure-bundle'); + + $config = [ + 'services' => [ + 'mercure:', + ' # In production, you may want to use the managed version of Mercure, https://mercure.rocks', + ' image: dunglas/mercure', + ' environment:', + ' # You should definitely change all these values in production', + ' - JWT_KEY=!ChangeMe!', + ' - ALLOW_ANONYMOUS=1', + ' - CORS_ALLOWED_ORIGINS=*', + ' - PUBLISH_ALLOWED_ORIGINS=http://localhost:1337', + ' - DEMO=1', + ' ports:', + ' - "1337:80"', + ], + ]; + + $this->configurator->configure($recipe, $config, $this->lock); + + $this->assertEquals(self::ORIGINAL_CONTENT.<<<'YAML' + +###> doctrine/doctrine-bundle ### + db: + image: postgres:11-alpine + environment: + - POSTGRES_DB=symfony + - POSTGRES_USER=symfony + # You should definitely change the password in production + - POSTGRES_PASSWORD=!ChangeMe! + volumes: + - db-data:/var/lib/postgresql/data:rw + # You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data! + # - ./docker/db/data:/var/lib/postgresql/data:rw + ports: + - "5432:5432" +###< doctrine/doctrine-bundle ### + +###> symfony/mercure-bundle ### + mercure: + # In production, you may want to use the managed version of Mercure, https://mercure.rocks + image: dunglas/mercure + environment: + # You should definitely change all these values in production + - JWT_KEY=!ChangeMe! + - ALLOW_ANONYMOUS=1 + - CORS_ALLOWED_ORIGINS=* + - PUBLISH_ALLOWED_ORIGINS=http://localhost:1337 + - DEMO=1 + ports: + - "1337:80" +###< symfony/mercure-bundle ### + +volumes: +###> doctrine/doctrine-bundle ### + db-data: {} +###< doctrine/doctrine-bundle ### + +YAML + , file_get_contents($dockerComposeFile)); + + $this->configurator->unconfigure($recipe, $config, $this->lock); + $this->assertEquals($originalContent, file_get_contents($dockerComposeFile)); + + // Unconfigure doctrine + $this->configurator->unconfigure($this->recipeDb, self::CONFIG_DB, $this->lock); + $this->assertEquals(self::ORIGINAL_CONTENT, file_get_contents($dockerComposeFile)); + } +} diff --git a/tests/Configurator/DockerfileConfiguratorTest.php b/tests/Configurator/DockerfileConfiguratorTest.php new file mode 100644 index 000000000..6aae03fca --- /dev/null +++ b/tests/Configurator/DockerfileConfiguratorTest.php @@ -0,0 +1,187 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Flex\Configurator; + +use Composer\Composer; +use Composer\IO\IOInterface; +use Composer\Package\Package; +use PHPUnit\Framework\TestCase; +use Symfony\Flex\Lock; +use Symfony\Flex\Options; +use Symfony\Flex\Recipe; + +class DockerfileConfiguratorTest extends TestCase +{ + public function testConfigure() + { + $originalContent = <<<'EOF' +FROM php:7.1-fpm-alpine + +RUN apk add --no-cache --virtual .persistent-deps \ + git \ + icu-libs \ + make \ + zlib + +ENV APCU_VERSION 5.1.8 + +RUN set -xe \ + && apk add --no-cache --virtual .build-deps \ + $PHPIZE_DEPS \ + icu-dev \ + zlib-dev \ + && docker-php-ext-install \ + intl \ + zip \ + && pecl install \ + apcu-${APCU_VERSION} \ + && docker-php-ext-enable --ini-name 20-apcu.ini apcu \ + && docker-php-ext-enable --ini-name 05-opcache.ini opcache \ + && apk del .build-deps + +###> recipes ### +###< recipes ## + +COPY docker/app/php.ini /usr/local/etc/php/php.ini + +COPY docker/app/install-composer.sh /usr/local/bin/docker-app-install-composer +RUN chmod +x /usr/local/bin/docker-app-install-composer + +RUN set -xe \ + && docker-app-install-composer \ + && mv composer.phar /usr/local/bin/composer + +# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser +ENV COMPOSER_ALLOW_SUPERUSER 1 + +RUN composer global require "hirak/prestissimo:^0.3" --prefer-dist --no-progress --no-suggest --optimize-autoloader --classmap-authoritative \ + && composer clear-cache + +WORKDIR /srv/app + +COPY . . +# Cleanup unneeded files +RUN rm -Rf docker/ + +# Download the Symfony skeleton +ENV SKELETON_COMPOSER_JSON https://raw.githubusercontent.com/symfony/skeleton/v3.3.2/composer.json +RUN [ -f composer.json ] || php -r "copy('$SKELETON_COMPOSER_JSON', 'composer.json');" + +RUN mkdir -p var/cache var/logs var/sessions \ + && composer install --prefer-dist --no-dev --no-progress --no-suggest --optimize-autoloader --classmap-authoritative --no-interaction \ + && composer clear-cache \ +# Permissions hack because setfacl does not work on Mac and Windows + && chown -R www-data var + +COPY docker/app/docker-entrypoint.sh /usr/local/bin/docker-app-entrypoint +RUN chmod +x /usr/local/bin/docker-app-entrypoint + +ENTRYPOINT ["docker-app-entrypoint"] +CMD ["php-fpm"] + +EOF; + + $lock = $this->getMockBuilder(Lock::class)->disableOriginalConstructor()->getMock(); + $recipe = $this->getMockBuilder(Recipe::class)->disableOriginalConstructor()->getMock(); + $recipe->method('getName')->willReturn('doctrine/doctrine-bundle'); + + @mkdir(FLEX_TEST_DIR); + $config = FLEX_TEST_DIR.'/Dockerfile'; + file_put_contents($config, $originalContent); + + $package = new Package('dummy/dummy', '1.0.0', '1.0.0'); + $package->setExtra(['symfony' => ['docker' => true]]); + + $composer = $this->getMockBuilder(Composer::class)->getMock(); + $composer->method('getPackage')->willReturn($package); + + $configurator = new DockerfileConfigurator( + $composer, + $this->getMockBuilder(IOInterface::class)->getMock(), + new Options(['config-dir' => 'config', 'root-dir' => FLEX_TEST_DIR]) + ); + $configurator->configure($recipe, ['RUN docker-php-ext-install pdo_mysql'], $lock); + $this->assertEquals(<<<'EOF' +FROM php:7.1-fpm-alpine + +RUN apk add --no-cache --virtual .persistent-deps \ + git \ + icu-libs \ + make \ + zlib + +ENV APCU_VERSION 5.1.8 + +RUN set -xe \ + && apk add --no-cache --virtual .build-deps \ + $PHPIZE_DEPS \ + icu-dev \ + zlib-dev \ + && docker-php-ext-install \ + intl \ + zip \ + && pecl install \ + apcu-${APCU_VERSION} \ + && docker-php-ext-enable --ini-name 20-apcu.ini apcu \ + && docker-php-ext-enable --ini-name 05-opcache.ini opcache \ + && apk del .build-deps + +###> recipes ### +###> doctrine/doctrine-bundle ### +RUN docker-php-ext-install pdo_mysql +###< doctrine/doctrine-bundle ### +###< recipes ## + +COPY docker/app/php.ini /usr/local/etc/php/php.ini + +COPY docker/app/install-composer.sh /usr/local/bin/docker-app-install-composer +RUN chmod +x /usr/local/bin/docker-app-install-composer + +RUN set -xe \ + && docker-app-install-composer \ + && mv composer.phar /usr/local/bin/composer + +# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser +ENV COMPOSER_ALLOW_SUPERUSER 1 + +RUN composer global require "hirak/prestissimo:^0.3" --prefer-dist --no-progress --no-suggest --optimize-autoloader --classmap-authoritative \ + && composer clear-cache + +WORKDIR /srv/app + +COPY . . +# Cleanup unneeded files +RUN rm -Rf docker/ + +# Download the Symfony skeleton +ENV SKELETON_COMPOSER_JSON https://raw.githubusercontent.com/symfony/skeleton/v3.3.2/composer.json +RUN [ -f composer.json ] || php -r "copy('$SKELETON_COMPOSER_JSON', 'composer.json');" + +RUN mkdir -p var/cache var/logs var/sessions \ + && composer install --prefer-dist --no-dev --no-progress --no-suggest --optimize-autoloader --classmap-authoritative --no-interaction \ + && composer clear-cache \ +# Permissions hack because setfacl does not work on Mac and Windows + && chown -R www-data var + +COPY docker/app/docker-entrypoint.sh /usr/local/bin/docker-app-entrypoint +RUN chmod +x /usr/local/bin/docker-app-entrypoint + +ENTRYPOINT ["docker-app-entrypoint"] +CMD ["php-fpm"] + +EOF + , file_get_contents($config)); + + $configurator->unconfigure($recipe, [], $lock); + $this->assertEquals($originalContent, file_get_contents($config)); + } +}