Files
MageSail/Magesail/Model/NginxGlobalMapManager.php
T
2026-03-21 01:36:59 -05:00

546 lines
19 KiB
PHP

<?php
declare(strict_types=1);
namespace MageSail\Magesail\Model;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Shell;
use Psr\Log\LoggerInterface;
/**
* Updates Ansible-style global map files: map $http_host $MAGE_RUN_CODE { ... } and $MAGE_RUN_TYPE { ... }.
*/
class NginxGlobalMapManager
{
private const XML_MAP_PATH = 'magesail/nginx/global_map_path';
private const XML_NGINX_BINARY = 'magesail/nginx/nginx_binary';
private const XML_MAP_SOURCE = 'magesail/nginx/map_source_variable';
private const XML_MAP_RUN_CODE = 'magesail/nginx/map_run_code_variable';
private const XML_MAP_RUN_TYPE = 'magesail/nginx/map_run_type_variable';
private const XML_MAP_RUN_TYPE_VALUE = 'magesail/nginx/map_run_type_value';
private const XML_TEST_PID_PATH = 'magesail/nginx/test_pid_path';
private const XML_RELOAD_WRAPPER = 'magesail/nginx/reload_wrapper_script';
public const LINE_COMMENT = '# MageSail tunnel';
public function __construct(
private readonly ScopeConfigInterface $scopeConfig,
private readonly Shell $shell,
private readonly LoggerInterface $logger
) {
}
public function getMapFilePath(): ?string
{
$path = trim((string) $this->scopeConfig->getValue(self::XML_MAP_PATH));
return $path !== '' ? $path : null;
}
/**
* @throws LocalizedException
*/
public function readMapFile(): string
{
$path = $this->getMapFilePath();
if ($path === null) {
throw new LocalizedException(__('NGINX global map path is not configured.'));
}
if (!\is_readable($path)) {
throw new LocalizedException(__('NGINX global map file is not readable: %1', $path));
}
$content = file_get_contents($path);
if ($content === false) {
throw new LocalizedException(__('Failed to read NGINX global map file: %1', $path));
}
return $content;
}
/**
* @throws LocalizedException
*/
public function writeMapFile(string $content): void
{
$path = $this->getMapFilePath();
if ($path === null) {
throw new LocalizedException(__('NGINX global map path is not configured.'));
}
$dir = \dirname($path);
if (!\is_dir($dir)) {
throw new LocalizedException(__('NGINX global map directory does not exist: %1', $dir));
}
$fileExists = \is_file($path);
if (!$fileExists) {
if (!\is_writable($dir)) {
throw new LocalizedException(
__(
'NGINX global map directory is not writable: %1. Create the file first (e.g. touch) and chown/chgrp ' .
'so the PHP-FPM user can write it, or grant ACL/group write on the directory.',
$dir
)
);
}
} elseif (!\is_writable($path)) {
throw new LocalizedException(
__(
'NGINX global map file is not writable: %1. Ensure the PHP-FPM user can write this file ' .
'(e.g. a shared UNIX group with mode 664 and usermod -aG <group> <php_user>, or setfacl -m u:<php_user>:rw).',
$path
)
);
}
if (file_put_contents($path, $content, LOCK_EX) === false) {
throw new LocalizedException(__('Failed to write NGINX global map file: %1', $path));
}
}
/**
* Add host → store code in $MAGE_RUN_CODE map and host → store in $MAGE_RUN_TYPE map.
*
* @throws LocalizedException
*/
public function addTunnelMapping(string $hostname, string $storeCode): void
{
$content = $this->readMapFile();
$content = $this->removeTunnelMappingFromContent($content, $hostname);
$runTypeValue = trim((string) $this->scopeConfig->getValue(self::XML_MAP_RUN_TYPE_VALUE)) ?: 'store';
$lineCode = $this->buildMapLine($hostname, $storeCode);
$lineType = $this->buildMapLine($hostname, $runTypeValue);
$content = $this->insertIntoNamedMap($content, $this->getMapRunCodeVar(), $lineCode);
$content = $this->insertIntoNamedMap($content, $this->getMapRunTypeVar(), $lineType);
$this->writeMapFile($content);
}
/**
* @throws LocalizedException
*/
public function removeTunnelMapping(string $hostname): void
{
$content = $this->readMapFile();
$updated = $this->removeTunnelMappingFromContent($content, $hostname);
if ($updated !== $content) {
$this->writeMapFile($updated);
}
}
/**
* @return array{valid: bool, output?: string, error?: string}
*/
public function validateNginxConfig(): array
{
$mapPath = $this->getMapFilePath();
if ($mapPath === null) {
return [
'valid' => false,
'error' => 'Global map path is not configured.',
'output' => '',
];
}
if (!\is_readable($mapPath)) {
return [
'valid' => false,
'error' => 'Global map file is not readable: ' . $mapPath,
'output' => '',
];
}
$configuredPid = trim((string) $this->scopeConfig->getValue(self::XML_TEST_PID_PATH));
$cleanupPidFile = $configuredPid === '';
$pidPath = $cleanupPidFile
? rtrim(sys_get_temp_dir(), \DIRECTORY_SEPARATOR) . \DIRECTORY_SEPARATOR
. 'magesail-nginx-test-' . getmypid() . '-' . bin2hex(random_bytes(4)) . '.pid'
: $configuredPid;
$tempConf = rtrim(sys_get_temp_dir(), \DIRECTORY_SEPARATOR)
. \DIRECTORY_SEPARATOR . 'magesail-nginx-minimal-test-' . getmypid() . '.conf';
$body = $this->buildMinimalNginxTestConfig($pidPath, $mapPath);
if (file_put_contents($tempConf, $body, LOCK_EX) === false) {
return [
'valid' => false,
'error' => 'Could not write temporary nginx test config: ' . $tempConf,
'output' => '',
];
}
try {
$result = $this->runNginxProcess(['-t', '-c', $tempConf]);
} finally {
if (\is_file($tempConf)) {
@unlink($tempConf);
}
if ($cleanupPidFile && \is_file($pidPath)) {
@unlink($pidPath);
}
}
if ($result['exitCode'] === 0) {
$note = $result['output'] !== ''
? $result['output'] . "\n"
: '';
$note .= '(syntax check: minimal config + include ' . $mapPath . ')';
return ['valid' => true, 'output' => trim($note)];
}
$this->logger->error('NGINX config validation failed: ' . $result['output']);
$err = $result['output'] !== ''
? $result['output']
: 'nginx -t exited with code ' . $result['exitCode'];
return [
'valid' => false,
'error' => $err,
'output' => $result['output'],
];
}
/**
* @return array{success: bool, output?: string, error?: string}
*/
public function reloadNginx(): array
{
$wrapper = trim((string) $this->scopeConfig->getValue(self::XML_RELOAD_WRAPPER));
if ($wrapper !== '') {
if (!\is_executable($wrapper)) {
$msg = 'Reload wrapper script is missing or not executable: ' . $wrapper;
$this->logger->error('MageSail NGINX: ' . $msg);
return ['success' => false, 'error' => $msg, 'output' => ''];
}
$result = $this->runExternalExecutable([$wrapper]);
} else {
$result = $this->runNginxProcess(['-s', 'reload']);
}
if ($result['exitCode'] === 0) {
$this->logger->info('NGINX reloaded successfully after tunnel map update.');
return ['success' => true, 'output' => $result['output']];
}
$this->logger->error('NGINX reload failed: ' . $result['output']);
$err = $result['output'] !== ''
? $result['output']
: 'nginx reload exited with code ' . $result['exitCode'];
return [
'success' => false,
'error' => $err,
'output' => $result['output'],
];
}
/**
* @return array{valid: bool, reloaded: bool, error?: string, output?: string}
*/
public function validateAndReload(): array
{
$validation = $this->validateNginxConfig();
if (!$validation['valid']) {
return [
'valid' => false,
'reloaded' => false,
'error' => $validation['error'] ?? 'NGINX config validation failed',
'output' => $validation['output'] ?? null,
];
}
$reload = $this->reloadNginx();
return [
'valid' => true,
'reloaded' => $reload['success'],
'error' => $reload['success'] ? null : ($reload['error'] ?? 'NGINX reload failed'),
'output' => $reload['output'] ?? null,
];
}
public function extractHostname(string $baseUrl): string
{
$parsed = parse_url($baseUrl);
if ($parsed === false || !isset($parsed['host'])) {
throw new LocalizedException(__('Could not extract hostname from base URL: %1', $baseUrl));
}
return $parsed['host'];
}
/**
* Diagnostic info about map file accessibility.
*
* @return array{path: string|null, exists: bool, readable: bool, writable: bool, error?: string}
*/
public function getDiagnostics(): array
{
$path = $this->getMapFilePath();
if ($path === null) {
return [
'path' => null,
'exists' => false,
'readable' => false,
'writable' => false,
'error' => 'Map file path is not configured in MageSail → Holesail Tunnel → Configuration (NGINX Configuration group).',
];
}
$exists = \is_file($path);
$readable = $exists && \is_readable($path);
$writable = $exists && \is_writable($path);
$dirWritable = \is_dir(\dirname($path)) && \is_writable(\dirname($path));
$error = null;
if (!$exists && !$dirWritable) {
$error = 'Directory ' . \dirname($path) . ' is not writable.';
} elseif (!$exists) {
$error = 'File does not exist yet (will be created).';
} elseif (!$readable) {
$error = 'File exists but is not readable. Check permissions.';
} elseif (!$writable) {
$error = 'File exists but is not writable. Check permissions.';
}
return [
'path' => $path,
'exists' => $exists,
'readable' => $readable,
'writable' => $writable,
'error' => $error,
];
}
private function getNginxBinary(): string
{
$configured = trim((string) $this->scopeConfig->getValue(self::XML_NGINX_BINARY));
return $configured !== '' ? $configured : 'nginx';
}
/**
* Single pid + events + http { include map } — avoids loading main nginx.conf (duplicate pid, /run/nginx.pid perms).
*/
private function buildMinimalNginxTestConfig(string $pidPath, string $mapIncludePath): string
{
$pidLine = 'pid ' . $this->nginxConfigQuotedPath($pidPath) . ';';
$includeLine = 'include ' . $this->nginxConfigQuotedPath(str_replace('\\', '/', $mapIncludePath)) . ';';
return <<<NGINX
error_log stderr;
{$pidLine}
events {
worker_connections 1;
}
http {
{$includeLine}
}
NGINX;
}
private function nginxConfigQuotedPath(string $path): string
{
$path = str_replace('\\', '/', $path);
if (preg_match('/^[a-zA-Z0-9._\/-]+$/', $path)) {
return $path;
}
return '"' . addcslashes($path, '"\\') . '"';
}
private function getMapSourceVar(): string
{
$v = trim((string) $this->scopeConfig->getValue(self::XML_MAP_SOURCE));
return $v !== '' ? ltrim($v, '$') : 'http_host';
}
private function getMapRunCodeVar(): string
{
$v = trim((string) $this->scopeConfig->getValue(self::XML_MAP_RUN_CODE));
return $v !== '' ? ltrim($v, '$') : 'MAGE_RUN_CODE';
}
private function getMapRunTypeVar(): string
{
$v = trim((string) $this->scopeConfig->getValue(self::XML_MAP_RUN_TYPE));
return $v !== '' ? ltrim($v, '$') : 'MAGE_RUN_TYPE';
}
/**
* Run nginx with argv (binary from config prepended); merges stdout+stderr.
*
* @param string[] $args
* @return array{exitCode: int, output: string}
*/
private function runNginxProcess(array $args): array
{
$binary = escapeshellarg($this->getNginxBinary());
$parts = array_map(static fn (string $a): string => escapeshellarg($a), $args);
$cmd = $binary . ' ' . implode(' ', $parts);
return $this->executeProcOrShellCommand($cmd);
}
/**
* Run an arbitrary executable path with optional args (no nginx binary prefix).
*
* @param string[] $argv
* @return array{exitCode: int, output: string}
*/
private function runExternalExecutable(array $argv): array
{
if ($argv === []) {
return ['exitCode' => 1, 'output' => 'No command'];
}
$cmd = implode(' ', array_map(static fn (string $a): string => escapeshellarg($a), $argv));
return $this->executeProcOrShellCommand($cmd);
}
/**
* @return array{exitCode: int, output: string}
*/
private function executeProcOrShellCommand(string $cmd): array
{
$disabled = explode(',', str_replace(' ', ',', (string) ini_get('disable_functions')));
if (\in_array('proc_open', $disabled, true)) {
return $this->runNginxViaShell($cmd);
}
$descriptorspec = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$process = @proc_open($cmd, $descriptorspec, $pipes, null, null);
if (!\is_resource($process)) {
return $this->runNginxViaShell($cmd);
}
fclose($pipes[0]);
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($process);
if ($exitCode === -1) {
$exitCode = 1;
}
$combined = trim(
($stdout !== '' ? rtrim($stdout) : '')
. ($stderr !== '' ? (($stdout !== '' ? "\n" : '') . rtrim($stderr)) : '')
);
return ['exitCode' => $exitCode, 'output' => $combined];
}
/**
* Fallback when proc_open is unavailable (merges stderr like 2>&1).
*/
private function runNginxViaShell(string $cmd): array
{
try {
$wrapped = '/bin/sh -c ' . escapeshellarg($cmd . ' 2>&1');
$output = $this->shell->execute($wrapped);
return ['exitCode' => 0, 'output' => trim((string) $output)];
} catch (\Throwable $e) {
$prev = $e->getPrevious();
$msg = $prev instanceof \Exception ? $prev->getMessage() : $e->getMessage();
return ['exitCode' => 1, 'output' => $msg];
}
}
private function buildMapLine(string $hostname, string $value): string
{
$indent = $this->detectIndentOrDefault();
return sprintf(
'%s%s %s; %s',
$indent,
$this->escapeNginxMapKey($hostname),
$this->escapeNginxMapValue($value),
self::LINE_COMMENT
);
}
private function detectIndentOrDefault(): string
{
try {
$content = $this->readMapFile();
} catch (LocalizedException) {
return ' ';
}
$src = preg_quote($this->getMapSourceVar(), '/');
$code = preg_quote($this->getMapRunCodeVar(), '/');
if (!preg_match('/map\s+\$' . $src . '\s+\$' . $code . '\s*\{/s', $content, $m, PREG_OFFSET_CAPTURE)) {
return ' ';
}
$openBracePos = $m[0][1] + \strlen($m[0][0]) - 1;
$closePos = $this->findMatchingClosingBrace($content, $openBracePos);
if ($closePos === null) {
return ' ';
}
$inner = substr($content, $openBracePos + 1, $closePos - $openBracePos - 1);
if (preg_match('/^(\s*)default/m', $inner, $im)) {
return $im[1];
}
return ' ';
}
private function escapeNginxMapKey(string $value): string
{
if (preg_match('/^[a-zA-Z0-9._-]+$/', $value)) {
return $value;
}
return '"' . addcslashes($value, '"\\') . '"';
}
private function escapeNginxMapValue(string $value): string
{
if (preg_match('/^[a-zA-Z0-9._-]+$/', $value)) {
return $value;
}
return '"' . addcslashes($value, '"\\') . '"';
}
/**
* Remove lines we added for this hostname (MAGE_RUN_CODE and MAGE_RUN_TYPE).
*/
private function removeTunnelMappingFromContent(string $content, string $hostname): string
{
$hostPat = preg_quote($this->escapeNginxMapKey($hostname), '/');
$comment = preg_quote(self::LINE_COMMENT, '/');
$content = preg_replace(
'/^\s*' . $hostPat . '\s+\S+\s*;\s*' . $comment . '\s*$/m',
'',
$content
) ?? $content;
$content = preg_replace(
'/^\s*' . $hostPat . '\s+\S+\s*;\s*' . $comment . '\s*\}\s*$/m',
'}',
$content
) ?? $content;
return $content;
}
/**
* Insert a line before the closing `}` of map $source $resultVar { ... }
*
* @throws LocalizedException
*/
private function insertIntoNamedMap(string $content, string $resultVar, string $line): string
{
$source = preg_quote($this->getMapSourceVar(), '/');
$result = preg_quote($resultVar, '/');
if (!preg_match('/map\s+\$' . $source . '\s+\$' . $result . '\s*\{/s', $content, $m, PREG_OFFSET_CAPTURE)) {
throw new LocalizedException(
__(
'Could not find map block "map $%1 $%2 { ... }" in the global map file. Add it to your Ansible template (see MageSail docs).',
$this->getMapSourceVar(),
$resultVar
)
);
}
$openBracePos = $m[0][1] + \strlen($m[0][0]) - 1;
$closePos = $this->findMatchingClosingBrace($content, $openBracePos);
if ($closePos === null) {
throw new LocalizedException(__('Unclosed map block for $%1 in global map file.', $resultVar));
}
$inner = substr($content, $openBracePos + 1, $closePos - $openBracePos - 1);
if (str_contains($inner, $line)) {
return $content;
}
$before = substr($content, 0, $closePos);
$after = substr($content, $closePos);
return $before . "\n" . $line . "\n" . $after;
}
private function findMatchingClosingBrace(string $content, int $openBraceIndex): ?int
{
$len = \strlen($content);
$depth = 0;
for ($i = $openBraceIndex; $i < $len; $i++) {
$c = $content[$i];
if ($c === '{') {
$depth++;
} elseif ($c === '}') {
$depth--;
if ($depth === 0) {
return $i;
}
}
}
return null;
}
}