diff --git a/src/building-build-configs-php-munich/code/generating-files.txt b/src/building-build-configs-php-munich/code/generating-files.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/building-build-configs-php-munich/diagram.png b/src/building-build-configs-php-munich/diagram.png new file mode 100644 index 0000000..081415a Binary files /dev/null and b/src/building-build-configs-php-munich/diagram.png differ diff --git a/src/building-build-configs-php-munich/example.mp4 b/src/building-build-configs-php-munich/example.mp4 new file mode 100644 index 0000000..02f1d7b Binary files /dev/null and b/src/building-build-configs-php-munich/example.mp4 differ diff --git a/src/building-build-configs-php-munich/sections/conclusion.rst b/src/building-build-configs-php-munich/sections/conclusion.rst new file mode 100644 index 0000000..985ec13 --- /dev/null +++ b/src/building-build-configs-php-munich/sections/conclusion.rst @@ -0,0 +1,27 @@ +.. raw:: pdf + + PageBreak standardPage + +Result +====== + +- Easier and faster to create and onboard projects. +- One canonical source of truth. +- Easy to add new features and fixes for all projects. +- Automation is easier due to consistency (e.g. Docker Compose service names). + +Thanks! +======= + +References: + +- https://opdavi.es/build-configs +- https://github.com/opdavies/docker-example-drupal +- https://github.com/opdavies/docker-example-drupal-commerce-kickstart +- https://github.com/opdavies/docker-example-drupal-localgov + +| + +Me: + +- https://www.oliverdavies.uk diff --git a/src/building-build-configs-php-munich/sections/customisation.rst b/src/building-build-configs-php-munich/sections/customisation.rst new file mode 100644 index 0000000..17324f7 --- /dev/null +++ b/src/building-build-configs-php-munich/sections/customisation.rst @@ -0,0 +1,52 @@ +.. raw:: pdf + + PageBreak standardPage + +Overriding Values +================= + +.. code-block:: yaml + + php: + version: 8.1-fpm-bullseye + # Disable PHPCS, PHPStan and PHPUnit. + phpcs: false + phpstan: false + phpunit: false + + # Ignore more directories from Git. + git: + ignore: + - /bin/ + - /libraries/ + - /web/profiles/contrib/ + +.. raw:: pdf + + TextAnnotation "Drupal Commerce Kickstart demo. No custom modules to test, and additional paths to ignore from Git." + + PageBreak + +.. code-block:: yaml + + dockerfile: + stages: + build: + # What additional directories do we need? + extra_directories: + - config + - patches + - scripts + + commands: + - composer validate --strict + - composer install + + # What additional PHP extensions do we need? + extensions: + install: [bcmath] + +.. raw:: pdf + + TextAnnotation "Extra directories and PHP extensions that need to be added". + diff --git a/src/building-build-configs-php-munich/sections/example.rst b/src/building-build-configs-php-munich/sections/example.rst new file mode 100644 index 0000000..6e8c115 --- /dev/null +++ b/src/building-build-configs-php-munich/sections/example.rst @@ -0,0 +1,92 @@ +Example +======= + +build.yaml: + +.. code-block:: yaml + + name: my-example-project + type: drupal + language: php + + php: + version: 8.1-fpm-bullseye + +| + +Dockerfile: + +.. raw:: pdf + + TextAnnotation "Abstract the project-specific values and configuration into this file." + +.. code-block:: yaml + + FROM php:8.1-fpm-bullseye AS base + +Configuring a Project +===================== + +.. code-block:: yaml + + php: + version: 8.1-fpm-bullseye + + # Which PHPCS standards should be used and on which paths? + phpcs: + paths: [web/modules/custom] + standards: [Drupal, DrupalPractice] + + # What level should PHPStan run and on what paths? + phpstan: + level: max + paths: [web/modules/custom] + +.. raw:: pdf + + PageBreak + +.. code-block:: yaml + + docker-compose: + # Which Docker Compose services do we need? + services: + - database + - php + - web + + dockerfile: + stages: + build: + # What commands do we need to run? + commands: + - composer validate --strict + - composer install + +.. raw:: pdf + + PageBreak + +.. code-block:: yaml + + web: + type: nginx # nginx, apache, caddy + + database: + type: mariadb # mariadb, mysql + version: 10 + + # Where is Drupal located? + drupal: + docroot: web # web, docroot, null + + experimental: + createGitHubActionsConfiguration: true + runGitHooksBeforePush: true + useNewDatabaseCredentials: true + +.. raw:: pdf + + TextAnnotation "Experimental opt-in features that I want to trial on certain projects or to disable non-applicable features - e.g. GitHub Actions on Bitbucket." + + PageBreak diff --git a/src/building-build-configs-php-munich/sections/internals.rst b/src/building-build-configs-php-munich/sections/internals.rst new file mode 100644 index 0000000..c007021 --- /dev/null +++ b/src/building-build-configs-php-munich/sections/internals.rst @@ -0,0 +1,300 @@ +.. raw:: pdf + + PageBreak titlePage + +.. class:: centredtitle + +Build Configs internals + +.. raw:: pdf + + PageBreak standardPage + +.. code-block:: + + src/ + Action/ + CreateFinalConfigurationData.php + CreateListOfFilesToGenerate.php + GenerateConfigurationFiles.php + ValidateConfigurationData.php + Command/ + GenerateCommand.php + InitCommand.php + DataTransferObject/ + ConfigDto.php + TemplateFile.php + Enum/ + Language.php + ProjectType.php + WebServer.php + +.. code-block:: php + :linenos: + :startinline: true + + protected function configure(): void + $this + ->addOption( + name: 'config-file', + shortcut: ['c'], + mode: InputOption::VALUE_REQUIRED, + description: 'The path to the project\'s build.yaml file', + default: 'build.yaml', + ) + ->addOption( + name: 'output-dir', + shortcut: ['o'], + mode: InputOption::VALUE_REQUIRED, + description: 'The directory to create files in', + default: '.', + ); + } + +.. code-block:: php + :linenos: + :startinline: true + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $configFile = $input->getOption(name: 'config-file'); + $outputDir = $input->getOption(name: 'output-dir'); + } + +.. raw:: pdf + + PageBreak + +.. code-block:: php + :linenos: + :startinline: true + + protected function execute(InputInterface $input, OutputInterface $output): int + { + // ... + + $pipelines = [ + new CreateFinalConfigurationData(), + + new ValidateConfigurationData(), + + new CreateListOfFilesToGenerate(), + + new GenerateConfigurationFiles( + $this->filesystem, + $this->twig, + $outputDir, + ), + ]; + } + +.. code-block:: php + :linenos: + :startinline: true + + protected function execute(InputInterface $input, OutputInterface $output): int + { + // ... + + /** + * @var Collection $generatedFiles + * @var ConfigDto $configurationData + */ + [$configurationData, $generatedFiles] = (new Pipeline()) + ->send($configFile) + ->through($pipelines) + ->thenReturn(); + + $io->info("Building configuration for {$configurationData->name}."); + + $io->write('Generated files:'); + $io->listing(static::getListOfFiles(filesToGenerate: $generatedFiles)->toArray()); + + return Command::SUCCESS; + } + +.. code-block:: php + :linenos: + :startinline: true + + // CreateFinalConfigurationData.php + + public function handle(string $configFile, \Closure $next) { + { + $configurationData = Yaml::parseFile(filename: $configFile); + + $configurationData = array_replace_recursive( + Yaml::parseFile(filename: __DIR__ . '/../../resources/build.defaults.yaml'), + $configurationData, + ); + + // ... + + return $next($configurationData); + } + +.. raw:: pdf + + PageBreak + +.. code-block:: php + :linenos: + :startinline: true + + // ValidateConfigurationData.php + + public function handle(array $configurationData, \Closure $next) + { + // Convert the input to a configuration data object. + $normalizer = new ObjectNormalizer(null, new CamelCaseToSnakeCaseNameConverter()); + $serializer = new Serializer([$normalizer], [new JsonEncoder()]); + + $configurationDataDto = $serializer->deserialize( + json_encode($configurationData), + ConfigDto::class, + 'json', + ); + + // ... + } + +.. raw:: pdf + + PageBreak + +.. code-block:: php + :linenos: + :startinline: true + + // ValidateConfigurationData.php + + public function handle(array $configurationData, \Closure $next) + { + // ... + + $validator = Validation::createValidatorBuilder() + ->enableAnnotationMapping() + ->getValidator(); + $violations = $validator->validate($configurationDataDto); + + if (0 < $violations->count()) { + throw new \RuntimeException('Configuration is invalid.'); + } + + return $next([$configurationData, $configurationDataDto]); + } + +.. code-block:: php + :linenos: + :startinline: true + + // ConfigDto.php + + #[Assert\Collection( + allowExtraFields: false, + fields: ['docroot' => new Assert\Choice([null, 'web', 'docroot'])], + )] + public array $drupal; + + #[Assert\Collection([ + 'ignore' => new Assert\Optional([ + new Assert\All([ + new Assert\Type('string'), + ]), + ]), + ])] + public array $git; + + #[Assert\Choice(choices: ['javascript', 'php', 'typescript'])] + public string $language; + + #[Assert\NotBlank] + #[Assert\Type('string')] + public string $name; + + #[Assert\Type('string')] + public string $projectRoot; + + #[Assert\Choice(choices: [ + 'drupal', + 'fractal', + 'php-library', + 'symfony', + ])] + public string $type; + +.. code-block:: php + :startinline: true + :linenos: + + // CreateListOfFilesToGenerate.php + + public function handle(array $configurationDataAndDto, \Closure $next) + { + /** + * @var ConfigDto $configDto, + * @var array $configurationData + */ + [$configurationData, $configDto] = $configurationDataAndDto; + + /** @var Collection */ + $filesToGenerate = collect(); + + // ... + } + +.. code-block:: php + :startinline: true + :linenos: + + // CreateListOfFilesToGenerate.php + + public function handle(array $configurationDataAndDto, \Closure $next) + { + // ... + + if (!isset($configDto->php['phpunit']) || $configDto->php['phpunit'] !== false) { + + $filesToGenerate->push( + new TemplateFile( + data: 'drupal/phpunit.xml.dist', + name: 'phpunit.xml.dist', + ) + ); + } + + // ... + + return $next([$configurationData, $configDto, $filesToGenerate]); + } + +.. code-block:: php + :linenos: + :startinline: true + + // GenerateConfigurationFiles.php + + public function handle(array $filesToGenerateAndConfigurationData, \Closure $next) + { + // ... + + $filesToGenerate->each(function(TemplateFile $templateFile) use ($configurationData): void { + if ($templateFile->path !== null) { + if (!$this->filesystem->exists($templateFile->path)) { + $this->filesystem->mkdir("{$this->outputDir}/{$templateFile->path}"); + } + } + + $sourceFile = "{$templateFile->data}.twig"; + + $outputFile = collect([$this->outputDir, $templateFile->path, $templateFile->name]) + ->filter()->implode('/'); + + $this->filesystem->dumpFile($outputFile, $this->twig->render($sourceFile, $configurationData)); + }); + + return $next([$configurationDataDto, $filesToGenerate]); + } + diff --git a/src/building-build-configs-php-munich/sections/intro.rst b/src/building-build-configs-php-munich/sections/intro.rst new file mode 100644 index 0000000..83206bd --- /dev/null +++ b/src/building-build-configs-php-munich/sections/intro.rst @@ -0,0 +1,51 @@ +.. raw:: pdf + + PageBreak standardPage + +What is "Build Configs"? +======================== + +- Command-line tool. +- Inspired by Workspace, name from the TheAltF4Stream. +- Built with Symfony. +- Creates and manages build configuration files. +- Customisable per-project. +- Drupal, PHP library, Fractal (TypeScript). +- "Sprint zero in a box". + +What Problem Does it Solve? +=========================== + +- I work on multiple similar projects. +- Different configuration values - e.g. ``web`` vs. ``docroot``. +- Different versions of PHP, node, etc. +- Different Docker Compose (``fpm`` vs. ``apache`` images). +- Each project was separate. +- Difficult to add new features and fix bugs across all projects. +- Inconsistencies across projects. +- Out of the box solutions didn't seem like the best fit. + +.. raw:: pdf + + TextAnnotation "Multiple projects with similar but different configurations." + TextAnnotation "" + TextAnnotation "Out of the box solutions tend to focus on one technology, could be hard to customise, and usually had more than I nedeed." + TextAnnotation "" + TextAnnotation "Start small and build up instead of removing additional things." + TextAnnotation "" + TextAnnotation "More opportunities to learn the underlying technologies." + +How Does it Work? +================= + +.. image:: diagram.png + :width: 18cm + +What Files Does it Generate? +============================ + +- Dockerfile, Docker Compose, Nix Flake, php.ini, NGINX default.conf. +- ``run`` file. +- PHPUnit, PHPCS, PHPStan. +- GitHub Actions workflow. +- Git hooks. diff --git a/src/building-build-configs-php-munich/sections/templates.rst b/src/building-build-configs-php-munich/sections/templates.rst new file mode 100644 index 0000000..b07ec59 --- /dev/null +++ b/src/building-build-configs-php-munich/sections/templates.rst @@ -0,0 +1,117 @@ +.. raw:: pdf + + PageBreak standardPage + +Dockerfile.twig +=============== + +.. code-block:: twig + :linenos: + + FROM php:{{ php.version }} AS base + + COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + RUN which composer && composer -V + + ARG DOCKER_UID=1000 + ENV DOCKER_UID="${DOCKER_UID}" + + WORKDIR {{ project_root }} + + RUN adduser --disabled-password --uid "${DOCKER_UID}" app \ + && chown app:app -R {{ project_root }} + +Dockerfile.twig +=============== + +.. code-block:: twig + :linenos: + + {% if dockerfile.stages.build.extensions.install %} + RUN docker-php-ext-install + {{ dockerfile.stages.build.extensions.install|join(' ') }} + {% endif %} + + COPY --chown=app:app phpunit.xml* ./ + + {% if dockerfile.stages.build.extra_files %} + COPY --chown=app:app {{ dockerfile.stages.build.extra_files|join(" ") }} ./ + {% endif %} + + {% for directory in dockerfile.stages.build.extra_directories %} + COPY --chown=app:app {{ directory }} {{ directory }} + {% endfor %} + +docker-compose.yaml.twig +======================== + +.. code-block:: twig + :linenos: + + services: + {% if "web" in dockerCompose.services %} + web: + <<: [*default-proxy, *default-app] + build: + context: . + target: web + depends_on: + - php + profiles: [web] + {% endif %} + +phpstan.neon.dist.twig +====================== + +.. code-block:: twig + :linenos: + + parameters: + level: {{ php.phpstan.level }} + excludePaths: + - *Test.php + - *TestBase.php + paths: + {% for path in php.phpstan.paths -%} + - {{ path }} + {%- endfor %} + + {% if php.phpstan.baseline %} + includes: + - phpstan-baseline.neon + {% endif %} + +phpunit.xml.dist.twig +===================== + +.. code-block:: twig + :linenos: + + + +phpunit.xml.dist.twig +===================== + +.. code-block:: twig + :linenos: + + + + ./{{ drupal.docroot }}/modules/custom/**/tests/**/Functional + + + ./{{ drupal.docroot }}/modules/custom/**/tests/**/Kernel + + + ./{{ drupal.docroot }}/modules/custom/**/tests/**/Unit + + diff --git a/src/building-build-configs-php-munich/slides.rst b/src/building-build-configs-php-munich/slides.rst new file mode 100644 index 0000000..0f2dafb --- /dev/null +++ b/src/building-build-configs-php-munich/slides.rst @@ -0,0 +1,30 @@ +.. footer:: @opdavies + +Building "Build Configs" +######################## + +.. class:: titleslideinfo + +Oliver Davies (@opdavies) + +| + +.. class:: titleslideinfo + +https://opdavi.es/bcm + +.. include:: ./sections/intro.rst +.. include:: ./sections/example.rst +.. include:: ./sections/customisation.rst +.. include:: ./sections/templates.rst +.. include:: ./sections/internals.rst + +.. raw:: pdf + + PageBreak titlePage + +.. class:: centredtitle + +Demo + +.. include:: ./sections/conclusion.rst