diff --git a/src/bundle/Resources/config/services/.gitkeep b/phpstan-baseline.neon similarity index 100% rename from src/bundle/Resources/config/services/.gitkeep rename to phpstan-baseline.neon diff --git a/src/bundle/DependencyInjection/IbexaCloudExtension.php b/src/bundle/DependencyInjection/IbexaCloudExtension.php index 08987c5..9b90afb 100644 --- a/src/bundle/DependencyInjection/IbexaCloudExtension.php +++ b/src/bundle/DependencyInjection/IbexaCloudExtension.php @@ -36,6 +36,7 @@ public function load(array $configs, ContainerBuilder $container): void public function prepend(ContainerBuilder $container): void { + $this->configureUpsunSetup($container); $this->prependDefaultConfiguration($container); $this->prependJMSTranslation($container); } @@ -73,4 +74,50 @@ private function shouldLoadTestServices(ContainerBuilder $container): bool return $container->hasParameter('ibexa.behat.browser.enabled') && true === $container->getParameter('ibexa.behat.browser.enabled'); } + + private function configureUpsunSetup(ContainerBuilder $container): void + { + $envVars = (new UpsunEnvVarLoader())->loadEnvVars(); + + if (($_SERVER['HTTPCACHE_PURGE_TYPE'] ?? $_ENV['HTTPCACHE_PURGE_TYPE'] ?? null) === 'varnish') { + $container->setParameter('ibexa.http_cache.purge_type', 'varnish'); + } + + // Cannot be placed as env var due to how LiipImagineBundle processes its config + if (\extension_loaded('imagick')) { + $container->setParameter('liip_imagine_driver', 'imagick'); + } + + $projectDir = $container->getParameter('kernel.project_dir'); + assert(is_string($projectDir)); + + if (isset($envVars['DFS_NFS_PATH'])) { + $loader = new YamlFileLoader( + $container, + new FileLocator($projectDir . '/config/packages/dfs') + ); + $loader->load('dfs.yaml'); + } + + if (!isset($envVars['CACHE_POOL'])) { + return; + } + + $cacheType = $envVars['CACHE_POOL']; + $configFile = match ($cacheType) { + 'cache.redis' => 'cache.redis.yaml', + 'cache.memcached' => 'cache.memcached.yaml', + default => null, + }; + + if ($configFile === null) { + return; + } + + $loader = new YamlFileLoader( + $container, + new FileLocator($projectDir . '/config/packages/cache_pool') + ); + $loader->load($configFile); + } } diff --git a/src/bundle/DependencyInjection/UpsunEnvVarLoader.php b/src/bundle/DependencyInjection/UpsunEnvVarLoader.php new file mode 100644 index 0000000..129892f --- /dev/null +++ b/src/bundle/DependencyInjection/UpsunEnvVarLoader.php @@ -0,0 +1,495 @@ +decodePayload($relationshipsEncoded); + $routes = $this->decodePayload($routesEncoded); + + if ($relationships === null || $routes === null) { + return []; + } + + $groupedRelationships = $this->groupRelationshipsByScheme($relationships); + + return array_filter( + array_merge( + $this->buildDatabaseEnvVars($groupedRelationships), + $this->buildDfsEnvVars($groupedRelationships), + $this->buildCacheEnvVars($groupedRelationships), + $this->buildSessionEnvVars($relationships, $groupedRelationships), + $this->buildSearchEnvVars($relationships), + $this->buildVarnishEnvVars($routes), + ), + static fn (string|int|null $value): bool => $value !== null && $value !== '' + ); + } + + /** + * @param array>>> $groupedRelationships + * + * @return array + */ + private function buildDatabaseEnvVars(array $groupedRelationships): array + { + $envVars = []; + + foreach ($groupedRelationships as $scheme => $relationshipsByKey) { + $isPGSQL = str_starts_with($scheme, 'pgsql'); + $isMySQL = str_starts_with($scheme, 'mysql'); + if (!$isPGSQL && !$isMySQL) { + continue; + } + + $normalizedScheme = $isPGSQL ? 'postgres' : 'mysql'; + + foreach ($relationshipsByKey as $key => $endpoints) { + $key = strtoupper($key); + + foreach (array_values($endpoints) as $i => $endpoint) { + $prefix = $this->buildPrefix($key, $i); + + $username = $endpoint['username'] ?? ''; + $password = $endpoint['password'] ?? ''; + $host = $endpoint['host'] ?? ''; + $port = $endpoint['port'] ?? 0; + $path = $endpoint['path'] ?? 'main'; + + $url = sprintf('%s://', $normalizedScheme); + if ($username !== '') { + $url .= rawurlencode($username); + if ($password !== '') { + $url .= ':' . rawurlencode($password); + } + $url .= '@'; + } + $url .= sprintf('%s:%s/%s?sslmode=disable', $host, $port, $path); + + $charset = $isMySQL ? self::MYSQL_DEFAULT_DATABASE_CHARSET : self::PGSQL_DEFAULT_DATABASE_CHARSET; + $url .= '&charset=' . $charset; + + $type = $endpoint['type'] ?? null; + if ($type !== null && str_contains((string) $type, ':')) { + [, $version] = explode(':', (string) $type, 2); + + if ($isMySQL) { + $minor = $version === '10.2' ? 7 : 0; + $version = "{$version}.{$minor}-MariaDB"; + } + + $url .= '&serverVersion=' . $version; + } + + $envVars["{$prefix}URL"] = $url; + $envVars["{$prefix}USER"] = $username; + $envVars["{$prefix}USERNAME"] = $username; + $envVars["{$prefix}PASSWORD"] = $password; + $envVars["{$prefix}HOST"] = $host; + $envVars["{$prefix}PORT"] = (string) $port; + $envVars["{$prefix}NAME"] = $path; + $envVars["{$prefix}DATABASE"] = $path; + $envVars["{$prefix}DRIVER"] = $normalizedScheme; + $envVars["{$prefix}SERVER"] = sprintf('%s://%s:%s', $normalizedScheme, $host, $port); + } + } + } + + return $envVars; + } + + /** + * Builds DFS-specific env vars. Note: DFS_DATABASE_URL and other standard database + * vars are already generated by buildDatabaseEnvVars - this only adds DFS-specific ones. + * + * @param array>>> $groupedRelationships + * + * @return array + */ + private function buildDfsEnvVars(array $groupedRelationships): array + { + $dfsPath = $_SERVER['PLATFORMSH_DFS_NFS_PATH'] ?? null; + if ($dfsPath === null) { + return []; + } + + $envVars = [ + $this->envKey('dfs_nfs_path') => $dfsPath, + ]; + + // Look for 'dfs_database' relationship within mysql/pgsql schemes to determine driver and charset + $dfsEndpoint = $this->findDfsEndpoint($groupedRelationships); + + if ($dfsEndpoint !== null) { + $scheme = (string) ($dfsEndpoint['scheme'] ?? ''); + $isPgsql = str_starts_with($scheme, 'pgsql'); + + $envVars[$this->envKey('dfs_database_driver')] = $this->normalizePdoDriver($scheme); + $envVars[$this->envKey('dfs_database_charset')] = $isPgsql + ? self::PGSQL_DEFAULT_DATABASE_CHARSET + : self::MYSQL_DEFAULT_DATABASE_CHARSET; + + // Collation is MySQL-specific, PostgreSQL doesn't use it + if (!$isPgsql) { + $envVars[$this->envKey('dfs_database_collation')] = self::DEFAULT_DATABASE_COLLATION; + } + } + + return $envVars; + } + + /** + * @param array>>> $groupedRelationships + * + * @return array|null + */ + private function findDfsEndpoint(array $groupedRelationships): ?array + { + foreach ($groupedRelationships as $scheme => $relationshipsByKey) { + if (!str_starts_with($scheme, 'mysql') && !str_starts_with($scheme, 'pgsql')) { + continue; + } + + if (!isset($relationshipsByKey['dfs_database'])) { + continue; + } + + foreach ($relationshipsByKey['dfs_database'] as $endpoint) { + if (isset($endpoint['query']['is_master']) && $endpoint['query']['is_master'] === true) { + return $endpoint; + } + } + } + + return null; + } + + /** + * @param array>>> $groupedRelationships Grouped by scheme + * + * @return array + */ + private function buildCacheEnvVars(array $groupedRelationships): array + { + $envVars = []; + $cachePoolSet = false; + + // Process Redis first (always preferred over memcached) + if (isset($groupedRelationships['redis'])) { + foreach ($groupedRelationships['redis'] as $key => $endpoints) { + $key = strtoupper($key); + + foreach (array_values($endpoints) as $i => $endpoint) { + $prefix = $this->buildPrefix($key, $i); + + $host = $endpoint['host'] ?? ''; + $port = $endpoint['port'] ?? 0; + + $envVars["{$prefix}URL"] = sprintf('redis://%s:%s', $host, $port); + $envVars["{$prefix}HOST"] = $host; + $envVars["{$prefix}PORT"] = (string) $port; + $envVars["{$prefix}SCHEME"] = 'redis'; + + if (!$cachePoolSet) { + $envVars[$this->envKey('cache_pool')] = 'cache.redis'; + $envVars[$this->envKey('cache_dsn')] = sprintf( + '%s:%d?retry_interval=3', + $host, + $port, + ); + $cachePoolSet = true; + } + } + } + } + + // Process Memcached (fallback, only sets cache_pool if redis wasn't found) + if (isset($groupedRelationships['memcached'])) { + foreach ($groupedRelationships['memcached'] as $key => $endpoints) { + $key = strtoupper($key); + + foreach (array_values($endpoints) as $i => $endpoint) { + $prefix = $this->buildPrefix($key, $i); + + $host = $endpoint['host'] ?? ''; + $port = $endpoint['port'] ?? 0; + + $envVars["{$prefix}HOST"] = $host; + $envVars["{$prefix}PORT"] = (string) $port; + + if (!$cachePoolSet) { + @trigger_error('Usage of Memcached is deprecated, redis is recommended', E_USER_DEPRECATED); + + $envVars[$this->envKey('cache_pool')] = 'cache.memcached'; + $envVars[$this->envKey('cache_dsn')] = sprintf('%s:%d', $host, $port); + $cachePoolSet = true; + } + } + } + } + + return $envVars; + } + + /** + * Uses Redis-based sessions if possible. If a dedicated 'redissession' relationship + * is available, use that. If not, fallback to any available Redis instance. + * + * @param array>> $relationships Original relationships + * @param array>>> $groupedRelationships Grouped by scheme + * + * @return array + */ + private function buildSessionEnvVars(array $relationships, array $groupedRelationships): array + { + // First, try a dedicated 'redissession' relationship + if (isset($relationships['redissession'])) { + foreach ($relationships['redissession'] as $endpoint) { + if (($endpoint['scheme'] ?? null) !== 'redis') { + continue; + } + + return [ + $this->envKey('session_handler_id') => NativeSessionHandler::class, + $this->envKey('session_save_path') => sprintf('%s:%d', $endpoint['host'], $endpoint['port']), + ]; + } + } + + // Fallback: use any available Redis instance (by scheme) + if (isset($groupedRelationships['redis'])) { + foreach ($groupedRelationships['redis'] as $endpoints) { + foreach ($endpoints as $endpoint) { + return [ + $this->envKey('session_handler_id') => NativeSessionHandler::class, + $this->envKey('session_save_path') => sprintf('%s:%d', $endpoint['host'], $endpoint['port']), + ]; + } + } + } + + return []; + } + + /** + * Builds search engine env vars. + * Uses original relationships (not grouped) to check 'rel' field for elasticsearch. + * + * @param array>> $relationships Original relationships + * + * @return array + */ + private function buildSearchEnvVars(array $relationships): array + { + $envVars = []; + + foreach ($relationships as $key => $endpoints) { + $upperKey = strtoupper($key); + + foreach (array_values($endpoints) as $i => $endpoint) { + $scheme = $endpoint['scheme'] ?? null; + $rel = $endpoint['rel'] ?? null; + + // Handle Solr (by scheme) + if ($scheme === 'solr') { + $prefix = $this->buildPrefix($upperKey, $i); + + $host = $endpoint['host'] ?? ''; + $port = $endpoint['port'] ?? 0; + $path = $endpoint['path'] ?? ''; + + $envVars[$this->envKey('search_engine')] = 'solr'; + $envVars[$this->envKey('solr_dsn')] = sprintf( + 'http://%s:%d/%s', + $host, + $port, + 'solr' + ); + $envVars[$this->envKey('solr_core')] = basename($path); + + $envVars["{$prefix}HOST"] = $host; + $envVars["{$prefix}PORT"] = (string) $port; + $envVars["{$prefix}NAME"] = $path; + $envVars["{$prefix}DATABASE"] = $path; + } + + // Handle Elasticsearch (by 'rel' field) + if ($rel === 'elasticsearch') { + $prefix = $this->buildPrefix($upperKey, $i); + + $host = $endpoint['host'] ?? ''; + $port = $endpoint['port'] ?? 0; + $scheme = $endpoint['scheme'] ?? 'http'; + $path = $endpoint['path'] ?? null; + + $dsn = sprintf('%s:%d', $host, $port); + + if (($endpoint['username'] ?? null) !== null && ($endpoint['password'] ?? null) !== null) { + $dsn = rawurlencode($endpoint['username']) . ':' . rawurlencode($endpoint['password']) . '@' . $dsn; + } + + if ($path !== null) { + $dsn .= '/' . ltrim((string) $path, '/'); + } + + $url = $scheme . '://' . $host . ':' . $port; + if ($path !== null && $path !== '') { + $url .= $path; + } + + $dsn = $scheme . '://' . $dsn; + + $envVars[$this->envKey('search_engine')] = 'elasticsearch'; + $envVars[$this->envKey('elasticsearch_dsn')] = $dsn; + + $envVars["{$prefix}URL"] = $url; + $envVars["{$prefix}HOST"] = $host; + $envVars["{$prefix}PORT"] = (string) $port; + $envVars["{$prefix}SCHEME"] = $scheme; + } + } + } + + return $envVars; + } + + /** + * @param array> $routes + * + * @return array + */ + private function buildVarnishEnvVars(array $routes): array + { + $envVars = []; + $varnishRoute = null; + + foreach ($routes as $host => $info) { + if ($varnishRoute === null && $this->isVarnishRoute($info)) { + $varnishRoute = $host; + } + + if ($this->isVarnishRoute($info) && ($info['primary'] ?? false) === true) { + $varnishRoute = $host; + break; + } + } + + $skipHttpCachePurge = (bool) ($_SERVER['SKIP_HTTPCACHE_PURGE'] ?? false); + + if ($varnishRoute !== null && $skipHttpCachePurge === false) { + $purgeServer = rtrim($varnishRoute, '/'); + $username = $_SERVER['HTTPCACHE_USERNAME'] ?? null; + $password = $_SERVER['HTTPCACHE_PASSWORD'] ?? null; + + if ($username !== null && $password !== null) { + $domain = parse_url($purgeServer, PHP_URL_HOST); + if (is_string($domain) && $domain !== '') { + $credentials = rawurlencode($username) . ':' . rawurlencode($password); + $purgeServer = str_replace($domain, $credentials . '@' . $domain, $purgeServer); + } + } + + $envVars[$this->envKey('httpcache_purge_type')] = 'varnish'; + $envVars[$this->envKey('httpcache_purge_server')] = $purgeServer; + } + + $envVars[$this->envKey('httpcache_varnish_invalidate_token')] = $_SERVER['HTTPCACHE_VARNISH_INVALIDATE_TOKEN'] + ?? $_SERVER['PLATFORM_PROJECT_ENTROPY'] + ?? ''; + + return $envVars; + } + + /** + * @return array|null + */ + private function decodePayload(string $payload): ?array + { + $decoded = base64_decode($payload, true); + + if ($decoded === false) { + return null; + } + + try { + return json_decode($decoded, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return null; + } + } + + /** + * @param array>> $relationships + * + * @return array>>> + */ + private function groupRelationshipsByScheme(array $relationships): array + { + $groupedRelationships = []; + foreach ($relationships as $key => $endpoints) { + foreach ($endpoints as $endpoint) { + if (!isset($endpoint['scheme'])) { + continue; + } + + $groupedRelationships[$endpoint['scheme']][$key][] = $endpoint; + } + } + + return $groupedRelationships; + } + + private function normalizePdoDriver(string $scheme): string + { + if ($scheme === '') { + return ''; + } + + return str_starts_with($scheme, 'pdo_') ? $scheme : 'pdo_' . $scheme; + } + + private function envKey(string $parameterName): string + { + return strtoupper(str_replace(['.', '-'], '_', $parameterName)); + } + + private function buildPrefix(string $key, int $index): string + { + $prefix = $index === 0 ? "{$key}_" : "{$key}_{$index}_"; + + return str_replace('-', '_', $prefix); + } + + /** + * @param array $route + */ + private function isVarnishRoute(array $route): bool + { + return ($route['type'] ?? null) === 'upstream' && ($route['upstream'] ?? null) === 'varnish'; + } +} diff --git a/src/bundle/EventSubscriber/TrustedHeaderClientIpEventSubscriber.php b/src/bundle/EventSubscriber/TrustedHeaderClientIpEventSubscriber.php new file mode 100644 index 0000000..2b34075 --- /dev/null +++ b/src/bundle/EventSubscriber/TrustedHeaderClientIpEventSubscriber.php @@ -0,0 +1,65 @@ + ['onKernelRequest', PHP_INT_MAX - 1], + ]; + } + + public function onKernelRequest(RequestEvent $event): void + { + $request = $event->getRequest(); + + $trustedProxies = Request::getTrustedProxies(); + $trustedHeaderSet = Request::getTrustedHeaderSet(); + + $trustedHeaderName = $this->trustedHeaderName; + if (null === $trustedHeaderName && $this->isUpsunProxy($request)) { + $trustedHeaderName = self::PLATFORM_SH_TRUSTED_HEADER_CLIENT_IP; + } + + if (null === $trustedHeaderName) { + return; + } + + $trustedClientIp = $request->headers->get($trustedHeaderName); + + if (null !== $trustedClientIp) { + if ($trustedHeaderSet !== -1) { + $trustedHeaderSet |= Request::HEADER_X_FORWARDED_FOR; + } + $request->headers->set('X_FORWARDED_FOR', $trustedClientIp); + } + + /** @var int<0, 63> $trustedHeaderSet */ + Request::setTrustedProxies($trustedProxies, $trustedHeaderSet); + } + + private function isUpsunProxy(Request $request): bool + { + return null !== $request->server->get('PLATFORM_RELATIONSHIPS'); + } +} diff --git a/src/bundle/IbexaCloudBundle.php b/src/bundle/IbexaCloudBundle.php index 70c3ce4..5ec6107 100644 --- a/src/bundle/IbexaCloudBundle.php +++ b/src/bundle/IbexaCloudBundle.php @@ -8,8 +8,20 @@ namespace Ibexa\Bundle\Cloud; +use Ibexa\Bundle\Cloud\DependencyInjection\UpsunEnvVarLoader; use Symfony\Component\HttpKernel\Bundle\Bundle; final class IbexaCloudBundle extends Bundle { + public function boot(): void + { + $envVars = (new UpsunEnvVarLoader())->loadEnvVars(); + foreach ($envVars as $name => $value) { + $value = (string) $value; + + putenv($name . '=' . $value); + $_ENV[$name] = $value; + $_SERVER[$name] = $value; + } + } } diff --git a/src/bundle/Resources/config/services.yaml b/src/bundle/Resources/config/services.yaml index a6bdf1d..fb1ddef 100644 --- a/src/bundle/Resources/config/services.yaml +++ b/src/bundle/Resources/config/services.yaml @@ -1,2 +1,3 @@ imports: - { resource: services/**/*.yaml } + diff --git a/src/bundle/Resources/config/services/console.yaml b/src/bundle/Resources/config/services/console.yaml new file mode 100644 index 0000000..efecbe1 --- /dev/null +++ b/src/bundle/Resources/config/services/console.yaml @@ -0,0 +1,6 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + Ibexa\Cloud\Command\IbexaSetupCommand: ~ diff --git a/src/bundle/Resources/config/services/env.yaml b/src/bundle/Resources/config/services/env.yaml new file mode 100644 index 0000000..6ae0b7f --- /dev/null +++ b/src/bundle/Resources/config/services/env.yaml @@ -0,0 +1,6 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + Ibexa\Bundle\Cloud\DependencyInjection\UpsunEnvVarLoader: ~ diff --git a/tests/bundle/DependencyInjection/UpsunEnvVarLoaderTest.php b/tests/bundle/DependencyInjection/UpsunEnvVarLoaderTest.php new file mode 100644 index 0000000..5da7fa9 --- /dev/null +++ b/tests/bundle/DependencyInjection/UpsunEnvVarLoaderTest.php @@ -0,0 +1,597 @@ + */ + private array $originalServer; + + protected function setUp(): void + { + parent::setUp(); + + $this->originalServer = $_SERVER; + } + + protected function tearDown(): void + { + $_SERVER = $this->originalServer; + + parent::tearDown(); + } + + /** + * @param array>> $relationships + * @param array> $routes + * @param array $expectedEnv + * @param array $serverValues + * + * @dataProvider providerForTestLoadEnvVars + */ + public function testLoadEnvVars( + array $relationships, + array $routes, + array $expectedEnv, + array $serverValues + ): void { + $_SERVER = $this->originalServer; + + foreach ($serverValues as $key => $value) { + $_SERVER[$key] = $value; + } + + $_SERVER['PLATFORM_RELATIONSHIPS'] = base64_encode(json_encode($relationships, JSON_THROW_ON_ERROR)); + $_SERVER['PLATFORM_ROUTES'] = base64_encode(json_encode($routes, JSON_THROW_ON_ERROR)); + + $loader = new UpsunEnvVarLoader(); + $result = $loader->loadEnvVars(); + + self::assertSame($expectedEnv, $result); + } + + /** + * @return iterable< + * string, + * array{ + * array>>, + * array>, + * array, + * array + * } + * > + */ + public function providerForTestLoadEnvVars(): iterable + { + $routes = $this->createRoutes(); + $serverValues = ['PLATFORM_PROJECT_ENTROPY' => 'project_entropy']; + + yield 'redis cache with session fallback and elasticsearch' => [ + [ + 'replica_db' => [ + [ + 'host' => 'database.internal', + 'hostname' => 'mysql_db_random._.eu-4.platformsh.site', + 'cluster' => 'some_cluster', + 'service' => 'mysqldb', + 'rel' => 'user', + 'scheme' => 'mysql', + 'username' => 'user', + 'password' => 'some_password', + 'port' => 3306, + 'epoch' => 0, + 'path' => 'main', + 'query' => ['is_master' => true], + 'fragment' => null, + 'public' => false, + 'host_mapped' => false, + 'type' => 'mariadb:10.4', + 'instance_ips' => ['127.0.0.1'], + 'ip' => '127.0.0.1', + ], + ], + 'rediscache' => [ + [ + 'host' => 'rediscache.internal', + 'hostname' => 'redis.service._.eu-4.platformsh.site', + 'cluster' => 'some_cluster', + 'service' => 'rediscache', + 'rel' => 'redis', + 'scheme' => 'redis', + 'username' => null, + 'password' => null, + 'port' => 6379, + 'epoch' => 0, + 'path' => null, + 'query' => [], + 'fragment' => null, + 'public' => false, + 'host_mapped' => false, + 'type' => 'redis:5.0', + 'instance_ips' => ['127.0.0.1'], + 'ip' => '127.0.0.1', + ], + ], + 'site_elasticsearch' => [ + [ + 'username' => null, + 'password' => null, + 'scheme' => 'http', + 'service' => 'elasticsearch', + 'fragment' => null, + 'ip' => '123.456.78.90', + 'hostname' => 'something.elasticsearch.service._.eu-1.platformsh.site', + 'port' => 9200, + 'cluster' => 'something-main-7rqtwti', + 'host' => 'elasticsearch.internal', + 'rel' => 'elasticsearch', + 'path' => null, + 'query' => [], + 'type' => 'elasticsearch:8.5', + 'public' => false, + 'host_mapped' => false, + ], + ], + ], + $routes, + [ + 'REPLICA_DB_URL' => 'mysql://user:some_password@database.internal:3306/main?sslmode=disable&charset=utf8mb4&serverVersion=10.4.0-MariaDB', + 'REPLICA_DB_USER' => 'user', + 'REPLICA_DB_USERNAME' => 'user', + 'REPLICA_DB_PASSWORD' => 'some_password', + 'REPLICA_DB_HOST' => 'database.internal', + 'REPLICA_DB_PORT' => '3306', + 'REPLICA_DB_NAME' => 'main', + 'REPLICA_DB_DATABASE' => 'main', + 'REPLICA_DB_DRIVER' => 'mysql', + 'REPLICA_DB_SERVER' => 'mysql://database.internal:3306', + 'REDISCACHE_URL' => 'redis://rediscache.internal:6379', + 'REDISCACHE_HOST' => 'rediscache.internal', + 'REDISCACHE_PORT' => '6379', + 'REDISCACHE_SCHEME' => 'redis', + 'CACHE_POOL' => 'cache.redis', + 'CACHE_DSN' => 'rediscache.internal:6379?retry_interval=3', + 'SESSION_HANDLER_ID' => NativeSessionHandler::class, + 'SESSION_SAVE_PATH' => 'rediscache.internal:6379', + 'SEARCH_ENGINE' => 'elasticsearch', + 'ELASTICSEARCH_DSN' => 'http://elasticsearch.internal:9200', + 'SITE_ELASTICSEARCH_URL' => 'http://elasticsearch.internal:9200', + 'SITE_ELASTICSEARCH_HOST' => 'elasticsearch.internal', + 'SITE_ELASTICSEARCH_PORT' => '9200', + 'SITE_ELASTICSEARCH_SCHEME' => 'http', + 'HTTPCACHE_VARNISH_INVALIDATE_TOKEN' => 'project_entropy', + ], + $serverValues, + ]; + + yield 'redis cache with session fallback and solr' => [ + [ + 'replica_db' => [ + [ + 'host' => 'database.internal', + 'hostname' => 'mysql_db_random._.eu-4.platformsh.site', + 'cluster' => 'some_cluster', + 'service' => 'mysqldb', + 'rel' => 'user', + 'scheme' => 'mysql', + 'username' => 'user', + 'password' => 'some_password', + 'port' => 3306, + 'epoch' => 0, + 'path' => 'main', + 'query' => ['is_master' => true], + 'fragment' => null, + 'public' => false, + 'host_mapped' => false, + 'type' => 'mariadb:10.4', + 'instance_ips' => ['127.0.0.1'], + 'ip' => '127.0.0.1', + ], + ], + 'rediscache' => [ + [ + 'host' => 'rediscache.internal', + 'hostname' => 'redis.service._.eu-4.platformsh.site', + 'cluster' => 'some_cluster', + 'service' => 'rediscache', + 'rel' => 'redis', + 'scheme' => 'redis', + 'username' => null, + 'password' => null, + 'port' => 6379, + 'epoch' => 0, + 'path' => null, + 'query' => [], + 'fragment' => null, + 'public' => false, + 'host_mapped' => false, + 'type' => 'redis:5.0', + 'instance_ips' => ['127.0.0.1'], + 'ip' => '127.0.0.1', + ], + ], + 'site_solr' => [ + [ + 'username' => null, + 'scheme' => 'solr', + 'service' => 'solr', + 'fragment' => null, + 'ip' => '123.456.78.90', + 'hostname' => 'host.solr.service._.eu-1.platformsh.site', + 'port' => 8080, + 'cluster' => 'some-cluster', + 'host' => 'solr.internal', + 'rel' => 'solr', + 'path' => 'solr/collection1', + 'query' => [], + 'password' => null, + 'type' => 'solr:9.9', + 'public' => false, + 'host_mapped' => false, + ], + ], + ], + $routes, + [ + 'REPLICA_DB_URL' => 'mysql://user:some_password@database.internal:3306/main?sslmode=disable&charset=utf8mb4&serverVersion=10.4.0-MariaDB', + 'REPLICA_DB_USER' => 'user', + 'REPLICA_DB_USERNAME' => 'user', + 'REPLICA_DB_PASSWORD' => 'some_password', + 'REPLICA_DB_HOST' => 'database.internal', + 'REPLICA_DB_PORT' => '3306', + 'REPLICA_DB_NAME' => 'main', + 'REPLICA_DB_DATABASE' => 'main', + 'REPLICA_DB_DRIVER' => 'mysql', + 'REPLICA_DB_SERVER' => 'mysql://database.internal:3306', + 'REDISCACHE_URL' => 'redis://rediscache.internal:6379', + 'REDISCACHE_HOST' => 'rediscache.internal', + 'REDISCACHE_PORT' => '6379', + 'REDISCACHE_SCHEME' => 'redis', + 'CACHE_POOL' => 'cache.redis', + 'CACHE_DSN' => 'rediscache.internal:6379?retry_interval=3', + 'SESSION_HANDLER_ID' => NativeSessionHandler::class, + 'SESSION_SAVE_PATH' => 'rediscache.internal:6379', + 'SEARCH_ENGINE' => 'solr', + 'SOLR_DSN' => 'http://solr.internal:8080/solr', + 'SOLR_CORE' => 'collection1', + 'SITE_SOLR_HOST' => 'solr.internal', + 'SITE_SOLR_PORT' => '8080', + 'SITE_SOLR_NAME' => 'solr/collection1', + 'SITE_SOLR_DATABASE' => 'solr/collection1', + 'HTTPCACHE_VARNISH_INVALIDATE_TOKEN' => 'project_entropy', + ], + $serverValues, + ]; + + yield 'dfs' => [ + [ + 'dfs_database' => [ + [ + 'host' => 'dfs_database.internal', + 'scheme' => 'mysql', + 'username' => 'dfs', + 'password' => 'dfs', + 'port' => 3306, + 'path' => 'dfs', + 'query' => ['is_master' => true], + ], + ], + ], + $routes, + [ + 'DFS_DATABASE_URL' => 'mysql://dfs:dfs@dfs_database.internal:3306/dfs?sslmode=disable&charset=utf8mb4', + 'DFS_DATABASE_USER' => 'dfs', + 'DFS_DATABASE_USERNAME' => 'dfs', + 'DFS_DATABASE_PASSWORD' => 'dfs', + 'DFS_DATABASE_HOST' => 'dfs_database.internal', + 'DFS_DATABASE_PORT' => '3306', + 'DFS_DATABASE_NAME' => 'dfs', + 'DFS_DATABASE_DATABASE' => 'dfs', + 'DFS_DATABASE_DRIVER' => 'pdo_mysql', + 'DFS_DATABASE_SERVER' => 'mysql://dfs_database.internal:3306', + 'DFS_NFS_PATH' => '/mnt/dfs/nfs', + 'DFS_DATABASE_CHARSET' => 'utf8mb4', + 'DFS_DATABASE_COLLATION' => 'utf8mb4_unicode_520_ci', + 'HTTPCACHE_VARNISH_INVALIDATE_TOKEN' => 'project_entropy', + ], + $serverValues + ['PLATFORMSH_DFS_NFS_PATH' => '/mnt/dfs/nfs'], + ]; + + yield 'postgresql without version' => [ + [ + 'pg_main' => [ + [ + 'username' => 'main', + 'password' => 'main', + 'host' => 'database.internal', + 'port' => 5432, + 'path' => 'main', + 'scheme' => 'pgsql', + 'query' => ['is_master' => true], + ], + ], + ], + $routes, + [ + 'PG_MAIN_URL' => 'postgres://main:main@database.internal:5432/main?sslmode=disable&charset=utf8', + 'PG_MAIN_USER' => 'main', + 'PG_MAIN_USERNAME' => 'main', + 'PG_MAIN_PASSWORD' => 'main', + 'PG_MAIN_HOST' => 'database.internal', + 'PG_MAIN_PORT' => '5432', + 'PG_MAIN_NAME' => 'main', + 'PG_MAIN_DATABASE' => 'main', + 'PG_MAIN_DRIVER' => 'postgres', + 'PG_MAIN_SERVER' => 'postgres://database.internal:5432', + 'HTTPCACHE_VARNISH_INVALIDATE_TOKEN' => 'project_entropy', + ], + $serverValues, + ]; + + yield 'postgresql with version 9.6' => [ + [ + 'legacy_pgsql' => [ + [ + 'username' => 'main', + 'password' => 'main', + 'host' => 'database.internal', + 'port' => 5432, + 'path' => 'main', + 'scheme' => 'pgsql', + 'type' => 'postgresql:9.6', + 'query' => ['is_master' => true], + ], + ], + ], + $routes, + [ + 'LEGACY_PGSQL_URL' => 'postgres://main:main@database.internal:5432/main?sslmode=disable&charset=utf8&serverVersion=9.6', + 'LEGACY_PGSQL_USER' => 'main', + 'LEGACY_PGSQL_USERNAME' => 'main', + 'LEGACY_PGSQL_PASSWORD' => 'main', + 'LEGACY_PGSQL_HOST' => 'database.internal', + 'LEGACY_PGSQL_PORT' => '5432', + 'LEGACY_PGSQL_NAME' => 'main', + 'LEGACY_PGSQL_DATABASE' => 'main', + 'LEGACY_PGSQL_DRIVER' => 'postgres', + 'LEGACY_PGSQL_SERVER' => 'postgres://database.internal:5432', + 'HTTPCACHE_VARNISH_INVALIDATE_TOKEN' => 'project_entropy', + ], + $serverValues, + ]; + + yield 'mysql with version 10.0' => [ + [ + 'app_db' => [ + [ + 'username' => 'main', + 'password' => '6e602888576703030f53c154051bd778', + 'host' => 'database.internal', + 'port' => 3306, + 'path' => 'main', + 'scheme' => 'mysql', + 'type' => 'mysql:10.0', + 'query' => ['is_master' => true], + ], + ], + ], + $routes, + [ + 'APP_DB_URL' => 'mysql://main:6e602888576703030f53c154051bd778@database.internal:3306/main?sslmode=disable&charset=utf8mb4&serverVersion=10.0.0-MariaDB', + 'APP_DB_USER' => 'main', + 'APP_DB_USERNAME' => 'main', + 'APP_DB_PASSWORD' => '6e602888576703030f53c154051bd778', + 'APP_DB_HOST' => 'database.internal', + 'APP_DB_PORT' => '3306', + 'APP_DB_NAME' => 'main', + 'APP_DB_DATABASE' => 'main', + 'APP_DB_DRIVER' => 'mysql', + 'APP_DB_SERVER' => 'mysql://database.internal:3306', + 'HTTPCACHE_VARNISH_INVALIDATE_TOKEN' => 'project_entropy', + ], + $serverValues, + ]; + + yield 'postgresql with version 10' => [ + [ + 'analytics_db' => [ + [ + 'username' => 'main', + 'password' => 'main', + 'host' => 'database.internal', + 'port' => 5432, + 'path' => 'main', + 'scheme' => 'pgsql', + 'type' => 'postgresql:10', + 'query' => ['is_master' => true], + ], + ], + ], + $routes, + [ + 'ANALYTICS_DB_URL' => 'postgres://main:main@database.internal:5432/main?sslmode=disable&charset=utf8&serverVersion=10', + 'ANALYTICS_DB_USER' => 'main', + 'ANALYTICS_DB_USERNAME' => 'main', + 'ANALYTICS_DB_PASSWORD' => 'main', + 'ANALYTICS_DB_HOST' => 'database.internal', + 'ANALYTICS_DB_PORT' => '5432', + 'ANALYTICS_DB_NAME' => 'main', + 'ANALYTICS_DB_DATABASE' => 'main', + 'ANALYTICS_DB_DRIVER' => 'postgres', + 'ANALYTICS_DB_SERVER' => 'postgres://database.internal:5432', + 'HTTPCACHE_VARNISH_INVALIDATE_TOKEN' => 'project_entropy', + ], + $serverValues, + ]; + + yield 'mysql without credentials version 10.1' => [ + [ + 'cache_db' => [ + [ + 'host' => 'database.internal', + 'port' => 3306, + 'scheme' => 'mysql', + 'type' => 'mysql:10.1', + 'query' => [], + ], + ], + ], + $routes, + [ + 'CACHE_DB_URL' => 'mysql://database.internal:3306/main?sslmode=disable&charset=utf8mb4&serverVersion=10.1.0-MariaDB', + 'CACHE_DB_HOST' => 'database.internal', + 'CACHE_DB_PORT' => '3306', + 'CACHE_DB_NAME' => 'main', + 'CACHE_DB_DATABASE' => 'main', + 'CACHE_DB_DRIVER' => 'mysql', + 'CACHE_DB_SERVER' => 'mysql://database.internal:3306', + 'HTTPCACHE_VARNISH_INVALIDATE_TOKEN' => 'project_entropy', + ], + $serverValues, + ]; + + yield 'mysql version 10.2 with special minor version' => [ + [ + 'cms_database' => [ + [ + 'host' => 'database.internal', + 'port' => 3306, + 'scheme' => 'mysql', + 'type' => 'mysql:10.2', + 'query' => [], + ], + ], + ], + $routes, + [ + 'CMS_DATABASE_URL' => 'mysql://database.internal:3306/main?sslmode=disable&charset=utf8mb4&serverVersion=10.2.7-MariaDB', + 'CMS_DATABASE_HOST' => 'database.internal', + 'CMS_DATABASE_PORT' => '3306', + 'CMS_DATABASE_NAME' => 'main', + 'CMS_DATABASE_DATABASE' => 'main', + 'CMS_DATABASE_DRIVER' => 'mysql', + 'CMS_DATABASE_SERVER' => 'mysql://database.internal:3306', + 'HTTPCACHE_VARNISH_INVALIDATE_TOKEN' => 'project_entropy', + ], + $serverValues, + ]; + + yield 'two databases with indexed naming' => [ + [ + 'main_mysql' => [ + [ + 'username' => 'user', + 'password' => 'pass', + 'host' => 'database.internal', + 'port' => 3306, + 'scheme' => 'mysql', + 'type' => 'mysql:10.6', + 'query' => ['is_master' => true], + ], + [ + 'username' => 'replica_user', + 'password' => 'replica_pass', + 'host' => 'database-replica.internal', + 'port' => 3306, + 'scheme' => 'mysql', + 'type' => 'mysql:10.6', + 'query' => ['is_master' => false], + ], + ], + ], + $routes, + [ + 'MAIN_MYSQL_URL' => 'mysql://user:pass@database.internal:3306/main?sslmode=disable&charset=utf8mb4&serverVersion=10.6.0-MariaDB', + 'MAIN_MYSQL_USER' => 'user', + 'MAIN_MYSQL_USERNAME' => 'user', + 'MAIN_MYSQL_PASSWORD' => 'pass', + 'MAIN_MYSQL_HOST' => 'database.internal', + 'MAIN_MYSQL_PORT' => '3306', + 'MAIN_MYSQL_NAME' => 'main', + 'MAIN_MYSQL_DATABASE' => 'main', + 'MAIN_MYSQL_DRIVER' => 'mysql', + 'MAIN_MYSQL_SERVER' => 'mysql://database.internal:3306', + 'MAIN_MYSQL_1_URL' => 'mysql://replica_user:replica_pass@database-replica.internal:3306/main?sslmode=disable&charset=utf8mb4&serverVersion=10.6.0-MariaDB', + 'MAIN_MYSQL_1_USER' => 'replica_user', + 'MAIN_MYSQL_1_USERNAME' => 'replica_user', + 'MAIN_MYSQL_1_PASSWORD' => 'replica_pass', + 'MAIN_MYSQL_1_HOST' => 'database-replica.internal', + 'MAIN_MYSQL_1_PORT' => '3306', + 'MAIN_MYSQL_1_NAME' => 'main', + 'MAIN_MYSQL_1_DATABASE' => 'main', + 'MAIN_MYSQL_1_DRIVER' => 'mysql', + 'MAIN_MYSQL_1_SERVER' => 'mysql://database-replica.internal:3306', + 'HTTPCACHE_VARNISH_INVALIDATE_TOKEN' => 'project_entropy', + ], + $serverValues, + ]; + } + + /** + * @return array< + * string, + * array{ + * id: null, + * original_url: string, + * primary: bool, + * production_url: string, + * type: string, + * to?: string, + * attributes?: array, + * upstream?: string + * } + * > + */ + private function createRoutes(): array + { + return [ + 'http://app.example.com/' => [ + 'id' => null, + 'original_url' => 'http://some_app.example.com', + 'primary' => false, + 'production_url' => 'http://some_app.example.com/', + 'to' => 'https://app.example.com/', + 'type' => 'redirect', + ], + 'http://www.app.example.com/' => [ + 'id' => null, + 'original_url' => 'http://www.{default}/', + 'primary' => false, + 'production_url' => 'http://www.some_app.example.com/', + 'to' => 'https://www.app.example.com/', + 'type' => 'redirect', + ], + 'https://app.example.com/' => [ + 'attributes' => [], + 'id' => null, + 'original_url' => 'https://some_app.example.com', + 'primary' => true, + 'production_url' => 'https://some_app.example.com/', + 'type' => 'upstream', + 'upstream' => 'app', + ], + 'https://www.app.example.com/' => [ + 'attributes' => [], + 'id' => null, + 'original_url' => 'https://www.{default}/', + 'primary' => false, + 'production_url' => 'https://www.some_app.example.com/', + 'to' => 'https://app.example.com/', + 'type' => 'redirect', + ], + ]; + } +}