<?php namespace LakeDrops\Docker4Drupal; use Exception; use LakeDrops\Component\Composer\BaseHandler; use LakeDrops\DockerTraefik\Traefik; use LakeDrops\DrupalEnvironment\Handler as DrupalEnvironment; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Yaml\Yaml; /** * Class Handler. * * @package LakeDrops\Docker4Drupal */ class Handler extends BaseHandler { /** * {@inheritdoc} */ public function configId(): string { return 'docker4drupal'; } /** * {@inheritdoc} */ protected function configDefault(): array { $projectname = getenv('COMPOSE_PROJECT_NAME'); if (empty($projectname)) { $projectname = str_replace([' ', '-', '_', '.'], '', basename(getcwd())); $this->env->put('COMPOSE_PROJECT_NAME', $projectname); } $options = [ 'projectname' => $projectname, 'staging' => FALSE, 'basicauth' => [ 'enabled' => FALSE, 'user' => '', 'pass' => '', 'code' => '', ], 'ci_home' => '/home/gitlab-runner', 'docker0' => [ 'ip' => ($this->isCiContext() || $this->isLocalDevMode()) ? $this->getDockerGateway() : $this->getLocalIpv4('docker0'), 'proxy' => ($this->isCiContext() || $this->isLocalDevMode()) ? $this->getDockerProxy($projectname) : FALSE, ], 'traefik' => [ 'domain' => $this->env->receive('traefik_domain', '', 'docker.localhost'), 'usessl' => $this->env->receive('traefik_usessl', '', '0'), 'port' => $this->env->receive('traefik_port', '', '8000'), 'ports' => $this->env->receive('traefik_ports', '', '8443'), 'cert' => $this->env->receive('traefik_cert', '', 'fullchain.pem'), 'key' => $this->env->receive('traefik_key', '', 'privkey.pem'), 'portainer' => $this->env->receive('traefik_portainer', '', '0'), ], 'live' => [ 'root' => '', 'uri' => '', 'host' => '', 'user' => $this->env->receive('live_host_username', 'Remote username for host of the live site', getenv('USER')), ], 'drush' => [ 'sql' => [ 'tables' => [ 'structure' => [ 'cache', 'cachetags', 'cache_*', 'history', 'search_*', 'sessions', 'watchdog', ], 'skip' => [ 'migration_*', ], ], ], ], 'drupal' => [ 'version' => '9', ], 'php' => [ 'version' => $this->env->receiveGlobal('PHP_VERSION', 'PHP version', '7.4'), 'xdebug' => $this->env->receiveGlobal('PHP_DEBUG', 'PHP debug', '0'), 'coverage' => $this->env->receiveGlobal('PHP_COVERAGE', 'PHP coverage', '0'), ], 'dbserver' => [ 'type' => 'mariadb', 'version' => '10.6', ], 'webserver' => [ 'type' => 'apache', 'overwriteconfig' => FALSE, ], 'mailhog' => [ 'host' => $this->env->receiveGlobal('MAILHOG_HOST', 'MailHog Host', 'smtp.freesmtpservers.com'), 'port' => $this->env->receiveGlobal('MAILHOG_PORT', 'MailHog Port', '25'), 'username' => $this->env->receiveGlobal('MAILHOG_USERNAME', 'MailHog Username'), 'password' => $this->env->receiveGlobal('MAILHOG_PASSWORD', 'MailHog Password'), 'mechanism' => $this->env->receiveGlobal('MAILHOG_MECHANISM', 'MailHog Auth Mechanism', 'NONE'), ], 'varnish' => [ 'enable' => 0, ], 'redis' => [ 'version' => '6', ], 'dbbrowser' => [ 'type' => 'pma', ], 'solr' => [ 'enable' => 0, 'version' => '4.8.0', ], 'node' => [ 'enable' => 0, 'key' => '', 'path' => '', ], 'memcached' => [ 'enable' => 0, ], 'rsyslog' => [ 'enable' => 0, ], 'athenapdf' => [ 'enable' => 0, 'key' => '', ], 'blackfire' => [ 'enable' => 0, 'id' => '', 'token' => '', ], 'webgrind' => [ 'enable' => 0, ], 'selenium' => [ 'enable' => 0, ], 'elasticsearch' => [ 'enable' => 0, ], 'wkhtmltox' => [ 'enable' => 0, ], 'backstop' => $this->backstopDefaults(), 'crontabs' => [ 'www-data' => [], ], 'backup' => [ 'enabled' => FALSE, 'version' => 'base-1.2.0-1.6.0', 'crontime' => '0 1 * * *', 'remoterepo' => FALSE, 'retention' => [ 'hourly' => 2, 'daily' => 7, 'weekly' => 8, 'monthly' => 12, 'yearly' => 30, ], ], ]; if ($this->isCiContext() || $this->isLocalDevMode()) { $projectRoot = $this->getDockerMountSource(getenv('CI_PROJECT_DIR')); } else { $projectRoot = getcwd(); } // Check if SSH auth sockets are supported. $ssh_auth_sock = getenv('SSH_AUTH_SOCK'); $options['php']['ssh'] = !empty($ssh_auth_sock); if ($options['php']['ssh']) { $options['php']['ssh_auth_sock'] = ($this->isCiContext() || $this->isLocalDevMode()) ? $this->getDockerMountSource('/ssh-agent') : '$SSH_AUTH_SOCK'; } $options['projectroot'] = $projectRoot; $options['projectdomain'] = $options['projectname'] . '.' . $options['traefik']['domain']; $options['projectprotocol'] = 'http' . ($options['traefik']['usessl'] ? 's' : ''); $options['projectport'] = ''; if ($options['traefik']['usessl'] && (int) $options['traefik']['ports'] !== 443) { $options['projectport'] = ':' . $options['traefik']['ports']; } else if (!$options['traefik']['usessl'] && (int) $options['traefik']['port'] !== 80) { $options['projectport'] = ':' . $options['traefik']['port']; } return $options; } /** * {@inheritdoc} */ protected function postInit(): void { $this->env->put('PHP_VERSION', $this->config->readValue(['php', 'version']), TRUE); } /** * Configure Drupal Project for Docker. * * @param bool $overwrite * Whether to overwrite existing config files. */ public function configureProject(bool $overwrite = FALSE): void { // We only do the fancy stuff for developers. if (!$this->isDevMode()) { return; } $this->init(); // Configure Drupal environment if avaiable. if ($this->getPackage('lakedrops/drupal-environment')) { $handler = new DrupalEnvironment($this->composer, $this->io); $handler->setupLakeDropsProject(); // Update config for production build. if (getenv('LAKEDROPS_BUILD_NG') === 'yes') { $config = $handler->getConfig(); $root = '/drupal/' . getenv('CI_PROJECT_ID') . '/' . getenv('CI_COMMIT_BRANCH'); $drupal = $this->config->readValue('drupal'); $drupal['live'] = $config->readValue('live'); $traefik = $this->config->readValue('traefik'); $traefik['usessl'] = 1; $traefik['ports'] = 443; $overwriteConfig = [ 'staging' => !in_array(getenv('PROJECT_BRANCH_SANITIZED'), ['master', 'main'], TRUE), 'docker0' => [ 'ip' => 'TRAEFIK-IP-PLACEHOLDER', 'proxy' => 'TRAEFIK-IP-PLACEHOLDER', ], 'drupal' => $drupal, 'traefik' => $traefik, 'projectroot' => $root . '/app', 'projectrootbackup' => $root . '/backup', 'projectrootdb' => $root . '/db', 'projectrootfiles' => $root . '/files', 'projectrootredis' => $root . '/redis', 'projectname' => getenv('PROJECT_NAME') . '_' . getenv('CI_COMMIT_REF_SLUG'), 'projectdomain' => $config->readValue('domain'), 'projectprotocol' => 'https', 'projectport' => '', 'extradomains' => $config->readValue('aliases'), ]; foreach ($overwriteConfig as $key => $value) { $this->config->setValue($key, $value, FALSE); } } } $fs = new Filesystem(); $installationManager = $this->composer->getInstallationManager(); $webRoot = $this->config->readValue('webroot'); if ($webRoot !== NULL) { if (!$fs->exists($webRoot)) { return; } } else { $drupalCorePackage = $this->getDrupalCorePackage(); if (!$drupalCorePackage) { // We are called too early, Drupal core is not available yet. return; } $corePath = $installationManager->getInstallPath($drupalCorePackage); // Directory where Drupal's index.php is located. $webRoot = dirname($corePath); } $this->config->setValue('webRoot', $webRoot, FALSE); // Directory where the root project is being created. $projectRoot = getcwd(); // Directory where this plugin is being installed. $pluginRoot = $installationManager->getInstallPath($this->getPackage('lakedrops/docker4drupal')); // If the d8-project-scaffold or d9-project-scaffold plugin is present we // only execute this one if $force is TRUE. This way we can make sure that // we get executed after d8-project-scaffold or d9-project-scaffold. $settingsPath = $webRoot . '/sites/default'; if ($this->getPackage('lakedrops/d8-project-scaffold') || $this->getPackage('lakedrops/d9-project-scaffold') || $this->getPackage('lakedrops/drupal-environment') || $this->getPackage('lakedrops/drupal-development-environment')) { if (!$fs->exists($projectRoot . '/settings/default')) { return; } $settingsPath = 'settings/default'; } // Provide all the required files. $orig_ignored = FALSE; foreach ($this->getFiles($projectRoot, $webRoot, $settingsPath) as $template => $def) { if (isset($def['condition']) && !$def['condition']) { continue; } if (!$fs->exists($def['dest'])) { $fs->mkdir($def['dest']); } $filename = $this->config->render($template, $template); $file = $def['dest'] . '/' . $filename; if (($overwrite && empty($def['add2git'])) || !$fs->exists($file)) { $source = isset($def['source']) && is_file($pluginRoot . '/templates/' . $def['source']) ? $pluginRoot . '/templates/' . $def['source'] : $pluginRoot . '/templates/' . ($def['source'] ?? '') . $template . '.twig'; if (isset($def['options'])) { $this->config->setValue('loopoptions', $def['options'], FALSE); } $rendered = $this->config->render($filename, file_get_contents($source)); $extraOptions = $this->config->readValue($filename); if (!empty($def['add2yaml']) && $extraOptions !== NULL) { $yaml = Yaml::parse($rendered); /** @noinspection SlowArrayOperationsInLoopInspection */ $yaml = array_merge_recursive($yaml, $extraOptions); $rendered = Yaml::dump($yaml, 9, 2); // Render the string again so that custom content can also use variables $rendered = $this->config->render($filename, $rendered); } elseif ($extraOptions !== NULL) { $rendered .= $extraOptions; } if ($fs->exists($file)) { if (md5_file($file) === md5($rendered)) { continue; } $orig_file = $file . '.orig'; if ($fs->exists($orig_file)) { $fs->remove($orig_file); } $fs->rename($file, $orig_file); if (!$orig_ignored) { $this->gitIgnore('*.orig'); $orig_ignored = TRUE; } } if (empty($def['add2git'])) { $this->gitIgnore($filename); } file_put_contents($file, $rendered); } if (isset($def['link']) && ($def['link'] !== $settingsPath)) { $link = $def['link'] . '/' . $filename; if (!$fs->exists($link)) { $rel = substr($fs->makePathRelative($file, $projectRoot . '/' . $link), 3, -1); $fs->symlink($rel, $link); } } $fs->chmod($file, $def['mode'] ?? 0664); } // Make sure that settings.docker.php gets called from settings.php. $settingsPhpFile = $settingsPath . '/settings.php'; if ($fs->exists($settingsPhpFile)) { $settingsPhp = file_get_contents($settingsPhpFile); if (strpos($settingsPhp, 'settings.docker.php') === FALSE) { $settingsPhp .= "\n\nif (file_exists(__DIR__ . '/settings.docker.php')) {\n include __DIR__ . '/settings.docker.php';\n}\n"; file_put_contents($settingsPhpFile, $settingsPhp); } } // Setup BackstopJS. $this->gitIgnore('tests/backstop/backstop.json'); $this->gitIgnore('tests/backstop/backstop-script-*'); $this->gitIgnore('tests/backstop/backstop_data/bitmaps_test'); $this->gitIgnore('tests/backstop/backstop_data/html_report'); $this->gitLFS('tests/backstop/**/*.png'); if (getenv('LAKEDROPS_BUILD_NG') !== 'yes') { $this->updateTraefik(); } // Set permissions, see https://wodby.com/stacks/drupal/docs/local/permissions exec('setfacl -dR -m u:$(whoami):rwX -m u:82:rwX -m u:100:rX -m g::rwX ' . $projectRoot . ' >/dev/null 2>&1'); exec('setfacl -R -m u:$(whoami):rwX -m u:82:rwX -m u:100:rX -m g::rwX ' . $projectRoot . ' >/dev/null 2>&1'); } /** * Configure Traefik on the host for all projects. */ public function configureTraefik(): void { if (!$this->isDevMode()) { return; } $this->init(); $this->updateTraefik(); } /** * Update Traefik. */ private function updateTraefik(): void { $traefik = new Traefik( $this->config->readValue('projectname'), $this->config->readValue(['traefik', 'domain']), $this->config->readValue(['traefik', 'port']), $this->config->readValue(['traefik', 'ports']), $this->config->readValue(['traefik', 'cert']), $this->config->readValue(['traefik', 'key']) ); if ($this->config->readValue(['traefik', 'portainer'])) { $traefik->setAddonPortainer(TRUE); } $traefik->update(); } /** * List of files and settings on how to handle them. * * @param string $projectRoot * Name of the project's root directory. * @param string $webRoot * Name of the web's root directory. * @param string $settingsPath * Name of the settings directory. * * @return array * List of files. */ protected function getFiles(string $projectRoot, string $webRoot, string $settingsPath): array { $files = [ 'settings.docker.php' => [ 'dest' => $projectRoot . '/' . $settingsPath, 'link' => $webRoot . '/sites/default', ], 'docker-compose.yml' => [ 'dest' => $projectRoot, 'add2yaml' => TRUE, ], 'drushrc.php' => [ 'dest' => $projectRoot . '/drush', ], 'default.site.yml' => [ 'dest' => $projectRoot . '/drush/sites', 'add2yaml' => TRUE, ], 'stage.site.yml' => [ 'dest' => $projectRoot . '/drush/sites', 'add2yaml' => TRUE, ], 'drush.yml' => [ 'dest' => $projectRoot . '/drush', 'add2yaml' => TRUE, ], 'wkhtmltox.sh' => [ 'dest' => $projectRoot . '/.docker-init', ], 'backstop.json' => [ 'source' => 'tests/backstop/', 'dest' => $projectRoot . '/tests/backstop', 'add2yaml' => TRUE, ], 'backstop-script-before' => [ 'source' => 'tests/backstop/', 'dest' => $projectRoot . '/tests/backstop', 'add2yaml' => TRUE, 'mode' => 0775, ], 'backstop-script-after' => [ 'source' => 'tests/backstop/', 'dest' => $projectRoot . '/tests/backstop', 'add2yaml' => TRUE, 'mode' => 0775, ], 'vhost.conf' => [ 'dest' => $projectRoot . '/apache', 'condition' => $this->config->readValue(['webserver', 'overwriteconfig']), ], 'mhout.json' => [ 'dest' => $projectRoot . '/tests', ], ]; if (getenv('LAKEDROPS_BUILD_NG') === 'yes') { $files['config.yaml'] = [ 'source' => 'backup/', 'dest' => $projectRoot . '/backup', 'condition' => $this->config->readValue(['backup', 'enabled']), ]; $files['crontab.txt'] = [ 'source' => 'backup/', 'dest' => $projectRoot . '/backup', 'condition' => $this->config->readValue(['backup', 'enabled']), ]; foreach ($this->config->readValue('crontabs') ?? [] as $user => $tasks) { $files[$user] = [ 'source' => 'crontabs/template.twig', 'dest' => $projectRoot . '/crontabs', 'options' => $tasks, ]; } } return $files; } /** * Determine local ipv4 address. * * @param string|null $interface * The name of the interface for which to determine the ipv4 address. * * @return string|array * The ipv4 address(es). */ private function getLocalIpv4(string $interface = NULL) { $out = explode(PHP_EOL, shell_exec('LC_ALL=C /sbin/ifconfig')); $local_addrs = array(); $ifname = 'unknown'; foreach ($out as $str) { $matches = array(); if (preg_match('/^([a-z0-9]+)(:\d{1,2})?(\s)+Link/', $str, $matches)) { $ifname = $matches[1]; if ($matches[2] !== '') { $ifname .= $matches[2]; } } elseif (preg_match('/inet addr:((?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:[.](?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3})\s/', $str, $matches)) { $local_addrs[$ifname] = $matches[1]; } } if (!isset($interface)) { return $local_addrs; } return $local_addrs[$interface] ?? '127.0.0.1'; } /** * @return string */ private function getDockerGateway(): string { $container = $this->readContainerConfig(); return $container['NetworkSettings']['Gateway']; } /** * @param string $projectname * * @return string */ private function getDockerProxy(string $projectname): string { foreach ($this->readNetworkConfig()['Containers'] as $container) { if (isset($container['Name']) && in_array($container['Name'], ['traefik_traefik_1', 'traefik-traefik-1'])) { return explode('/', $container['IPv4Address'])[0]; } } return '127.0.0.1'; } /** * @param $projectRoot * * @return string */ private function getDockerMountSource($projectRoot): string { $currentDir = getcwd(); $container = $this->readContainerConfig(); foreach ($container['Mounts'] as $mount) { if (empty($projectRoot)) { if ($currentDir === $mount['Destination']) { return $mount['Source']; } } else if (strpos($projectRoot, $mount['Destination']) === 0) { return $mount['Source'] . substr($projectRoot, strlen($mount['Destination'])); } } return getcwd(); } /** * @return array */ private function readContainerConfig(): array { try { $output = []; exec('basename "$(cat /proc/1/cpuset)"', $output); $id = reset($output); if ($id === '/') { $id = getenv('COMPOSE_PROJECT_NAME') . '_l3d'; } $output = []; exec('docker container inspect ' . $id, $output); return json_decode(implode('', $output), TRUE)[0]; } catch (Exception $ex) { // Ignore. } return [ 'NetworkSettings' => [ 'Gateway' => '127.0.0.1', ], 'Mounts' => [], ]; } /** * @return array */ private function readNetworkConfig(): array { try { $output = []; exec('docker network inspect traefik-public', $output); return json_decode(implode('', $output), TRUE)[0]; } catch (Exception $ex) { // Ignore. } return [ 'Containers' => [], ]; } /** * @return array */ private function backstopDefaults(): array { return [ 'id' => 'drupal', 'd4dscripts' => [ 'before' => [], 'after' => [], ], 'viewports' => [ 'desktop' => [ 'width' => 1960, 'height' => 1280, ], ], 'scenarios' => [ 'frontpage' => '/', ], ]; } }