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>
374 lines
11 KiB
PHP
374 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Hosting;
|
|
|
|
use App\Models\HostingAccount;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Str;
|
|
|
|
class BrowserTerminalSessionManager
|
|
{
|
|
private const IDLE_TIMEOUT_SECONDS = 120;
|
|
|
|
public function createSession(HostingAccount $account, int $userId, string $relativePath = '/public_html', int $cols = 120, int $rows = 30): array
|
|
{
|
|
$sessionId = (string) Str::uuid();
|
|
$directory = $this->sessionDirectory($sessionId);
|
|
|
|
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
|
|
throw new \RuntimeException('Unable to create browser terminal session directory.');
|
|
}
|
|
|
|
touch($this->eventLogPath($sessionId));
|
|
touch($this->outputLogPath($sessionId));
|
|
touch($this->heartbeatPath($sessionId));
|
|
|
|
$metadata = [
|
|
'session_id' => $sessionId,
|
|
'account_id' => $account->id,
|
|
'user_id' => $userId,
|
|
'username' => $account->username,
|
|
'relative_path' => $relativePath,
|
|
'cols' => $this->normalizeColumns($cols),
|
|
'rows' => $this->normalizeRows($rows),
|
|
'status' => 'starting',
|
|
'created_at' => now()->toIso8601String(),
|
|
'last_seen_at' => now()->toIso8601String(),
|
|
'pid' => null,
|
|
'exit_code' => null,
|
|
'error' => null,
|
|
];
|
|
|
|
$this->writeMetadata($sessionId, $metadata);
|
|
|
|
$workerCommand = $this->buildWorkerCommand($sessionId);
|
|
|
|
$result = Process::timeout(10)->run(['bash', '-lc', $workerCommand]);
|
|
$pid = trim($result->output());
|
|
|
|
if ($result->failed() || $pid === '' || ! ctype_digit($pid)) {
|
|
$metadata['status'] = 'failed';
|
|
$metadata['error'] = 'Unable to start the browser terminal worker.';
|
|
$this->writeMetadata($sessionId, $metadata);
|
|
|
|
throw new \RuntimeException($metadata['error']);
|
|
}
|
|
|
|
$metadata['pid'] = (int) $pid;
|
|
$this->writeMetadata($sessionId, $metadata);
|
|
|
|
$metadata = $this->waitForWorkerStartup($sessionId, $metadata);
|
|
|
|
return $metadata;
|
|
}
|
|
|
|
public function readOutput(HostingAccount $account, int $userId, string $sessionId, int $offset = 0): array
|
|
{
|
|
$metadata = $this->sessionMetadataFor($account, $userId, $sessionId);
|
|
$offset = max(0, $offset);
|
|
$outputPath = $this->outputLogPath($sessionId);
|
|
$size = is_file($outputPath) ? filesize($outputPath) : 0;
|
|
$chunk = '';
|
|
|
|
if ($size > $offset) {
|
|
$stream = fopen($outputPath, 'rb');
|
|
|
|
if ($stream !== false) {
|
|
fseek($stream, $offset);
|
|
$chunk = (string) stream_get_contents($stream);
|
|
fclose($stream);
|
|
}
|
|
}
|
|
|
|
$this->touchHeartbeat($sessionId);
|
|
|
|
return [
|
|
'output' => $chunk,
|
|
'offset' => $size,
|
|
'status' => $metadata['status'] ?? 'closed',
|
|
'exit_code' => $metadata['exit_code'] ?? null,
|
|
'error' => $metadata['error'] ?? null,
|
|
];
|
|
}
|
|
|
|
public function appendInput(HostingAccount $account, int $userId, string $sessionId, string $input): void
|
|
{
|
|
$this->sessionMetadataFor($account, $userId, $sessionId);
|
|
$this->touchHeartbeat($sessionId);
|
|
|
|
$this->appendEvent($sessionId, [
|
|
'type' => 'input',
|
|
'data' => $input,
|
|
]);
|
|
}
|
|
|
|
public function resizeSession(HostingAccount $account, int $userId, string $sessionId, int $cols, int $rows): void
|
|
{
|
|
$this->sessionMetadataFor($account, $userId, $sessionId);
|
|
$this->touchHeartbeat($sessionId);
|
|
|
|
$this->appendEvent($sessionId, [
|
|
'type' => 'resize',
|
|
'cols' => $this->normalizeColumns($cols),
|
|
'rows' => $this->normalizeRows($rows),
|
|
]);
|
|
}
|
|
|
|
public function closeSession(HostingAccount $account, int $userId, string $sessionId): void
|
|
{
|
|
$this->sessionMetadataFor($account, $userId, $sessionId);
|
|
$this->touchHeartbeat($sessionId);
|
|
|
|
$this->appendEvent($sessionId, [
|
|
'type' => 'close',
|
|
]);
|
|
}
|
|
|
|
public function readWorkerMetadata(string $sessionId): array
|
|
{
|
|
$metadata = $this->readMetadata($sessionId);
|
|
|
|
if ($metadata === null) {
|
|
throw new \RuntimeException('Browser terminal session not found.');
|
|
}
|
|
|
|
return $metadata;
|
|
}
|
|
|
|
public function writeWorkerMetadata(string $sessionId, array $metadata): void
|
|
{
|
|
$this->writeMetadata($sessionId, $metadata);
|
|
}
|
|
|
|
public function appendWorkerOutput(string $sessionId, string $output): void
|
|
{
|
|
if ($output === '') {
|
|
return;
|
|
}
|
|
|
|
file_put_contents($this->outputLogPath($sessionId), $output, FILE_APPEND | LOCK_EX);
|
|
}
|
|
|
|
public function readWorkerEvents(string $sessionId, int &$offset): array
|
|
{
|
|
$path = $this->eventLogPath($sessionId);
|
|
|
|
if (! is_file($path)) {
|
|
return [];
|
|
}
|
|
|
|
$stream = fopen($path, 'rb');
|
|
|
|
if ($stream === false) {
|
|
return [];
|
|
}
|
|
|
|
fseek($stream, $offset);
|
|
$payload = (string) stream_get_contents($stream);
|
|
$offset = ftell($stream) ?: $offset;
|
|
fclose($stream);
|
|
|
|
$events = [];
|
|
foreach (preg_split("/\r\n|\n|\r/", $payload) as $line) {
|
|
$line = trim($line);
|
|
if ($line === '') {
|
|
continue;
|
|
}
|
|
|
|
$decoded = json_decode($line, true);
|
|
if (is_array($decoded)) {
|
|
$events[] = $decoded;
|
|
}
|
|
}
|
|
|
|
return $events;
|
|
}
|
|
|
|
public function isSessionIdle(array $metadata): bool
|
|
{
|
|
$heartbeatTime = @filemtime($this->heartbeatPath((string) ($metadata['session_id'] ?? '')));
|
|
|
|
if ($heartbeatTime === false) {
|
|
return false;
|
|
}
|
|
|
|
return (time() - $heartbeatTime) >= self::IDLE_TIMEOUT_SECONDS;
|
|
}
|
|
|
|
private function sessionMetadataFor(HostingAccount $account, int $userId, string $sessionId): array
|
|
{
|
|
$metadata = $this->readMetadata($sessionId);
|
|
|
|
if ($metadata === null) {
|
|
throw new \RuntimeException('Browser terminal session not found.');
|
|
}
|
|
|
|
if (($metadata['account_id'] ?? null) !== $account->id || ($metadata['user_id'] ?? null) !== $userId) {
|
|
throw new \RuntimeException('Browser terminal session not found.');
|
|
}
|
|
|
|
return $metadata;
|
|
}
|
|
|
|
private function appendEvent(string $sessionId, array $payload): void
|
|
{
|
|
file_put_contents(
|
|
$this->eventLogPath($sessionId),
|
|
json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).PHP_EOL,
|
|
FILE_APPEND | LOCK_EX
|
|
);
|
|
}
|
|
|
|
private function readMetadata(string $sessionId): ?array
|
|
{
|
|
$path = $this->metadataPath($sessionId);
|
|
|
|
if (! is_file($path)) {
|
|
return null;
|
|
}
|
|
|
|
$decoded = json_decode((string) file_get_contents($path), true);
|
|
|
|
return is_array($decoded) ? $decoded : null;
|
|
}
|
|
|
|
private function writeMetadata(string $sessionId, array $metadata): void
|
|
{
|
|
file_put_contents(
|
|
$this->metadataPath($sessionId),
|
|
json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
|
|
LOCK_EX
|
|
);
|
|
}
|
|
|
|
private function metadataPath(string $sessionId): string
|
|
{
|
|
return $this->sessionDirectory($sessionId).'/meta.json';
|
|
}
|
|
|
|
private function eventLogPath(string $sessionId): string
|
|
{
|
|
return $this->sessionDirectory($sessionId).'/events.ndjson';
|
|
}
|
|
|
|
private function outputLogPath(string $sessionId): string
|
|
{
|
|
return $this->sessionDirectory($sessionId).'/output.log';
|
|
}
|
|
|
|
private function heartbeatPath(string $sessionId): string
|
|
{
|
|
return $this->sessionDirectory($sessionId).'/heartbeat';
|
|
}
|
|
|
|
private function sessionDirectory(string $sessionId): string
|
|
{
|
|
return storage_path('app/browser-terminal-sessions/'.$sessionId);
|
|
}
|
|
|
|
private function normalizeColumns(int $cols): int
|
|
{
|
|
return max(40, min($cols, 240));
|
|
}
|
|
|
|
private function normalizeRows(int $rows): int
|
|
{
|
|
return max(10, min($rows, 80));
|
|
}
|
|
|
|
private function touchHeartbeat(string $sessionId): void
|
|
{
|
|
touch($this->heartbeatPath($sessionId));
|
|
}
|
|
|
|
private function buildWorkerCommand(string $sessionId): string
|
|
{
|
|
return sprintf(
|
|
'cd %s && nohup %s artisan hosting:terminal-worker %s >> %s 2>&1 < /dev/null & echo $!',
|
|
escapeshellarg(base_path()),
|
|
escapeshellarg($this->resolvePhpCliBinary()),
|
|
escapeshellarg($sessionId),
|
|
escapeshellarg($this->outputLogPath($sessionId))
|
|
);
|
|
}
|
|
|
|
private function resolvePhpCliBinary(): string
|
|
{
|
|
$candidates = array_filter([
|
|
env('LADILL_PHP_CLI_BINARY'),
|
|
$this->isCliBinary(PHP_BINARY) ? PHP_BINARY : null,
|
|
$this->joinBinaryPath(PHP_BINDIR, 'php'),
|
|
$this->joinBinaryPath(PHP_BINDIR, 'php-cli'),
|
|
'/usr/bin/php',
|
|
'/usr/local/bin/php',
|
|
]);
|
|
|
|
foreach ($candidates as $candidate) {
|
|
if ($this->isCliBinary($candidate)) {
|
|
return $candidate;
|
|
}
|
|
}
|
|
|
|
return PHP_BINARY;
|
|
}
|
|
|
|
private function joinBinaryPath(string $directory, string $binary): string
|
|
{
|
|
return rtrim($directory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$binary;
|
|
}
|
|
|
|
private function isCliBinary(?string $path): bool
|
|
{
|
|
if (! is_string($path) || trim($path) === '') {
|
|
return false;
|
|
}
|
|
|
|
$basename = strtolower(basename($path));
|
|
|
|
if (str_contains($basename, 'fpm') || str_contains($basename, 'cgi')) {
|
|
return false;
|
|
}
|
|
|
|
return is_file($path) && is_executable($path);
|
|
}
|
|
|
|
private function waitForWorkerStartup(string $sessionId, array $metadata): array
|
|
{
|
|
$deadline = microtime(true) + 2.5;
|
|
|
|
while (microtime(true) < $deadline) {
|
|
usleep(100000);
|
|
|
|
$current = $this->readWorkerMetadata($sessionId);
|
|
|
|
if (($current['status'] ?? 'starting') !== 'starting') {
|
|
return $current;
|
|
}
|
|
}
|
|
|
|
$metadata['status'] = 'failed';
|
|
$metadata['exit_code'] = 1;
|
|
$metadata['closed_at'] = now()->toIso8601String();
|
|
$metadata['error'] = $this->workerBootstrapError($sessionId);
|
|
$this->writeMetadata($sessionId, $metadata);
|
|
|
|
return $metadata;
|
|
}
|
|
|
|
private function workerBootstrapError(string $sessionId): string
|
|
{
|
|
$output = trim((string) @file_get_contents($this->outputLogPath($sessionId)));
|
|
|
|
if ($output !== '') {
|
|
$lines = preg_split("/\r\n|\n|\r/", $output) ?: [];
|
|
$tail = trim((string) end($lines));
|
|
|
|
if ($tail !== '') {
|
|
return 'Terminal worker failed to start: '.$tail;
|
|
}
|
|
}
|
|
|
|
return 'Terminal worker failed to start.';
|
|
}
|
|
}
|