From 49bd7d3e444e1ddbc9488e75016560edb9a8e4ae Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Sat, 21 Dec 2024 13:30:22 +0000 Subject: [PATCH] Refactor tunnels --- src/Command/Tunnel/TunnelCloseCommand.php | 32 +-- src/Command/Tunnel/TunnelCommandBase.php | 237 ---------------- src/Command/Tunnel/TunnelInfoCommand.php | 18 +- src/Command/Tunnel/TunnelListCommand.php | 23 +- src/Command/Tunnel/TunnelOpenCommand.php | 51 ++-- src/Command/Tunnel/TunnelSingleCommand.php | 43 +-- src/Service/TunnelManager.php | 301 +++++++++++++++++++++ src/Tunnel/Tunnel.php | 20 ++ 8 files changed, 384 insertions(+), 341 deletions(-) create mode 100644 src/Service/TunnelManager.php create mode 100644 src/Tunnel/Tunnel.php diff --git a/src/Command/Tunnel/TunnelCloseCommand.php b/src/Command/Tunnel/TunnelCloseCommand.php index e3c9264e2..a369f049b 100644 --- a/src/Command/Tunnel/TunnelCloseCommand.php +++ b/src/Command/Tunnel/TunnelCloseCommand.php @@ -3,6 +3,7 @@ use Platformsh\Cli\Selector\Selector; use Platformsh\Cli\Service\QuestionHelper; +use Platformsh\Cli\Service\TunnelManager; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -11,7 +12,7 @@ #[AsCommand(name: 'tunnel:close', description: 'Close SSH tunnels')] class TunnelCloseCommand extends TunnelCommandBase { - public function __construct(private readonly QuestionHelper $questionHelper, private readonly Selector $selector) + public function __construct(private readonly QuestionHelper $questionHelper, private readonly Selector $selector, private readonly TunnelManager $tunnelManager) { parent::__construct(); } @@ -28,7 +29,7 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - $tunnels = $this->getTunnelInfo(); + $tunnels = $this->tunnelManager->getTunnels(); $allTunnelsCount = count($tunnels); if (!$allTunnelsCount) { $this->stdErr->writeln('No tunnels found.'); @@ -38,7 +39,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Filter tunnels according to the current project and environment, if // available. if (!$input->getOption('all')) { - $tunnels = $this->filterTunnels($tunnels, $input); + $tunnels = $this->tunnelManager->filterBySelection($tunnels, $this->selector->getSelection($input)); if (!count($tunnels)) { $this->stdErr->writeln('No tunnels found. Use --all to close all tunnels.'); return 1; @@ -47,10 +48,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $error = false; foreach ($tunnels as $tunnel) { - $relationshipString = $this->formatTunnelRelationship($tunnel); - $appString = $tunnel['projectId'] . '-' . $tunnel['environmentId']; - if ($tunnel['appName']) { - $appString .= '--' . $tunnel['appName']; + $relationshipString = $this->tunnelManager->formatRelationship($tunnel); + $appString = $tunnel->metadata['projectId'] . '-' . $tunnel->metadata['environmentId']; + if ($tunnel->metadata['appName']) { + $appString .= '--' . $tunnel->metadata['appName']; } $questionText = sprintf( 'Close tunnel to relationship %s on %s?', @@ -58,20 +59,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $appString ); if ($this->questionHelper->confirm($questionText)) { - if ($this->closeTunnel($tunnel)) { - $this->stdErr->writeln(sprintf( - 'Closed tunnel to %s on %s', - $relationshipString, - $appString - )); - } else { - $error = true; - $this->stdErr->writeln(sprintf( - 'Failed to close tunnel to %s on %s', - $relationshipString, - $appString - )); - } + $this->tunnelManager->close($tunnel); } } @@ -79,6 +67,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->stdErr->writeln('Use --all to close all tunnels.'); } - return $error ? 1 : 0; + return 0; } } diff --git a/src/Command/Tunnel/TunnelCommandBase.php b/src/Command/Tunnel/TunnelCommandBase.php index aa6aa3cb5..ad03d9e96 100644 --- a/src/Command/Tunnel/TunnelCommandBase.php +++ b/src/Command/Tunnel/TunnelCommandBase.php @@ -1,246 +1,9 @@ config = $config; - $this->relationships = $relationships; - $this->selector = $selector; - $this->io = $io; - } - - /** - * Checks whether a tunnel is already open. - */ - protected function isTunnelOpen(array $tunnel): false|array - { - foreach ($this->getTunnelInfo() as $info) { - if ($this->tunnelsAreEqual($tunnel, $info)) { - if (isset($info['pid']) && function_exists('posix_kill') && !posix_kill($info['pid'], 0)) { - $this->io->debug(sprintf( - 'The tunnel at port %d is no longer open, removing from list', - $info['localPort'] - )); - $this->closeTunnel($info); - continue; - } - - return $info; - } - } - - return false; - } - - /** - * Gets info on currently open tunnels. - */ - protected function getTunnelInfo(bool $open = true): array - { - if (!isset($this->tunnelInfo)) { - $this->tunnelInfo = []; - // @todo move this to State service (in a new major version) - $filename = $this->config->getWritableUserDir() . '/tunnel-info.json'; - if (file_exists($filename)) { - $this->io->debug(sprintf('Loading tunnel info from %s', $filename)); - $this->tunnelInfo = (array) json_decode(file_get_contents($filename), true); - } - } - - if ($open) { - $needsSave = false; - foreach ($this->tunnelInfo as $key => $tunnel) { - if (isset($tunnel['pid']) && function_exists('posix_kill') && !posix_kill($tunnel['pid'], 0)) { - $this->io->debug(sprintf( - 'The tunnel at port %d is no longer open, removing from list', - $tunnel['localPort'] - )); - unset($this->tunnelInfo[$key]); - $needsSave = true; - } - } - if ($needsSave) { - $this->saveTunnelInfo(); - } - } - - return $this->tunnelInfo; - } - - protected function saveTunnelInfo(): void - { - $filename = $this->config->getWritableUserDir() . '/tunnel-info.json'; - if (!empty($this->tunnelInfo)) { - $this->io->debug('Saving tunnel info to: ' . $filename); - if (!file_put_contents($filename, json_encode($this->tunnelInfo))) { - throw new \RuntimeException('Failed to write tunnel info to: ' . $filename); - } - } else { - unlink($filename); - } - } - - /** - * Close an open tunnel. - * - * @param array $tunnel - * - * @return bool - * True on success, false on failure. - */ - protected function closeTunnel(array $tunnel): bool - { - $success = true; - if (isset($tunnel['pid']) && function_exists('posix_kill')) { - $success = posix_kill($tunnel['pid'], SIGTERM); - if (!$success) { - $this->stdErr->writeln(sprintf( - 'Failed to kill process %d (POSIX error %s)', - $tunnel['pid'], - posix_get_last_error() - )); - } - } - $pidFile = $this->getPidFile($tunnel); - if (file_exists($pidFile)) { - $success = unlink($pidFile) && $success; - } - $this->tunnelInfo = array_filter($this->tunnelInfo, fn($info): bool => !$this->tunnelsAreEqual($info, $tunnel)); - $this->saveTunnelInfo(); - - return $success; - } - - /** - * Automatically determines the best port for a new tunnel. - */ - protected function getPort(int$default = 30000): int - { - $ports = []; - foreach ($this->getTunnelInfo() as $tunnel) { - $ports[] = $tunnel['localPort']; - } - - return PortUtil::getPort($ports ? max($ports) + 1 : $default); - } - - protected function openLog(string $logFile): OutputInterface|false - { - $logResource = fopen($logFile, 'a'); - if ($logResource) { - return new StreamOutput($logResource, OutputInterface::VERBOSITY_VERBOSE); - } - - return false; - } - - private function getTunnelKey(array $tunnel): string - { - return implode('--', [ - $tunnel['projectId'], - $tunnel['environmentId'], - $tunnel['appName'], - $tunnel['relationship'], - $tunnel['serviceKey'], - ]); - } - - protected function getTunnelUrl(array $tunnel, array $service): string - { - $localService = array_merge($service, array_intersect_key([ - 'host' => self::LOCAL_IP, - 'port' => $tunnel['localPort'], - ], $service)); - - return $this->relationships->buildUrl($localService); - } - - private function tunnelsAreEqual(array $tunnel1, array $tunnel2): bool - { - return $this->getTunnelKey($tunnel1) === $this->getTunnelKey($tunnel2); - } - - protected function getPidFile(array $tunnel): string - { - $key = $this->getTunnelKey($tunnel); - $dir = $this->config->getWritableUserDir() . '/.tunnels'; - if (!is_dir($dir) && !mkdir($dir, 0700, true)) { - throw new \RuntimeException('Failed to create directory: ' . $dir); - } - - return $dir . '/' . preg_replace('/[^0-9a-z.]+/', '-', $key) . '.pid'; - } - - protected function createTunnelProcess(string $url, string $remoteHost, int $remotePort, int $localPort, array $extraArgs = []): Process - { - $args = ['ssh', '-n', '-N', '-L', implode(':', [$localPort, $remoteHost, $remotePort]), $url]; - $args = array_merge($args, $extraArgs); - $process = new Process($args); - $process->setTimeout(null); - - return $process; - } - - /** - * Filters a list of tunnels by the currently selected project/environment. - */ - protected function filterTunnels(array $tunnels, InputInterface $input): array - { - if (!$input->getOption('project') && !$this->selector->getProjectRoot()) { - return $tunnels; - } - $selection = $this->selector->getSelection($input, new SelectorConfig(envRequired: false)); - $project = $selection->getProject(); - $environment = $selection->hasEnvironment() ? $selection->getEnvironment() : null; - $appName = $selection->hasEnvironment() ? $selection->getAppName() : null; - foreach ($tunnels as $key => $tunnel) { - if ($tunnel['projectId'] !== $project->id - || ($environment !== null && $tunnel['environmentId'] !== $environment->id) - || ($appName !== null && $tunnel['appName'] !== $appName)) { - unset($tunnels[$key]); - } - } - - return $tunnels; - } - - /** - * Formats a tunnel's relationship as a string. - * - * @param array $tunnel - * - * @return string - */ - protected function formatTunnelRelationship(array $tunnel): string - { - return $tunnel['serviceKey'] > 0 - ? sprintf('%s.%d', $tunnel['relationship'], $tunnel['serviceKey']) - : $tunnel['relationship']; - } } diff --git a/src/Command/Tunnel/TunnelInfoCommand.php b/src/Command/Tunnel/TunnelInfoCommand.php index 69c5f3658..f1f5a2ff2 100644 --- a/src/Command/Tunnel/TunnelInfoCommand.php +++ b/src/Command/Tunnel/TunnelInfoCommand.php @@ -6,6 +6,7 @@ use Platformsh\Cli\Service\Config; use Platformsh\Cli\Service\PropertyFormatter; use Platformsh\Cli\Service\Relationships; +use Platformsh\Cli\Service\TunnelManager; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -14,10 +15,11 @@ #[AsCommand(name: 'tunnel:info', description: "View relationship info for SSH tunnels")] class TunnelInfoCommand extends TunnelCommandBase { - public function __construct(private readonly Config $config, private readonly Io $io, private readonly PropertyFormatter $propertyFormatter, private readonly Relationships $relationships, private readonly Selector $selector) + public function __construct(private readonly Config $config, private readonly Io $io, private readonly PropertyFormatter $propertyFormatter, private readonly Relationships $relationships, private readonly Selector $selector, private readonly TunnelManager $tunnelManager) { parent::__construct(); } + protected function configure(): void { $this @@ -38,21 +40,21 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $this->io->warnAboutDeprecatedOptions(['columns', 'format', 'no-header']); - $tunnels = $this->getTunnelInfo(); + $tunnels = $this->tunnelManager->getTunnels(); $relationships = []; - foreach ($this->filterTunnels($tunnels, $input) as $tunnel) { - $service = $tunnel['service']; + foreach ($this->tunnelManager->filterBySelection($tunnels, $this->selector->getSelection($input)) as $tunnel) { + $service = $tunnel->metadata['service']; // Overwrite the service's address with the local tunnel details. $service = array_merge($service, array_intersect_key([ - 'host' => self::LOCAL_IP, - 'ip' => self::LOCAL_IP, - 'port' => $tunnel['localPort'], + 'host' => TunnelManager::LOCAL_IP, + 'ip' => TunnelManager::LOCAL_IP, + 'port' => $tunnel->localPort, ], $service)); $service['url'] = $this->relationships->buildUrl($service); - $relationships[$tunnel['relationship']][$tunnel['serviceKey']] = $service; + $relationships[$tunnel->metadata['relationship']][$tunnel->metadata['serviceKey']] = $service; } if (!count($relationships)) { $this->stdErr->writeln('No tunnels found.'); diff --git a/src/Command/Tunnel/TunnelListCommand.php b/src/Command/Tunnel/TunnelListCommand.php index 37f365958..f489123c0 100644 --- a/src/Command/Tunnel/TunnelListCommand.php +++ b/src/Command/Tunnel/TunnelListCommand.php @@ -4,6 +4,7 @@ use Platformsh\Cli\Selector\Selector; use Platformsh\Cli\Service\Config; use Platformsh\Cli\Service\Table; +use Platformsh\Cli\Service\TunnelManager; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -21,9 +22,11 @@ class TunnelListCommand extends TunnelCommandBase 'relationship' => 'Relationship', 'url' => 'URL', ]; + /** @var string[] */ protected array $defaultColumns = ['Port', 'Project', 'Environment', 'App', 'Relationship']; - public function __construct(private readonly Config $config, private readonly Selector $selector, private readonly Table $table) + + public function __construct(private readonly Config $config, private readonly Selector $selector, private readonly Table $table, private readonly TunnelManager $tunnelManager) { parent::__construct(); } @@ -41,7 +44,7 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - $tunnels = $this->getTunnelInfo(); + $tunnels = $this->tunnelManager->getTunnels(); $allTunnelsCount = count($tunnels); if (!$allTunnelsCount) { $this->stdErr->writeln('No tunnels found.'); @@ -53,7 +56,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Filter tunnels according to the current project and environment, if // available. if (!$input->getOption('all')) { - $tunnels = $this->filterTunnels($tunnels, $input); + $selection = $this->selector->getSelection($input); + $tunnels = $this->tunnelManager->filterBySelection($tunnels, $selection); if (!count($tunnels)) { $this->stdErr->writeln('No tunnels found.'); $this->stdErr->writeln(sprintf( @@ -64,15 +68,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 1; } } + $rows = []; foreach ($tunnels as $tunnel) { $rows[] = [ - 'port' => $tunnel['localPort'], - 'project' => $tunnel['projectId'], - 'environment' => $tunnel['environmentId'], - 'app' => $tunnel['appName'] ?: '[default]', - 'relationship' => $this->formatTunnelRelationship($tunnel), - 'url' => $this->getTunnelUrl($tunnel, $tunnel['service']), + 'port' => $tunnel->localPort, + 'project' => $tunnel->metadata['projectId'], + 'environment' => $tunnel->metadata['environmentId'], + 'app' => $tunnel->metadata['appName'] ?: '[default]', + 'relationship' => $this->tunnelManager->formatRelationship($tunnel), + 'url' => $this->tunnelManager->getUrl($tunnel), ]; } $this->table->render($rows, $this->tableHeader, $this->defaultColumns); diff --git a/src/Command/Tunnel/TunnelOpenCommand.php b/src/Command/Tunnel/TunnelOpenCommand.php index 018a308f0..64869aadd 100644 --- a/src/Command/Tunnel/TunnelOpenCommand.php +++ b/src/Command/Tunnel/TunnelOpenCommand.php @@ -10,6 +10,7 @@ use Platformsh\Cli\Service\Relationships; use Platformsh\Cli\Service\Ssh; use Platformsh\Cli\Console\ProcessManager; +use Platformsh\Cli\Service\TunnelManager; use Platformsh\Cli\Util\OsUtil; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Event\ConsoleTerminateEvent; @@ -20,7 +21,7 @@ #[AsCommand(name: 'tunnel:open', description: "Open SSH tunnels to an app's relationships")] class TunnelOpenCommand extends TunnelCommandBase { - public function __construct(private readonly Api $api, private readonly Config $config, private readonly Io $io, private readonly QuestionHelper $questionHelper, private readonly Relationships $relationships, private readonly Selector $selector, private readonly Ssh $ssh) + public function __construct(private readonly Api $api, private readonly Config $config, private readonly Io $io, private readonly QuestionHelper $questionHelper, private readonly Relationships $relationships, private readonly Selector $selector, private readonly Ssh $ssh, private readonly TunnelManager $tunnelManager) { parent::__construct(); } @@ -73,11 +74,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); - $project = $selection->getProject(); $environment = $selection->getEnvironment(); $container = $selection->getRemoteContainer(); - $appName = $container->getName(); $sshUrl = $container->getSshUrl(); $host = $this->selector->getHostFromSelection($input, $selection); $relationships = $this->relationships->getRelationships($host); @@ -96,7 +95,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $logFile = $this->config->getWritableUserDir() . '/tunnels.log'; - if (!$log = $this->openLog($logFile)) { + if (!$log = $this->tunnelManager->openLog($logFile)) { $this->stdErr->writeln(sprintf('Failed to open log file for writing: %s', $logFile)); return 1; } @@ -120,39 +119,25 @@ protected function execute(InputInterface $input, OutputInterface $output): int $error = false; $processIds = []; - foreach ($relationships as $relationship => $services) { - foreach ($services as $serviceKey => $service) { - $remoteHost = $service['host']; - $remotePort = $service['port']; - - $localPort = $this->getPort(); - $tunnel = [ - 'projectId' => $project->id, - 'environmentId' => $environment->id, - 'appName' => $appName, - 'relationship' => $relationship, - 'serviceKey' => $serviceKey, - 'remotePort' => $remotePort, - 'remoteHost' => $remoteHost, - 'localPort' => $localPort, - 'service' => $service, - 'pid' => null, - ]; - - $relationshipString = $this->formatTunnelRelationship($tunnel); - - if ($openTunnelInfo = $this->isTunnelOpen($tunnel)) { + foreach ($relationships as $name => $services) { + foreach ($services as $key => $service) { + $service['_relationship_name'] = $name; + $service['_relationship_key'] = $key; + $tunnel = $this->tunnelManager->create($selection, $service); + + $relationshipString = $this->tunnelManager->formatRelationship($tunnel); + + if ($openTunnelInfo = $this->tunnelManager->isOpen($tunnel)) { $this->stdErr->writeln(sprintf( 'A tunnel is already opened to the relationship %s, at: %s', $relationshipString, - $this->getTunnelUrl($openTunnelInfo, $service) + $this->tunnelManager->getUrl($openTunnelInfo) )); continue; } - $process = $this->createTunnelProcess($sshUrl, $remoteHost, $remotePort, $localPort, $sshArgs); - - $pidFile = $this->getPidFile($tunnel); + $process = $this->tunnelManager->createProcess($sshUrl, $tunnel, $sshArgs); + $pidFile = $this->tunnelManager->getPidFilename($tunnel); try { $pid = $processManager->startProcess($process, $pidFile, $log); @@ -180,14 +165,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int } // Save information about the tunnel for use in other commands. - $tunnel['pid'] = $pid; - $this->tunnelInfo[] = $tunnel; - $this->saveTunnelInfo(); + $this->tunnelManager->saveNewTunnel($tunnel, $pid); $this->stdErr->writeln(sprintf( 'SSH tunnel opened to %s at: %s', $relationshipString, - $this->getTunnelUrl($tunnel, $service) + $this->tunnelManager->getUrl($tunnel), )); $processIds[] = $pid; diff --git a/src/Command/Tunnel/TunnelSingleCommand.php b/src/Command/Tunnel/TunnelSingleCommand.php index 7124b748c..2fe619cd3 100644 --- a/src/Command/Tunnel/TunnelSingleCommand.php +++ b/src/Command/Tunnel/TunnelSingleCommand.php @@ -8,6 +8,7 @@ use Platformsh\Cli\Service\Relationships; use Platformsh\Cli\Service\Ssh; use Platformsh\Cli\Console\ProcessManager; +use Platformsh\Cli\Service\TunnelManager; use Platformsh\Cli\Util\PortUtil; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; @@ -17,7 +18,7 @@ #[AsCommand(name: 'tunnel:single', description: 'Open a single SSH tunnel to an app relationship')] class TunnelSingleCommand extends TunnelCommandBase { - public function __construct(private readonly Api $api, private readonly QuestionHelper $questionHelper, private readonly Relationships $relationships, private readonly Selector $selector, private readonly Ssh $ssh) + public function __construct(private readonly Api $api, private readonly QuestionHelper $questionHelper, private readonly Relationships $relationships, private readonly Selector $selector, private readonly Ssh $ssh, private readonly TunnelManager $tunnelManager) { parent::__construct(); } @@ -41,11 +42,9 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); - $project = $selection->getProject(); $environment = $selection->getEnvironment(); $container = $selection->getRemoteContainer(); - $appName = $container->getName(); $sshUrl = $container->getSshUrl(); $host = $this->selector->getHostFromSelection($input, $selection); $relationships = $this->relationships->getRelationships($host); @@ -80,9 +79,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $sshArgs = $this->ssh->getSshArgs($sshUrl, $sshOptions); - $remoteHost = $service['host']; - $remotePort = $service['port']; - if ($localPort = $input->getOption('port')) { if (!PortUtil::validatePort($localPort)) { $this->stdErr->writeln(sprintf('Invalid port: %s', $localPort)); @@ -94,39 +90,26 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 1; } - } else { - $localPort = $this->getPort(); } - $tunnel = [ - 'projectId' => $project->id, - 'environmentId' => $environment->id, - 'appName' => $appName, - 'relationship' => $service['_relationship_name'], - 'serviceKey' => $service['_relationship_key'], - 'remotePort' => $remotePort, - 'remoteHost' => $remoteHost, - 'localPort' => $localPort, - 'service' => $service, - 'pid' => null, - ]; - - $relationshipString = $this->formatTunnelRelationship($tunnel); - - if ($openTunnelInfo = $this->isTunnelOpen($tunnel)) { + $tunnel = $this->tunnelManager->create($selection, $service, $localPort); + + $relationshipString = $this->tunnelManager->formatRelationship($tunnel); + + if ($openTunnelInfo = $this->tunnelManager->isOpen($tunnel)) { $this->stdErr->writeln(sprintf( 'A tunnel is already opened to the relationship %s, at: %s', $relationshipString, - $this->getTunnelUrl($openTunnelInfo, $service) + $this->tunnelManager->getUrl($openTunnelInfo) )); return 1; } - $pidFile = $this->getPidFile($tunnel); + $pidFile = $this->tunnelManager->getPidFilename($tunnel); $processManager = new ProcessManager(); - $process = $this->createTunnelProcess($sshUrl, $remoteHost, $remotePort, $localPort, $sshArgs); + $process = $this->tunnelManager->createProcess($sshUrl, $tunnel, $sshArgs); $pid = $processManager->startProcess($process, $pidFile, $output); // Wait a very small time to capture any immediate errors. @@ -142,9 +125,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 1; } - $tunnel['pid'] = $pid; - $this->tunnelInfo[] = $tunnel; - $this->saveTunnelInfo(); + $this->tunnelManager->saveNewTunnel($tunnel, $pid); if ($output->isVerbose()) { // Just an extra line for separation from the process manager's log. @@ -154,7 +135,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->stdErr->writeln(sprintf( 'SSH tunnel opened to %s at: %s', $relationshipString, - $this->getTunnelUrl($tunnel, $service) + $this->tunnelManager->getUrl($tunnel), )); $this->stdErr->writeln(''); diff --git a/src/Service/TunnelManager.php b/src/Service/TunnelManager.php new file mode 100644 index 000000000..6d5ab8429 --- /dev/null +++ b/src/Service/TunnelManager.php @@ -0,0 +1,301 @@ + $service + * + * @throws \Exception + */ + public function create(Selection $selection, array $service, ?int $localPort = null): Tunnel + { + $metadata = [ + 'projectId' => $selection->getProject()->id, + 'environmentId' => $selection->getEnvironment()->id, + 'appName' => $selection->getAppName(), + 'relationship' => $service['_relationship_name'], + 'serviceKey' => $service['_relationship_key'], + 'service' => $service, + ]; + + return new Tunnel($this->getId($metadata), $localPort ?: $this->getPort(), $service['host'], $service['port'], $metadata); + } + + /** + * Automatically determines the best port for a new tunnel. + * @throws \Exception + */ + protected function getPort(int $default = 30000): int + { + $ports = []; + foreach ($this->getTunnels() as $tunnel) { + $ports[] = $tunnel->localPort; + } + + return PortUtil::getPort($ports ? max($ports) + 1 : $default); + } + + /** + * Gets info on currently open tunnels. + * + * @return Tunnel[] + */ + public function getTunnels(bool $open = true): array + { + if (!isset($this->tunnels)) { + $this->tunnels = []; + // @todo move this to State service (in a new major version) + $filename = $this->config->getWritableUserDir() . '/tunnel-info.json'; + if (file_exists($filename)) { + $this->io->debug(sprintf('Loading tunnel info from %s', $filename)); + $this->tunnels = $this->unserialize((string) file_get_contents($filename)); + } + } + + if ($open) { + $needsSave = false; + foreach ($this->tunnels as $key => $tunnel) { + if ($tunnel->pid === null) { + $this->io->debug(sprintf( + 'No PID found for the tunnel at port %d; removing from list', + $tunnel->localPort, + )); + unset($this->tunnels[$key]); + $needsSave = true; + } elseif (function_exists('posix_kill') && !posix_kill($tunnel->pid, 0)) { + $this->io->debug(sprintf( + 'The tunnel at port %d is no longer open, removing from list', + $tunnel->localPort, + )); + unset($this->tunnels[$key]); + $needsSave = true; + } + } + if ($needsSave) { + $this->saveTunnelInfo(); + } + } + + return $this->tunnels; + } + + public function saveNewTunnel(Tunnel $tunnel, int $pid): void + { + $tunnel->pid = $pid; + $this->tunnels[] = $tunnel; + $this->saveTunnelInfo(); + } + + private function saveTunnelInfo(): void + { + $filename = $this->config->getWritableUserDir() . '/tunnel-info.json'; + if (!empty($this->tunnels)) { + $this->io->debug('Saving tunnel info to: ' . $filename); + if (!file_put_contents($filename, $this->serialize($this->tunnels))) { + throw new \RuntimeException('Failed to write tunnel info to: ' . $filename); + } + } else { + unlink($filename); + } + } + + /** + * Checks whether a tunnel is already open. + * + * @return false|Tunnel + * If the tunnel is open, a new Tunnel object is returned with its PID + * set. + */ + public function isOpen(Tunnel $tunnel): false|Tunnel + { + foreach ($this->tunnels as $t) { + if ($t->id === $tunnel->id) { + if ($t->pid && function_exists('posix_kill') && !posix_kill($t->pid, 0)) { + $this->io->debug(sprintf( + 'The tunnel at port %d is no longer open, removing from list', + $t->localPort + )); + $this->close($t); + return false; + } + $tunnel->pid = $t->pid; + + return $tunnel; + } + } + + return false; + } + + /** + * @param Tunnel[] $tunnels + * @throws \JsonException + */ + private function serialize(array $tunnels): string + { + $data = []; + foreach ($tunnels as $tunnel) { + $data[$tunnel->id] = $tunnel->metadata + [ + 'id' => $tunnel->id, + 'localPort' => $tunnel->localPort, + 'remoteHost' => $tunnel->remoteHost, + 'remotePort' => $tunnel->remotePort, + 'pid' => $tunnel->pid, + ]; + } + + return \json_encode($data, JSON_THROW_ON_ERROR); + } + + /** + * @return Tunnel[] + */ + private function unserialize(string $jsonData): array + { + $tunnels = []; + $data = (array) json_decode($jsonData, true); + foreach ($data as $item) { + $metadata = $item; + unset($metadata['id'], $metadata['localPort'], $metadata['remoteHost'], $metadata['remotePort'], $metadata['pid']); + $tunnels[] = new Tunnel($item['id'], $item['localPort'], $item['remoteHost'], $item['remotePort'], $metadata, $item['pid']); + } + return $tunnels; + } + + /** + * Closes an open tunnel. + */ + public function close(Tunnel $tunnel): void + { + if ($tunnel->pid !== null && function_exists('posix_kill')) { + if (!posix_kill($tunnel->pid, SIGTERM)) { + throw new \RuntimeException(sprintf( + 'Failed to kill process %d (POSIX error: %s)', + $tunnel->pid, + posix_get_last_error() + )); + } + } + $pidFile = $this->getPidFilename($tunnel); + if (file_exists($pidFile) && !unlink($pidFile)) { + throw new \RuntimeException(sprintf( + 'Failed to delete file: %s', + $pidFile + )); + } + } + + public function getPidFilename(Tunnel $tunnel): string + { + $dir = $this->config->getWritableUserDir() . '/.tunnels'; + if (!is_dir($dir) && !mkdir($dir, 0700, true)) { + throw new \RuntimeException('Failed to create directory: ' . $dir); + } + + return $dir . '/' . preg_replace('/[^0-9a-z.]+/', '-', $tunnel->id) . '.pid'; + } + + public function createProcess(string $url, Tunnel $tunnel, array $extraArgs = []): Process + { + $args = ['ssh', '-n', '-N', '-L', implode(':', [$tunnel->localPort, $tunnel->remoteHost, $tunnel->remotePort]), $url]; + $args = array_merge($args, $extraArgs); + $process = new Process($args); + $process->setTimeout(null); + + return $process; + } + + /** + * Filters a list of tunnels by the currently selected project/environment. + * + * @param Tunnel[] $tunnels + * + * @return Tunnel[] + */ + public function filterBySelection(array $tunnels, Selection $selection): array + { + if (!$selection->hasProject()) { + return $tunnels; + } + $project = $selection->getProject(); + $environment = $selection->hasEnvironment() ? $selection->getEnvironment() : null; + $appName = $selection->hasEnvironment() ? $selection->getAppName() : null; + foreach ($tunnels as $key => $tunnel) { + $metadata = $tunnel->metadata; + if ($metadata['projectId'] !== $project->id + || ($environment !== null && $metadata['environmentId'] !== $environment->id) + || ($appName !== null && $metadata['appName'] !== $appName)) { + unset($tunnels[$key]); + } + } + + return $tunnels; + } + + public function getUrl(Tunnel $tunnel): string + { + $localService = array_merge($tunnel->metadata['service'], array_intersect_key([ + 'host' => self::LOCAL_IP, + 'port' => $tunnel->localPort, + ], $tunnel->metadata['service'])); + + return $this->relationships->buildUrl($localService); + } + + /** + * Formats a tunnel's relationship as a string. + */ + public function formatRelationship(Tunnel $tunnel): string + { + $metadata = $tunnel->metadata; + + return $metadata['serviceKey'] > 0 + ? sprintf('%s.%d', $metadata['relationship'], $metadata['serviceKey']) + : $metadata['relationship']; + } + + public function openLog(string $logFile): OutputInterface|false + { + $logResource = fopen($logFile, 'a'); + if ($logResource) { + return new StreamOutput($logResource, OutputInterface::VERBOSITY_VERBOSE); + } + + return false; + } +} + diff --git a/src/Tunnel/Tunnel.php b/src/Tunnel/Tunnel.php new file mode 100644 index 000000000..260e2d2e4 --- /dev/null +++ b/src/Tunnel/Tunnel.php @@ -0,0 +1,20 @@ + $metadata + */ + public function __construct( + public readonly string $id, + public readonly int $localPort, + public readonly string $remoteHost, + public readonly int $remotePort, + public readonly array $metadata, + public ?int $pid = null) + { + } +}