feat(tunnels): add Service type with dynamic env.php endpoint discovery
CI / php (push) Successful in 2m53s

- Add EnvServicePortResolver (DeploymentConfig) for MySQL, Redis frontends,
  session Redis, AMQP, http_cache_hosts, optional search
- Extend tunnel registry with tunnel_type, service_key, service metadata;
  provisionServiceTunnel without store/NGINX map; teardown guards
- Admin: Type (Website|Service), service dropdown, list Type column; JS toggle
- TunnelManager/start-server: forward to resolved host:port
- Tests: EnvServicePortResolver + service teardown; test stubs for Magento/PSR
- docs: admin-ui Type/Service and security notes
This commit is contained in:
2026-03-21 04:38:37 -05:00
parent 46b30d5781
commit 960db9801c
22 changed files with 970 additions and 38 deletions
+284
View File
@@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
namespace MageSail\Magesail\Model;
use Magento\Framework\App\DeploymentConfig;
/**
* Discovers TCP endpoints from app/etc/env.php (via DeploymentConfig) for service-type Holesail tunnels.
*/
class EnvServicePortResolver
{
private const MYSQL_DEFAULT_KEY = 'mysql_default';
private const SESSION_REDIS_KEY = 'session_redis';
private const QUEUE_AMQP_KEY = 'queue_amqp';
public function __construct(
private readonly DeploymentConfig $deploymentConfig
) {
}
/**
* @return list<array{service_key: string, label: string, host: string, port: int}>
*/
public function getDiscoveredServices(): array
{
$out = [];
foreach ($this->discoverMysql() as $row) {
$out[] = $row;
}
foreach ($this->discoverCacheFrontends() as $row) {
$out[] = $row;
}
foreach ($this->discoverSessionRedis() as $row) {
$out[] = $row;
}
foreach ($this->discoverAmqp() as $row) {
$out[] = $row;
}
foreach ($this->discoverHttpCacheHosts() as $row) {
$out[] = $row;
}
foreach ($this->discoverCatalogSearch() as $row) {
$out[] = $row;
}
return $out;
}
/**
* @return array{host: string, port: int}|null
*/
public function resolveService(string $serviceKey): ?array
{
foreach ($this->getDiscoveredServices() as $row) {
if ($row['service_key'] === $serviceKey) {
return ['host' => $row['host'], 'port' => $row['port']];
}
}
return null;
}
/**
* @return list<array{service_key: string, label: string, host: string, port: int}>
*/
private function discoverMysql(): array
{
$conn = $this->deploymentConfig->get('db/connection/default');
if (!\is_array($conn)) {
return [];
}
$host = trim((string) ($conn['host'] ?? ''));
if ($host === '') {
return [];
}
$port = $this->parsePort($conn['port'] ?? null) ?? 3306;
return [[
'service_key' => self::MYSQL_DEFAULT_KEY,
'label' => sprintf('MySQL (db/connection/default) — %s:%d', $host, $port),
'host' => $host,
'port' => $port,
]];
}
/**
* @return list<array{service_key: string, label: string, host: string, port: int}>
*/
private function discoverCacheFrontends(): array
{
$cache = $this->deploymentConfig->get('cache');
if (!\is_array($cache)) {
return [];
}
$frontends = $cache['frontend'] ?? null;
if (!\is_array($frontends)) {
return [];
}
$out = [];
foreach ($frontends as $name => $frontend) {
if (!\is_array($frontend)) {
continue;
}
$backend = (string) ($frontend['backend'] ?? '');
if ($backend === '' || stripos($backend, 'Redis') === false) {
continue;
}
$opts = $frontend['backend_options'] ?? null;
if (!\is_array($opts)) {
continue;
}
$host = trim((string) ($opts['server'] ?? ''));
$port = $this->parsePort($opts['port'] ?? null);
if ($host === '' || $port === null) {
continue;
}
$safeName = preg_replace('/[^a-zA-Z0-9_\-]/', '_', (string) $name) ?: 'frontend';
$key = 'cache_frontend_' . $safeName;
$out[] = [
'service_key' => $key,
'label' => sprintf('Redis cache (%s) — %s:%d', (string) $name, $host, $port),
'host' => $host,
'port' => $port,
];
}
return $out;
}
/**
* @return list<array{service_key: string, label: string, host: string, port: int}>
*/
private function discoverSessionRedis(): array
{
$session = $this->deploymentConfig->get('session');
if (!\is_array($session)) {
return [];
}
if (strtolower((string) ($session['save'] ?? '')) !== 'redis') {
return [];
}
$redis = $session['redis'] ?? null;
if (!\is_array($redis)) {
return [];
}
$host = trim((string) ($redis['host'] ?? ''));
$port = $this->parsePort($redis['port'] ?? null);
if ($host === '' || $port === null) {
return [];
}
return [[
'service_key' => self::SESSION_REDIS_KEY,
'label' => sprintf('Redis (sessions) — %s:%d', $host, $port),
'host' => $host,
'port' => $port,
]];
}
/**
* @return list<array{service_key: string, label: string, host: string, port: int}>
*/
private function discoverAmqp(): array
{
$queue = $this->deploymentConfig->get('queue');
if (!\is_array($queue)) {
return [];
}
$amqp = $queue['amqp'] ?? null;
if (!\is_array($amqp)) {
return [];
}
$host = trim((string) ($amqp['host'] ?? ''));
$port = $this->parsePort($amqp['port'] ?? null);
if ($host === '' || $port === null) {
return [];
}
return [[
'service_key' => self::QUEUE_AMQP_KEY,
'label' => sprintf('RabbitMQ (AMQP) — %s:%d', $host, $port),
'host' => $host,
'port' => $port,
]];
}
/**
* @return list<array{service_key: string, label: string, host: string, port: int}>
*/
private function discoverHttpCacheHosts(): array
{
$hosts = $this->deploymentConfig->get('http_cache_hosts');
if (!\is_array($hosts)) {
return [];
}
$out = [];
foreach ($hosts as $i => $entry) {
if (!\is_array($entry)) {
continue;
}
$host = trim((string) ($entry['host'] ?? ''));
$port = $this->parsePort($entry['port'] ?? null);
if ($host === '' || $port === null) {
continue;
}
$key = 'http_cache_' . $i;
$out[] = [
'service_key' => $key,
'label' => sprintf('HTTP cache host — %s:%d', $host, $port),
'host' => $host,
'port' => $port,
];
}
return $out;
}
/**
* Elasticsearch / OpenSearch from catalog/search in env when present.
*
* @return list<array{service_key: string, label: string, host: string, port: int}>
*/
private function discoverCatalogSearch(): array
{
$catalog = $this->deploymentConfig->get('catalog');
if (!\is_array($catalog)) {
return [];
}
$search = $catalog['search'] ?? null;
if (!\is_array($search)) {
return [];
}
$candidates = [
[
'key' => 'catalog_search_elasticsearch7',
'host' => 'elasticsearch7_server_hostname',
'port' => 'elasticsearch7_server_port',
'label' => 'Elasticsearch 7',
],
[
'key' => 'catalog_search_elasticsearch8',
'host' => 'elasticsearch8_server_hostname',
'port' => 'elasticsearch8_server_port',
'label' => 'Elasticsearch 8',
],
[
'key' => 'catalog_search_opensearch',
'host' => 'opensearch_server_hostname',
'port' => 'opensearch_server_port',
'label' => 'OpenSearch',
],
];
$out = [];
foreach ($candidates as $c) {
$host = trim((string) ($search[$c['host']] ?? ''));
$port = $this->parsePort($search[$c['port']] ?? null);
if ($host === '' || $port === null) {
continue;
}
$out[] = [
'service_key' => $c['key'],
'label' => sprintf('%s — %s:%d', $c['label'], $host, $port),
'host' => $host,
'port' => $port,
];
}
return $out;
}
private function parsePort(mixed $value): ?int
{
if ($value === null || $value === '') {
return null;
}
$n = (int) $value;
return ($n >= 1 && $n <= 65535) ? $n : null;
}
}