Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s
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>
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
<?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.';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user