Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Building "Build Configs"

Building "Build Configs"

Avatar for Oliver Davies

Oliver Davies

January 23, 2024
Tweet

More Decks by Oliver Davies

Other Decks in Technology

Transcript

  1. 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". @opdavies
  2. 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. @opdavies
  3. 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. @opdavies
  4. Example build.yaml: name: my-example-project type: drupal language: php php: version:

    8.1-fpm-bullseye Dockerfile: FROM php:8.1-fpm-bullseye AS base @opdavies
  5. Configuring a Project 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] @opdavies
  6. 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 @opdavies
  7. 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 @opdavies
  8. Overriding Values 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/ @opdavies
  9. 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] @opdavies
  10. Dockerfile.twig 1 FROM php:{{ php.version }} AS base 2 3

    COPY --from=composer:2 /usr/bin/composer /usr/bin/composer 4 RUN which composer && composer -V 5 6 ARG DOCKER_UID=1000 7 ENV DOCKER_UID="${DOCKER_UID}" 8 9 WORKDIR {{ project_root }} 10 11 RUN adduser --disabled-password --uid "${DOCKER_UID}" app \ 12 && chown app:app -R {{ project_root }} @opdavies
  11. Dockerfile.twig 1 {% if dockerfile.stages.build.extensions.install %} 2 RUN docker-php-ext-install 3

    {{ dockerfile.stages.build.extensions.install|join(' ') }} 4 {% endif %} 5 6 COPY --chown=app:app phpunit.xml* ./ 7 8 {% if dockerfile.stages.build.extra_files %} 9 COPY --chown=app:app {{ dockerfile.stages.build.extra_files|join(" ") }} ./ 10 {% endif %} 11 12 {% for directory in dockerfile.stages.build.extra_directories %} 13 COPY --chown=app:app {{ directory }} {{ directory }} 14 {% endfor %} @opdavies
  12. docker-compose.yaml.twig 1 services: 2 {% if "web" in dockerCompose.services %}

    3 web: 4 <<: [*default-proxy, *default-app] 5 build: 6 context: . 7 target: web 8 depends_on: 9 - php 10 profiles: [web] 11 {% endif %} @opdavies
  13. phpstan.neon.dist.twig 1 parameters: 2 level: {{ php.phpstan.level }} 3 excludePaths:

    4 - *Test.php 5 - *TestBase.php 6 paths: 7 {% for path in php.phpstan.paths -%} 8 - {{ path }} 9 {%- endfor %} 10 11 {% if php.phpstan.baseline %} 12 includes: 13 - phpstan-baseline.neon 14 {% endif %} @opdavies
  14. phpunit.xml.dist.twig 1 <phpunit 2 beStrictAboutChangesToGlobalState="true" 3 beStrictAboutOutputDuringTests="false" 4 beStrictAboutTestsThatDoNotTestAnything="true" 5

    bootstrap="{{ drupal.docroot }}/core/tests/bootstrap.php" 6 cacheResult="false" 7 colors="true" 8 failOnWarning="true" 9 printerClass="\Drupal\Tests\Listeners\HtmlOutputPrinter" 10 > @opdavies
  15. phpunit.xml.dist.twig 1 <testsuites> 2 <testsuite name="functional"> 3 <directory>./{{ drupal.docroot }}/modules/custom/**/tests/**/Functional</directory>

    4 </testsuite> 5 <testsuite name="kernel"> 6 <directory>./{{ drupal.docroot }}/modules/custom/**/tests/**/Kernel</directory> 7 </testsuite> 8 <testsuite name="unit"> 9 <directory>./{{ drupal.docroot }}/modules/custom/**/tests/**/Unit</directory> 10 </testsuite> 11 </testsuites> @opdavies
  16. 1 protected function configure(): void 2 $this 3 ->addOption( 4

    name: 'config-file', 5 shortcut: ['c'], 6 mode: InputOption::VALUE_REQUIRED, 7 description: 'The path to the project\'s build.yaml file', 8 default: 'build.yaml', 9 ) 10 ->addOption( 11 name: 'output-dir', 12 shortcut: ['o'], 13 mode: InputOption::VALUE_REQUIRED, 14 description: 'The directory to create files in', 15 default: '.', 16 ); 17 } @opdavies
  17. 1 protected function execute(InputInterface $input, OutputInterface $output): int 2 {

    3 $io = new SymfonyStyle($input, $output); 4 5 $configFile = $input->getOption(name: 'config-file'); 6 $outputDir = $input->getOption(name: 'output-dir'); 7 } @opdavies
  18. 1 protected function execute(InputInterface $input, OutputInterface $output): int 2 {

    3 // ... 4 5 $pipelines = [ 6 new CreateFinalConfigurationData(), 7 8 new ValidateConfigurationData(), 9 10 new CreateListOfFilesToGenerate(), 11 12 new GenerateConfigurationFiles( 13 $this->filesystem, 14 $this->twig, 15 $outputDir, 16 ), 17 ]; 18 } @opdavies
  19. 1 protected function execute(InputInterface $input, OutputInterface $output): int 2 {

    3 // ... 4 5 /** 6 * @var Collection<int,TemplateFile> $generatedFiles 7 * @var ConfigDto $configurationData 8 */ 9 [$configurationData, $generatedFiles] = (new Pipeline()) 10 ->send($configFile) 11 ->through($pipelines) 12 ->thenReturn(); 13 14 $io->info("Building configuration for {$configurationData->name}."); 15 16 $io->write('Generated files:'); 17 $io->listing(static::getListOfFiles(filesToGenerate: $generatedFiles)->toArray()); 18 19 return Command::SUCCESS; 20 } @opdavies
  20. 1 // CreateFinalConfigurationData.php 2 3 public function handle(string $configFile, \Closure

    $next) { 4 { 5 $configurationData = Yaml::parseFile(filename: $configFile); 6 7 $configurationData = array_replace_recursive( 8 Yaml::parseFile(filename: __DIR__ . '/../../resources/build.defaults.yaml'), 9 $configurationData, 10 ); 11 12 // ... 13 14 return $next($configurationData); 15 } @opdavies
  21. 1 // ValidateConfigurationData.php 2 3 public function handle(array $configurationData, \Closure

    $next) 4 { 5 // Convert the input to a configuration data object. 6 $normalizer = new ObjectNormalizer(null, new CamelCaseToSnakeCaseNameConverter()); 7 $serializer = new Serializer([$normalizer], [new JsonEncoder()]); 8 9 $configurationDataDto = $serializer->deserialize( 10 json_encode($configurationData), 11 ConfigDto::class, 12 'json', 13 ); 14 15 // ... 16 } @opdavies
  22. 1 // ValidateConfigurationData.php 2 3 public function handle(array $configurationData, \Closure

    $next) 4 { 5 // ... 6 7 $validator = Validation::createValidatorBuilder() 8 ->enableAnnotationMapping() 9 ->getValidator(); 10 $violations = $validator->validate($configurationDataDto); 11 12 if (0 < $violations->count()) { 13 throw new \RuntimeException('Configuration is invalid.'); 14 } 15 16 return $next([$configurationData, $configurationDataDto]); 17 } @opdavies
  23. 1 // ConfigDto.php 2 3 #[Assert\Collection( 4 allowExtraFields: false, 5

    fields: ['docroot' => new Assert\Choice([null, 'web', 'docroot'])], 6 )] 7 public array $drupal; 8 9 #[Assert\Collection([ 10 'ignore' => new Assert\Optional([ 11 new Assert\All([ 12 new Assert\Type('string'), 13 ]), 14 ]), 15 ])] 16 public array $git; 17 @opdavies
  24. 18 #[Assert\Choice(choices: ['javascript', 'php', 'typescript'])] 19 public string $language; 20

    21 #[Assert\NotBlank] 22 #[Assert\Type('string')] 23 public string $name; 24 25 #[Assert\Type('string')] 26 public string $projectRoot; 27 28 #[Assert\Choice(choices: [ 29 'drupal', 30 'fractal', 31 'php-library', 32 'symfony', 33 ])] 34 public string $type; @opdavies
  25. 1 // CreateListOfFilesToGenerate.php 2 3 public function handle(array $configurationDataAndDto, \Closure

    $next) 4 { 5 /** 6 * @var ConfigDto $configDto, 7 * @var array<string,mixed> $configurationData 8 */ 9 [$configurationData, $configDto] = $configurationDataAndDto; 10 11 /** @var Collection<int, TemplateFile> */ 12 $filesToGenerate = collect(); 13 14 // ... 15 } @opdavies
  26. 1 // CreateListOfFilesToGenerate.php 2 3 public function handle(array $configurationDataAndDto, \Closure

    $next) 4 { 5 // ... 6 7 if (!isset($configDto->php['phpunit']) || $configDto->php['phpunit'] !== false) { 8 9 $filesToGenerate->push( 10 new TemplateFile( 11 data: 'drupal/phpunit.xml.dist', 12 name: 'phpunit.xml.dist', 13 ) 14 ); 15 } 16 17 // ... 18 19 return $next([$configurationData, $configDto, $filesToGenerate]); 20 } @opdavies
  27. 1 // GenerateConfigurationFiles.php 2 3 public function handle(array $filesToGenerateAndConfigurationData, \Closure

    $next) 4 { 5 // ... 6 7 $filesToGenerate->each(function(TemplateFile $templateFile) use ($configurationData): void { 8 if ($templateFile->path !== null) { 9 if (!$this->filesystem->exists($templateFile->path)) { 10 $this->filesystem->mkdir("{$this->outputDir}/{$templateFile->path}"); 11 } 12 } 13 14 $sourceFile = "{$templateFile->data}.twig"; 15 16 $outputFile = collect([$this->outputDir, $templateFile->path, $templateFile->name]) 17 ->filter()->implode('/'); 18 19 $this->filesystem->dumpFile($outputFile, $this->twig->render($sourceFile, $configurationData)); 20 }); 21 22 return $next([$configurationDataDto, $filesToGenerate]); 23 } @opdavies
  28. 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). @opdavies