<?php namespace LakeDrops\Docker4Drupal; use Exception; use LakeDrops\Component\Composer\BaseHandler; use LakeDrops\DockerTraefik\Traefik; 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, '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'), ], 'webserver' => [ 'type' => 'apache', 'overwriteconfig' => FALSE, ], '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, ], 'wkhtmltox' => [ 'enable' => 0, ], ]; 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; 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($overwrite = FALSE): void { // We only do the fancy stuff for developers. if (!$this->isDevMode()) { return; } $this->init(); $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')) { 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)) { $rendered = $this->config->render($filename, file_get_contents($pluginRoot . '/templates/' . ($def['source'] ?? '') . $template . '.twig')); $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); } 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, 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_data/bitmaps_test'); $this->gitIgnore('tests/backstop/backstop_data/html_report'); $this->gitLFS('tests/backstop/**/*.png'); $this->updateTraefik(FALSE); // 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. * * @param bool $rewrite * Whether to rewrite existing Traefik config. */ public function configureTraefik($rewrite = FALSE): void { if (!$this->isDevMode()) { return; } $this->init(); $this->updateTraefik($rewrite); } /** * @param $rewrite */ private function updateTraefik($rewrite): 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 ((bool) $this->config->readValue(['traefik', 'portainer'])) { $traefik->setAddonPortainer(TRUE); } $traefik->update($rewrite); } /** * 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 { return [ '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, 'add2git' => TRUE, ], 'vhost.conf' => [ 'dest' => $projectRoot . '/apache', 'condition' => $this->config->readValue(['webserver', 'overwriteconfig']), ], ]; } /** * 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($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($projectname)['Containers'] as $container) { if (isset($container['Name']) && $container['Name'] === '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); $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' => [], ]; } /** * @param string $projectname * * @return array */ private function readNetworkConfig(string $projectname): array { try { $output = []; exec('docker network inspect traefik_' . $projectname, $output); return json_decode(implode('', $output), TRUE)[0]; } catch (Exception $ex) { // Ignore. } return [ 'Containers' => [], ]; } }