Deploy Ladill Hosting / deploy (push) Failing after 17s
Shared web hosting extracted from the platform monolith, with CI deploy to /var/www/ladill-hosting matching Bird/Domains/Email. Co-authored-by: Cursor <cursoragent@cursor.com>
218 lines
8.4 KiB
PHP
218 lines
8.4 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\HostingAccount;
|
|
use App\Models\HostingNode;
|
|
use App\Services\Hosting\BrowserTerminalSessionManager;
|
|
use App\Services\Hosting\Providers\SharedNodeProvider;
|
|
use App\Services\Hosting\Terminal\LocalPtyShellSession;
|
|
use App\Services\Hosting\Terminal\RemoteSshShellSession;
|
|
use App\Services\Hosting\Terminal\ShellSession;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class RunHostingTerminalWorkerCommand extends Command
|
|
{
|
|
protected $signature = 'hosting:terminal-worker {session}';
|
|
|
|
protected $description = 'Run a live browser terminal worker for a hosting account session.';
|
|
|
|
public function handle(
|
|
BrowserTerminalSessionManager $sessionManager,
|
|
SharedNodeProvider $provider,
|
|
): int {
|
|
$sessionId = (string) $this->argument('session');
|
|
$shell = null;
|
|
$status = 'closed';
|
|
$error = null;
|
|
$eventOffset = 0;
|
|
|
|
try {
|
|
$metadata = $sessionManager->readWorkerMetadata($sessionId);
|
|
$account = HostingAccount::query()
|
|
->with('node')
|
|
->find($metadata['account_id'] ?? null);
|
|
|
|
if (! $account || ! $account->node) {
|
|
throw new \RuntimeException('Hosting account or node was not found for this terminal session.');
|
|
}
|
|
|
|
$shell = $this->makeShell($account, $metadata, $provider);
|
|
$shell->boot();
|
|
|
|
$metadata['status'] = 'running';
|
|
$metadata['error'] = null;
|
|
$sessionManager->writeWorkerMetadata($sessionId, $metadata);
|
|
$status = 'closed';
|
|
|
|
while (true) {
|
|
$metadata = $sessionManager->readWorkerMetadata($sessionId);
|
|
|
|
if ($sessionManager->isSessionIdle($metadata)) {
|
|
$sessionManager->appendWorkerOutput($sessionId, "\r\n[Browser terminal disconnected after inactivity]\r\n");
|
|
break;
|
|
}
|
|
|
|
foreach ($sessionManager->readWorkerEvents($sessionId, $eventOffset) as $event) {
|
|
$type = (string) ($event['type'] ?? '');
|
|
|
|
if ($type === 'input') {
|
|
$shell->write((string) ($event['data'] ?? ''));
|
|
continue;
|
|
}
|
|
|
|
if ($type === 'resize') {
|
|
$shell->resize((int) ($event['cols'] ?? 120), (int) ($event['rows'] ?? 30));
|
|
continue;
|
|
}
|
|
|
|
if ($type === 'close') {
|
|
$sessionManager->appendWorkerOutput($sessionId, "\r\n[Browser terminal closed]\r\n");
|
|
break 2;
|
|
}
|
|
}
|
|
|
|
$output = $shell->read();
|
|
if ($output !== '') {
|
|
$sessionManager->appendWorkerOutput($sessionId, $output);
|
|
}
|
|
|
|
if (! $shell->isAlive()) {
|
|
break;
|
|
}
|
|
|
|
usleep(50000);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$status = 'failed';
|
|
$error = $e->getMessage();
|
|
Log::error('Hosting terminal worker failed.', [
|
|
'session_id' => $sessionId,
|
|
'exception' => get_class($e),
|
|
'message' => $e->getMessage(),
|
|
'file' => $e->getFile(),
|
|
'line' => $e->getLine(),
|
|
]);
|
|
$sessionManager->appendWorkerOutput(
|
|
$sessionId,
|
|
sprintf(
|
|
"\r\n[Terminal error] %s (%s:%d)\r\n",
|
|
$error,
|
|
basename($e->getFile()),
|
|
$e->getLine()
|
|
)
|
|
);
|
|
} finally {
|
|
if ($shell instanceof ShellSession) {
|
|
$shell->close();
|
|
}
|
|
|
|
try {
|
|
$metadata = $sessionManager->readWorkerMetadata($sessionId);
|
|
$metadata['status'] = $status;
|
|
$metadata['error'] = $error;
|
|
$metadata['exit_code'] = $status === 'failed' ? 1 : 0;
|
|
$metadata['closed_at'] = now()->toIso8601String();
|
|
$sessionManager->writeWorkerMetadata($sessionId, $metadata);
|
|
} catch (\Throwable) {
|
|
// Ignore metadata write failures during shutdown.
|
|
}
|
|
}
|
|
|
|
return $status === 'failed' ? self::FAILURE : self::SUCCESS;
|
|
}
|
|
|
|
private function makeShell(HostingAccount $account, array $metadata, SharedNodeProvider $provider): ShellSession
|
|
{
|
|
$relativePath = $this->normalizeRelativePath((string) ($metadata['relative_path'] ?? '/public_html'));
|
|
$cols = max(40, min((int) ($metadata['cols'] ?? 120), 240));
|
|
$rows = max(10, min((int) ($metadata['rows'] ?? 30), 80));
|
|
$launchCommand = $this->buildUserShellBootstrap($account, $relativePath, $cols, $rows);
|
|
|
|
if ($this->shouldUseLocalExecution($account->node)) {
|
|
return new LocalPtyShellSession($this->buildLocalLaunchCommand($account, $launchCommand));
|
|
}
|
|
|
|
$ssh = $provider->connect($account->node);
|
|
|
|
if (! $ssh) {
|
|
throw new \RuntimeException('Unable to establish an SSH connection for the browser terminal.');
|
|
}
|
|
|
|
$remoteCommand = "runuser -u {$account->username} -- bash -lc ".escapeshellarg($launchCommand);
|
|
|
|
return new RemoteSshShellSession($ssh, $remoteCommand, $cols, $rows);
|
|
}
|
|
|
|
private function buildUserShellBootstrap(HostingAccount $account, string $relativePath, int $cols, int $rows): string
|
|
{
|
|
$homeDirectory = "/home/{$account->username}";
|
|
$workingDirectory = $relativePath === '/'
|
|
? $homeDirectory
|
|
: $homeDirectory.$relativePath;
|
|
|
|
return implode('; ', [
|
|
'export HOME='.escapeshellarg($homeDirectory),
|
|
'export USER='.escapeshellarg($account->username),
|
|
'export LOGNAME='.escapeshellarg($account->username),
|
|
'export SHELL=/bin/bash',
|
|
'export TERM=xterm-256color',
|
|
'export COLORTERM=truecolor',
|
|
'export COLUMNS='.(int) $cols,
|
|
'export LINES='.(int) $rows,
|
|
'export PS1='.escapeshellarg($account->username.'@ladill:\w\$ '),
|
|
'cd '.escapeshellarg($workingDirectory).' 2>/dev/null || cd '.escapeshellarg($homeDirectory),
|
|
'stty rows '.(int) $rows.' cols '.(int) $cols.' echo 2>/dev/null || true',
|
|
'exec /bin/bash --noprofile --norc -i',
|
|
]);
|
|
}
|
|
|
|
private function buildLocalLaunchCommand(HostingAccount $account, string $launchCommand): string
|
|
{
|
|
// For local development, fall back to running as current user if user switching isn't available
|
|
$devFallback = app()->environment('local')
|
|
? 'exec bash -lc "$LAUNCH_CMD";'
|
|
: 'echo '.escapeshellarg('Local hosting commands require passwordless sudo, runuser access, or running the app as the hosting account user.').'; exit 1;';
|
|
|
|
return implode(' ', [
|
|
'TARGET_USER='.escapeshellarg($account->username).';',
|
|
'LAUNCH_CMD='.escapeshellarg($launchCommand).';',
|
|
'if [ "$(id -un)" = "$TARGET_USER" ]; then exec bash -lc "$LAUNCH_CMD"; fi;',
|
|
'if command -v sudo >/dev/null 2>&1 && sudo -n -u "$TARGET_USER" -- true >/dev/null 2>&1; then exec sudo -n -u "$TARGET_USER" -- bash -lc "$LAUNCH_CMD"; fi;',
|
|
'if command -v runuser >/dev/null 2>&1 && [ "$(id -u)" = "0" ]; then exec runuser -u "$TARGET_USER" -- bash -lc "$LAUNCH_CMD"; fi;',
|
|
$devFallback,
|
|
]);
|
|
}
|
|
|
|
private function normalizeRelativePath(string $path): string
|
|
{
|
|
$path = '/'.ltrim(trim($path), '/');
|
|
$path = preg_replace('#/+#', '/', $path) ?: '/public_html';
|
|
|
|
$segments = [];
|
|
foreach (explode('/', $path) as $segment) {
|
|
if ($segment === '' || $segment === '.') {
|
|
continue;
|
|
}
|
|
|
|
if ($segment === '..') {
|
|
array_pop($segments);
|
|
continue;
|
|
}
|
|
|
|
$segments[] = $segment;
|
|
}
|
|
|
|
$normalized = '/'.implode('/', $segments);
|
|
|
|
return $normalized === '/' ? '/' : $normalized;
|
|
}
|
|
|
|
private function shouldUseLocalExecution(HostingNode $node): bool
|
|
{
|
|
return ($node->provider === 'local' || $node->ip_address === '127.0.0.1' || $node->ip_address === 'localhost')
|
|
&& blank($node->ssh_private_key);
|
|
}
|
|
}
|