CI / php (push) Successful in 1m39s
the correct store behind a proxy. TunnelStoreProvisioner now receives ResourceConnection and copies default web CMS pages/blocks and theme-related setup for new tunnel stores. Tighten system.xml (remove invalid default), crontab/config defaults, and update tests with a ResourceConnection stub for PHPUnit.
549 lines
20 KiB
PHP
549 lines
20 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace MageSail\Magesail\Model;
|
|
|
|
use Magento\Framework\App\Cache\TypeListInterface;
|
|
use Magento\Framework\App\Config\ReinitableConfigInterface;
|
|
use Magento\Framework\App\Config\ScopeConfigInterface;
|
|
use Magento\Framework\App\Config\Storage\WriterInterface;
|
|
use Magento\Framework\App\ResourceConnection;
|
|
use Magento\Framework\Exception\LocalizedException;
|
|
use Magento\Framework\Exception\NoSuchEntityException;
|
|
use Magento\Store\Api\StoreRepositoryInterface;
|
|
use Magento\Store\Model\ResourceModel\Store as StoreResource;
|
|
use Magento\Store\Model\ScopeInterface;
|
|
use Magento\Store\Model\StoreFactory;
|
|
use Magento\Store\Model\StoreManagerInterface;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
/**
|
|
* Creates dedicated store views per tunnel with base URLs; tears down on explicit delete or stale process cleanup.
|
|
*/
|
|
class TunnelStoreProvisioner
|
|
{
|
|
/** @deprecated Legacy single-tunnel store code; migrated installs may still use this code. */
|
|
public const STORE_CODE = 'magesail_tunnel';
|
|
|
|
private const PATH_UNSECURE_BASE = 'web/unsecure/base_url';
|
|
private const PATH_SECURE_BASE = 'web/secure/base_url';
|
|
private const PATH_USE_IN_FRONTEND = 'web/secure/use_in_frontend';
|
|
private const PATH_USE_IN_ADMIN = 'web/secure/use_in_adminhtml';
|
|
|
|
/** @see \Magento\Cms\Helper\Page */
|
|
private const PATH_CMS_HOME = 'web/default/cms_home_page';
|
|
|
|
private const PATH_CMS_NO_ROUTE = 'web/default/cms_no_route';
|
|
|
|
private const PATH_CMS_NO_COOKIES = 'web/default/cms_no_cookies';
|
|
|
|
private const PATH_WEB_FRONT = 'web/default/front';
|
|
|
|
private const PATH_THEME_ID = 'design/theme/theme_id';
|
|
|
|
/** Paths copied from the website default store view so tunnel storefronts match the main demo/home (CMS + widgets). */
|
|
private const WEB_DEFAULT_PATHS = [
|
|
self::PATH_CMS_HOME,
|
|
self::PATH_CMS_NO_ROUTE,
|
|
self::PATH_CMS_NO_COOKIES,
|
|
self::PATH_WEB_FRONT,
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly StoreManagerInterface $storeManager,
|
|
private readonly StoreFactory $storeFactory,
|
|
private readonly StoreResource $storeResource,
|
|
private readonly StoreRepositoryInterface $storeRepository,
|
|
private readonly WriterInterface $configWriter,
|
|
private readonly TypeListInterface $typeList,
|
|
private readonly ReinitableConfigInterface $reinitableConfig,
|
|
private readonly TunnelRegistry $tunnelRegistry,
|
|
private readonly LoggerInterface $logger,
|
|
private readonly NginxGlobalMapManager $nginxMapManager,
|
|
private readonly ScopeConfigInterface $scopeConfig,
|
|
private readonly TunnelProcessPaths $tunnelProcessPaths,
|
|
private readonly ResourceConnection $resource
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @return array{tunnel_id: string, store_id: int, store_code: string, nginx_reloaded: bool}
|
|
* @throws LocalizedException
|
|
*/
|
|
public function provisionNewTunnel(
|
|
string $label,
|
|
int $websiteId,
|
|
int $groupId,
|
|
string $unsecureBase,
|
|
string $secureBase,
|
|
bool $useSecureUrls,
|
|
int $localPort,
|
|
bool $secureHolesail
|
|
): array {
|
|
$this->assertWebsiteAndStoreGroup($websiteId, $groupId);
|
|
|
|
$unsecureBase = $this->normalizeBaseUrl($this->expandBareHostToUrl(trim($unsecureBase), 'http'));
|
|
$secureTrimmed = trim($secureBase);
|
|
if ($secureTrimmed !== '') {
|
|
$secureResolved = $this->expandBareHostToUrl($secureTrimmed, 'https');
|
|
} else {
|
|
$secureResolved = $this->deriveSecureFromUnsecure($unsecureBase);
|
|
}
|
|
$secureBase = $this->normalizeBaseUrl($secureResolved);
|
|
$this->assertValidUrl($unsecureBase);
|
|
$this->assertValidUrl($secureBase);
|
|
|
|
$this->assertLocalPortInRange($localPort);
|
|
$this->assertHostnameUniqueAmongTunnels($unsecureBase, $secureBase, null);
|
|
|
|
$tunnelId = $this->generateTunnelId();
|
|
$storeCode = $this->generateUniqueStoreCode();
|
|
|
|
$store = $this->createStoreWithCode($storeCode, $label, $websiteId, $groupId);
|
|
$storeId = (int) $store->getId();
|
|
|
|
$this->writeStoreBaseConfig($storeId, $unsecureBase, $secureBase, $useSecureUrls);
|
|
$this->copyStorefrontDefaultsFromWebsiteDefault($websiteId, $storeId);
|
|
|
|
$nginxReloaded = $this->applyNginxMap($unsecureBase, $storeCode);
|
|
|
|
$tunnel = [
|
|
'id' => $tunnelId,
|
|
'label' => $label,
|
|
'website_id' => $websiteId,
|
|
'group_id' => $groupId,
|
|
'store_id' => $storeId,
|
|
'store_code' => $storeCode,
|
|
'unsecure_base_url' => $unsecureBase,
|
|
'secure_base_url' => $secureBase,
|
|
'use_secure_urls' => $useSecureUrls,
|
|
'local_port' => $localPort,
|
|
'secure' => $secureHolesail,
|
|
'nginx_pending_reload' => !$nginxReloaded,
|
|
];
|
|
$this->tunnelRegistry->saveTunnel($tunnel);
|
|
|
|
$this->storeRepository->clean();
|
|
$this->storeManager->reinitStores();
|
|
$this->flushConfigCache();
|
|
|
|
return [
|
|
'tunnel_id' => $tunnelId,
|
|
'store_id' => $storeId,
|
|
'store_code' => $storeCode,
|
|
'nginx_reloaded' => $nginxReloaded,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Service-only tunnel: TCP forward to env.php endpoint; no store, no NGINX map.
|
|
*
|
|
* @return array{tunnel_id: string}
|
|
* @throws LocalizedException
|
|
*/
|
|
public function provisionServiceTunnel(
|
|
string $label,
|
|
string $serviceKey,
|
|
string $serviceLabel,
|
|
string $targetHost,
|
|
int $targetPort,
|
|
bool $secureHolesail
|
|
): array {
|
|
$this->assertLocalPortInRange($targetPort);
|
|
$targetHost = trim($targetHost);
|
|
if ($targetHost === '') {
|
|
throw new LocalizedException(__('Service target host is missing.'));
|
|
}
|
|
$tunnelId = $this->generateTunnelId();
|
|
$tunnel = [
|
|
'id' => $tunnelId,
|
|
'tunnel_type' => 'service',
|
|
'label' => $label,
|
|
'service_key' => $serviceKey,
|
|
'service_label' => $serviceLabel,
|
|
'service_target_host' => $targetHost,
|
|
'website_id' => 0,
|
|
'group_id' => 0,
|
|
'store_id' => 0,
|
|
'store_code' => '',
|
|
'unsecure_base_url' => '',
|
|
'secure_base_url' => '',
|
|
'use_secure_urls' => false,
|
|
'local_port' => $targetPort,
|
|
'secure' => $secureHolesail,
|
|
'nginx_pending_reload' => false,
|
|
];
|
|
$this->tunnelRegistry->saveTunnel($tunnel);
|
|
$this->flushConfigCache();
|
|
|
|
return ['tunnel_id' => $tunnelId];
|
|
}
|
|
|
|
/**
|
|
* Full removal: NGINX map, store view (when safe), registry row, pid/key files.
|
|
*/
|
|
public function teardownTunnel(string $tunnelId): void
|
|
{
|
|
$tunnel = $this->tunnelRegistry->getById($tunnelId);
|
|
if ($tunnel === null) {
|
|
$this->flushConfigCache();
|
|
return;
|
|
}
|
|
if (($tunnel['tunnel_type'] ?? 'website') === 'service') {
|
|
$this->tunnelRegistry->deleteTunnel($tunnelId);
|
|
$this->unlinkProcessFiles($tunnelId);
|
|
$this->flushConfigCache();
|
|
return;
|
|
}
|
|
|
|
$store = $this->storeFactory->create();
|
|
$this->storeResource->load($store, $tunnel['store_id']);
|
|
if (!$store->getId()) {
|
|
$this->tryRemoveNginxMapping($tunnel['unsecure_base_url']);
|
|
$this->tunnelRegistry->deleteTunnel($tunnelId);
|
|
$this->unlinkProcessFiles($tunnelId);
|
|
$this->flushConfigCache();
|
|
return;
|
|
}
|
|
try {
|
|
$website = $this->storeManager->getWebsite((int) $store->getWebsiteId());
|
|
} catch (LocalizedException $e) {
|
|
$this->logger->warning('MageSail tunnel store teardown: ' . $e->getMessage());
|
|
$this->tryRemoveNginxMapping($tunnel['unsecure_base_url']);
|
|
$this->tunnelRegistry->deleteTunnel($tunnelId);
|
|
$this->unlinkProcessFiles($tunnelId);
|
|
$this->flushConfigCache();
|
|
return;
|
|
}
|
|
$defaultStoreId = (int) $website->getDefaultStoreId();
|
|
if ((int) $store->getId() === $defaultStoreId) {
|
|
$this->logger->warning(
|
|
'MageSail: tunnel store is the website default; skipping delete. Change default store view in Admin.'
|
|
);
|
|
$this->tryRemoveNginxMapping($tunnel['unsecure_base_url']);
|
|
$this->tunnelRegistry->deleteTunnel($tunnelId);
|
|
$this->unlinkProcessFiles($tunnelId);
|
|
$this->flushConfigCache();
|
|
return;
|
|
}
|
|
$this->tryRemoveNginxMapping($tunnel['unsecure_base_url']);
|
|
|
|
try {
|
|
$this->storeResource->delete($store);
|
|
$this->storeRepository->clean();
|
|
$this->storeManager->reinitStores();
|
|
} catch (\Throwable $e) {
|
|
$this->logger->error('MageSail tunnel store delete failed: ' . $e->getMessage());
|
|
}
|
|
$this->tunnelRegistry->deleteTunnel($tunnelId);
|
|
$this->unlinkProcessFiles($tunnelId);
|
|
$this->flushConfigCache();
|
|
}
|
|
|
|
/**
|
|
* After stale PID cleanup when auto-restart is disabled — remove store for that tunnel only.
|
|
*/
|
|
public function teardownAfterStaleProcess(string $tunnelId): void
|
|
{
|
|
$this->teardownTunnel($tunnelId);
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use tunnel registry list
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public function getPersistedState(): ?array
|
|
{
|
|
$list = $this->tunnelRegistry->listTunnels();
|
|
if (\count($list) === 0) {
|
|
return null;
|
|
}
|
|
return $list[0];
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public function listPersistedTunnels(): array
|
|
{
|
|
return $this->tunnelRegistry->listTunnels();
|
|
}
|
|
|
|
public function clearNginxPendingReloadFlag(): void
|
|
{
|
|
$this->tunnelRegistry->clearAllNginxPendingReloadFlags();
|
|
}
|
|
|
|
/**
|
|
* @throws LocalizedException
|
|
*/
|
|
private function assertWebsiteAndStoreGroup(int $websiteId, int $groupId): void
|
|
{
|
|
try {
|
|
$website = $this->storeManager->getWebsite($websiteId);
|
|
} catch (LocalizedException $e) {
|
|
throw new LocalizedException(__('Invalid website: %1', $e->getMessage()));
|
|
}
|
|
$found = false;
|
|
foreach ($website->getGroups() as $group) {
|
|
if ((int) $group->getId() === $groupId) {
|
|
$found = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!$found) {
|
|
throw new LocalizedException(__('Store group does not belong to the selected website.'));
|
|
}
|
|
}
|
|
|
|
private function assertLocalPortInRange(int $port): void
|
|
{
|
|
if ($port < 1 || $port > 65535) {
|
|
throw new LocalizedException(__('Local port must be between 1 and 65535.'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hostnames must remain unique per tunnel so NGINX map and routing stay unambiguous.
|
|
*
|
|
* @throws LocalizedException
|
|
*/
|
|
private function assertHostnameUniqueAmongTunnels(
|
|
string $unsecureBase,
|
|
string $secureBase,
|
|
?string $exceptTunnelId
|
|
): void {
|
|
$hosts = [];
|
|
foreach ([$unsecureBase, $secureBase] as $url) {
|
|
$h = TunnelRegistry::hostFromBaseUrl($url);
|
|
if ($h !== null && $h !== '') {
|
|
$hosts[strtolower($h)] = true;
|
|
}
|
|
}
|
|
foreach ($this->tunnelRegistry->listTunnels() as $t) {
|
|
if ($exceptTunnelId !== null && ($t['id'] ?? '') === $exceptTunnelId) {
|
|
continue;
|
|
}
|
|
if (($t['tunnel_type'] ?? 'website') === 'service') {
|
|
continue;
|
|
}
|
|
foreach ([$t['unsecure_base_url'] ?? '', $t['secure_base_url'] ?? ''] as $existingUrl) {
|
|
if ($existingUrl === '') {
|
|
continue;
|
|
}
|
|
$eh = TunnelRegistry::hostFromBaseUrl((string) $existingUrl);
|
|
if ($eh === null) {
|
|
continue;
|
|
}
|
|
if (isset($hosts[strtolower($eh)])) {
|
|
throw new LocalizedException(
|
|
__('Another tunnel already uses hostname %1. Choose a different host.', $eh)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private function generateTunnelId(): string
|
|
{
|
|
return bin2hex(random_bytes(8));
|
|
}
|
|
|
|
/**
|
|
* @throws LocalizedException
|
|
*/
|
|
private function generateUniqueStoreCode(): string
|
|
{
|
|
for ($i = 0; $i < 32; $i++) {
|
|
$code = 'mgtun_' . bin2hex(random_bytes(4));
|
|
if (!$this->storeCodeExists($code)) {
|
|
return $code;
|
|
}
|
|
}
|
|
throw new LocalizedException(__('Could not generate a unique store code.'));
|
|
}
|
|
|
|
private function storeCodeExists(string $code): bool
|
|
{
|
|
try {
|
|
$this->storeRepository->get($code);
|
|
return true;
|
|
} catch (NoSuchEntityException) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private function createStoreWithCode(string $storeCode, string $label, int $websiteId, int $groupId): \Magento\Store\Api\Data\StoreInterface
|
|
{
|
|
$name = $label !== ''
|
|
? (string) __('MageSail: %1', $label)
|
|
: (string) __('MageSail Tunnel');
|
|
$store = $this->storeFactory->create();
|
|
$store->setCode($storeCode);
|
|
$store->setName($name);
|
|
$store->setWebsiteId($websiteId);
|
|
$store->setStoreGroupId($groupId);
|
|
$store->setIsActive(true);
|
|
$this->storeResource->save($store);
|
|
return $store;
|
|
}
|
|
|
|
private function writeStoreBaseConfig(int $storeId, string $unsecureBase, string $secureBase, bool $useSecureUrls): void
|
|
{
|
|
$this->configWriter->save(self::PATH_UNSECURE_BASE, $unsecureBase, ScopeInterface::SCOPE_STORES, $storeId);
|
|
$this->configWriter->save(self::PATH_SECURE_BASE, $secureBase, ScopeInterface::SCOPE_STORES, $storeId);
|
|
$useSecure = $useSecureUrls ? '1' : '0';
|
|
$this->configWriter->save(self::PATH_USE_IN_FRONTEND, $useSecure, ScopeInterface::SCOPE_STORES, $storeId);
|
|
$this->configWriter->save(self::PATH_USE_IN_ADMIN, $useSecure, ScopeInterface::SCOPE_STORES, $storeId);
|
|
|
|
$disableRedirect = (bool) $this->scopeConfig->getValue('magesail/nginx/disable_base_url_redirect');
|
|
if ($disableRedirect) {
|
|
$this->configWriter->save('web/url/redirect_to_base', '0', ScopeInterface::SCOPE_STORES, $storeId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Align tunnel store view with the website's default store: CMS home/front + theme (Luma widgets) + cms_*_store rows.
|
|
*/
|
|
private function copyStorefrontDefaultsFromWebsiteDefault(int $websiteId, int $newStoreId): void
|
|
{
|
|
try {
|
|
$website = $this->storeManager->getWebsite($websiteId);
|
|
$defaultStore = $website->getDefaultStore();
|
|
if ($defaultStore === null || (int) $defaultStore->getId() === $newStoreId) {
|
|
return;
|
|
}
|
|
$fromStoreId = (int) $defaultStore->getId();
|
|
$fromStoreCode = $defaultStore->getCode();
|
|
foreach (self::WEB_DEFAULT_PATHS as $path) {
|
|
$value = $this->scopeConfig->getValue($path, ScopeInterface::SCOPE_STORES, $fromStoreCode);
|
|
if ($value !== null && $value !== '') {
|
|
$this->configWriter->save($path, (string) $value, ScopeInterface::SCOPE_STORES, $newStoreId);
|
|
}
|
|
}
|
|
$theme = $this->scopeConfig->getValue(self::PATH_THEME_ID, ScopeInterface::SCOPE_STORES, $fromStoreCode);
|
|
if ($theme !== null && $theme !== '') {
|
|
$this->configWriter->save(self::PATH_THEME_ID, (string) $theme, ScopeInterface::SCOPE_STORES, $newStoreId);
|
|
}
|
|
$this->mirrorCmsEntityStoreAssignments($fromStoreId, $newStoreId);
|
|
} catch (\Throwable $e) {
|
|
$this->logger->warning('MageSail: could not copy storefront defaults to tunnel store: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
private function mirrorCmsEntityStoreAssignments(int $fromStoreId, int $newStoreId): void
|
|
{
|
|
$conn = $this->resource->getConnection();
|
|
foreach (['cms_page_store' => 'page_id', 'cms_block_store' => 'block_id'] as $table => $idColumn) {
|
|
$t = $this->resource->getTableName($table);
|
|
$sql = "INSERT IGNORE INTO {$t} ({$idColumn}, store_id) "
|
|
. "SELECT DISTINCT {$idColumn}, :new_store FROM {$t} WHERE store_id IN (0, :from_store)";
|
|
$conn->query($sql, ['new_store' => $newStoreId, 'from_store' => $fromStoreId]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return bool True if nginx reported reloaded
|
|
*/
|
|
private function applyNginxMap(string $unsecureBase, string $storeCode): bool
|
|
{
|
|
$nginxReloaded = true;
|
|
try {
|
|
$hostname = $this->nginxMapManager->extractHostname($unsecureBase);
|
|
$this->nginxMapManager->addTunnelMapping($hostname, $storeCode);
|
|
$validation = $this->nginxMapManager->validateAndReload();
|
|
if (!$validation['valid']) {
|
|
$msg = 'NGINX config validation failed after tunnel map update: ' . ($validation['error'] ?? 'unknown');
|
|
$this->logger->error($msg);
|
|
throw new LocalizedException(__('Tunnel store was not finalized: NGINX map validation failed: %1', $validation['error'] ?? 'unknown'));
|
|
}
|
|
$nginxReloaded = !empty($validation['reloaded']);
|
|
if (!$nginxReloaded) {
|
|
$this->logger->warning(
|
|
'NGINX map updated for tunnel but reload failed (reload nginx manually): '
|
|
. ($validation['error'] ?? 'unknown')
|
|
);
|
|
}
|
|
} catch (LocalizedException $e) {
|
|
$this->logger->error('NGINX global map update failed: ' . $e->getMessage());
|
|
throw $e;
|
|
}
|
|
return $nginxReloaded;
|
|
}
|
|
|
|
private function tryRemoveNginxMapping(string $unsecureBaseUrl): void
|
|
{
|
|
try {
|
|
$hostname = $this->nginxMapManager->extractHostname($unsecureBaseUrl);
|
|
$this->nginxMapManager->removeTunnelMapping($hostname);
|
|
$validation = $this->nginxMapManager->validateAndReload();
|
|
if (!$validation['valid']) {
|
|
$this->logger->warning('NGINX config validation failed after tunnel map removal: ' . ($validation['error'] ?? 'unknown'));
|
|
} elseif (!$validation['reloaded']) {
|
|
$this->logger->warning('NGINX config valid but reload failed after map removal: ' . ($validation['error'] ?? 'unknown'));
|
|
}
|
|
} catch (LocalizedException $e) {
|
|
$this->logger->warning('NGINX global map removal skipped: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
private function unlinkProcessFiles(string $tunnelId): void
|
|
{
|
|
$pid = $this->tunnelProcessPaths->getPidFilePath($tunnelId);
|
|
$key = $this->tunnelProcessPaths->getKeyFilePath($tunnelId);
|
|
if (\is_file($pid)) {
|
|
@unlink($pid);
|
|
}
|
|
if (\is_file($key)) {
|
|
@unlink($key);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Accept full URLs or hostname-only (e.g. dev.myshop.local or dev.myshop.local:8080).
|
|
* When no http/https scheme is present, prepends {@see $defaultScheme}://
|
|
*/
|
|
private function expandBareHostToUrl(string $input, string $defaultScheme): string
|
|
{
|
|
if ($input === '') {
|
|
return '';
|
|
}
|
|
if (preg_match('#^https?://#i', $input) === 1) {
|
|
return $input;
|
|
}
|
|
return $defaultScheme . '://' . ltrim($input, '/');
|
|
}
|
|
|
|
private function normalizeBaseUrl(string $url): string
|
|
{
|
|
return rtrim(trim($url), '/') . '/';
|
|
}
|
|
|
|
private function deriveSecureFromUnsecure(string $unsecureBase): string
|
|
{
|
|
if (stripos($unsecureBase, 'http://') === 0) {
|
|
return 'https://' . substr($unsecureBase, \strlen('http://'));
|
|
}
|
|
return $unsecureBase;
|
|
}
|
|
|
|
private function assertValidUrl(string $url): void
|
|
{
|
|
if (filter_var($url, FILTER_VALIDATE_URL) === false) {
|
|
throw new LocalizedException(__('Invalid base URL: %1', $url));
|
|
}
|
|
$scheme = parse_url($url, PHP_URL_SCHEME);
|
|
if (!\in_array($scheme, ['http', 'https'], true)) {
|
|
throw new LocalizedException(__('Base URL must use http or https: %1', $url));
|
|
}
|
|
}
|
|
|
|
private function flushConfigCache(): void
|
|
{
|
|
$this->typeList->cleanType('config');
|
|
$this->typeList->cleanType('full_page');
|
|
$this->reinitableConfig->reinit();
|
|
}
|
|
}
|