diff --git a/app/Commands/DeployCommand.php b/app/Commands/DeployCommand.php index efc1a47..68787f2 100644 --- a/app/Commands/DeployCommand.php +++ b/app/Commands/DeployCommand.php @@ -17,7 +17,7 @@ class DeployCommand extends Command { use EnsureHasToken, HasPloiConfiguration, InteractWithServer, InteractWithSite; - protected $signature = 'deploy {--server=} {--site=} {--schedule=} {--stream : Stream deployment logs in real-time} {--deployment-id= : Specific deployment ID to stream}'; + protected $signature = 'deploy {--server=} {--site=} {--schedule=} {--no-stream : Disable real-time deployment log streaming}'; protected $description = 'Deploy your site to Ploi.io with optional log streaming.'; @@ -34,7 +34,7 @@ public function handle(): void $data = []; $isScheduled = false; - if ($this->ploi->getSiteDetails($serverId, $siteId)['data']['has_staging']) { + if ($this->site['has_staging']) { $this->warn("{$this->site['domain']} has a staging environment."); $deployToProduction = confirm( label: 'Do you want to deploy to production? (yes/no)', @@ -58,17 +58,6 @@ public function handle(): void } $this->deploy($serverId, $siteId, $this->site['domain'], $data, $isScheduled); - - // Handle streaming after deployment - if ($this->option('stream') && ! $isScheduled) { - $deploymentId = $this->option('deployment-id') ?? $this->getLatestDeploymentId($serverId, $siteId); - - if ($deploymentId) { - $this->streamDeploymentLogs($serverId, $siteId, $deploymentId); - } else { - $this->error('No deployment found to stream'); - } - } } protected function validateScheduleDatetime(string $datetime): void @@ -106,21 +95,27 @@ protected function deploy($serverId, $siteId, $domain, $data, $isScheduled = fal return; } - $this->pollDeploymentStatus($serverId, $siteId, $domain); + if ($this->option('no-stream')) { + $this->pollDeploymentStatus($serverId, $siteId, $domain); + + return; + } + + $this->streamAndAwait($serverId, $siteId, $domain); } protected function pollDeploymentStatus(string $serverId, string $siteId, string $domain): void { - $maxAttempts = 60; // Maximum number of polling attempts (10 minutes total with 10-second delay) - $delay = 5; // Delay between each attempt in seconds + $maxAttempts = 60; // Maximum number of polling attempts (5 minutes total with 5-second delay) + $delay = 5; // Delay between each attempt in seconds $this->info('Deployment initiated!'); + $status = spin( callback: function () use ($serverId, $siteId, $domain, $maxAttempts, $delay) { for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { $deploymentStatus = $this->ploi->getSiteDetails($serverId, $siteId)['data']['status'] ?? 'deploying'; - // If we get a final status, return it if (in_array($deploymentStatus, ['active', 'deploy-failed'])) { return [ 'status' => $deploymentStatus, @@ -131,7 +126,6 @@ protected function pollDeploymentStatus(string $serverId, string $siteId, string sleep($delay); } - // If we've exceeded max attempts, return timeout status return [ 'status' => 'timeout', 'domain' => $domain, @@ -140,59 +134,41 @@ protected function pollDeploymentStatus(string $serverId, string $siteId, string message: 'Checking deployment status...' ); - // Handle the deployment result match ($status['status']) { 'active' => $this->handleSuccessfulDeployment($serverId, $siteId, $status['domain']), 'deploy-failed' => $this->handleFailedDeployment($serverId, $siteId), 'timeout' => $this->warn('Deployment status check timed out. Please check manually.'), - default => $this->warn('Deployment status is unknown. Please check manually.') + default => $this->warn('Deployment status is unknown. Please check manually.'), }; } - /** - * Stream deployment logs in real-time - * - * @return void - */ - private function streamDeploymentLogs(int $serverId, int $siteId, int $deploymentId) + private function streamAndAwait(int $serverId, int $siteId, string $domain): void { $this->info('🔄 Streaming deployment logs...'); $this->newLine(); - $poller = new DeploymentLogPoller(config('ploi.token')); + $poller = new DeploymentLogPoller($this->ploi); try { - $poller->pollDeploymentLogs($serverId, $siteId, $deploymentId, function ($line) { - // Format the log line with timestamp + $status = $poller->pollDeploymentLogs($serverId, $siteId, function ($line) { $timestamp = now()->format('H:i:s'); - $this->line("[{$timestamp}] {$line}"); + $this->line("[{$timestamp}] {$line}"); }); - - $this->newLine(); - $this->success('✅ Deployment streaming completed!'); - } catch (Exception $e) { $this->newLine(); - $this->error('❌ Streaming failed: '.$e->getMessage()); - } - } + $this->warn('Log streaming failed ('.$e->getMessage().'), falling back to status checks...'); + $this->pollDeploymentStatus($serverId, $siteId, $domain); - /** - * Get the latest deployment ID for streaming - */ - private function getLatestDeploymentId(int $serverId, int $siteId): ?int - { - $poller = new DeploymentLogPoller(config('ploi.token')); - - try { - $deployment = $poller->getLatestDeployment($serverId, $siteId); + return; + } - return isset($deployment['id']) ? (int) $deployment['id'] : null; - } catch (Exception $e) { - $this->error('Failed to get latest deployment: '.$e->getMessage()); + $this->newLine(); - return null; - } + match ($status) { + 'active' => $this->handleSuccessfulDeployment($serverId, $siteId, $domain), + 'deploy-failed' => $this->handleFailedDeployment($serverId, $siteId), + default => $this->warn('Deployment status check timed out. Please check manually.'), + }; } /** @@ -210,8 +186,8 @@ private function handleFailedDeployment(string $serverId, string $siteId): void { $this->error('Your recent deployment has failed, please check recent deploy log for errors.'); - // Show link to deployment logs if not streaming - if (! $this->option('stream')) { + // When streaming, the failed log output is already shown inline. + if ($this->option('no-stream')) { $this->showLogLink($serverId, $siteId); } } @@ -222,7 +198,6 @@ private function handleFailedDeployment(string $serverId, string $siteId): void private function showLogLink(string $serverId, string $siteId): void { try { - // Get the latest deployment log ID $logs = $this->ploi->getSiteLogs($serverId, $siteId, 1)['data']; if (! empty($logs)) { diff --git a/app/Commands/LogsCommand.php b/app/Commands/LogsCommand.php index 8dfb622..3c5fb81 100644 --- a/app/Commands/LogsCommand.php +++ b/app/Commands/LogsCommand.php @@ -10,7 +10,7 @@ class LogsCommand extends Command { use EnsureHasToken; - protected $signature = 'logs:stream {server} {site} {--deployment-id= : Specific deployment ID to stream}'; + protected $signature = 'logs:stream {server} {site}'; protected $description = 'Stream deployment logs for a specific site'; @@ -20,39 +20,37 @@ public function handle(): void $serverId = (int) $this->argument('server'); $siteId = (int) $this->argument('site'); - $deploymentId = $this->option('deployment-id'); - - $poller = new DeploymentLogPoller(config('ploi.token')); try { - // If no deployment ID provided, get the latest or active deployment - if (! $deploymentId) { - $deployment = $poller->getActiveDeployment($serverId, $siteId) - ?? $poller->getLatestDeployment($serverId, $siteId); + $site = $this->ploi->getSiteDetails($serverId, $siteId)['data'] ?? null; + + if (! $site) { + $this->error('Site not found.'); + + return; + } - if (! $deployment) { - $this->error('No deployment found for this site'); + $currentLog = $site['current_deploy_log'] ?? null; + $status = $site['status'] ?? null; - return; - } + if ($currentLog === null && $status !== 'deploying') { + $this->info('No deployment in progress for this site.'); - $deploymentId = $deployment['id']; - $this->info("Streaming logs for deployment #{$deploymentId} (status: {$deployment['status']})"); - } else { - $this->info("Streaming logs for deployment #{$deploymentId}"); + return; } - $this->info('🔄 Streaming deployment logs... (Press Ctrl+C to stop)'); + $this->info("🔄 Streaming deployment logs for {$site['domain']}... (Press Ctrl+C to stop)"); $this->newLine(); - $poller->pollDeploymentLogs($serverId, $siteId, $deploymentId, function ($line) { + $poller = new DeploymentLogPoller($this->ploi); + + $poller->pollDeploymentLogs($serverId, $siteId, function ($line) { $timestamp = now()->format('H:i:s'); - $this->line("[{$timestamp}] {$line}"); + $this->line("[{$timestamp}] {$line}"); }); $this->newLine(); $this->success('✅ Deployment log streaming completed!'); - } catch (Exception $e) { $this->newLine(); $this->error('❌ Streaming failed: '.$e->getMessage()); diff --git a/app/Services/DeploymentLogPoller.php b/app/Services/DeploymentLogPoller.php index 6a6c9c9..d0ae9ff 100644 --- a/app/Services/DeploymentLogPoller.php +++ b/app/Services/DeploymentLogPoller.php @@ -3,214 +3,161 @@ namespace App\Services; use Exception; -use Illuminate\Support\Facades\Http; class DeploymentLogPoller { - private $apiKey; + private const POLL_INTERVAL = 2; - private $baseUrl; + private const MAX_POLL_ATTEMPTS = 300; - public function __construct(string $apiKey, ?string $baseUrl = null) - { - $this->apiKey = $apiKey; - $this->baseUrl = $baseUrl ?? config('ploi.api_url'); - } + private const MAX_RETRIES = 3; + + private const RETRY_BACKOFF = 5; + + private const FINAL_STATUSES = ['active', 'deploy-failed']; + + public function __construct(private readonly PloiAPI $ploi) {} /** - * Poll deployment logs and stream new lines as they appear + * Poll the site endpoint, streaming new lines from `current_deploy_log` as they appear. * - * @param callable|null $onNewLines Callback for each new log line + * Returns the site's final status (`active` / `deploy-failed`), or `null` on timeout. + * + * @param callable|null $onNewLines Receives each new non-empty log line. * * @throws Exception */ - public function pollDeploymentLogs(int $serverId, int $siteId, int $deploymentId, ?callable $onNewLines = null): void + public function pollDeploymentLogs(int $serverId, int $siteId, ?callable $onNewLines = null): ?string { - $isActive = true; $lastLogPosition = 0; - $pollInterval = 2; // seconds - $maxRetries = 3; + $hadLogContent = false; $retryCount = 0; - $maxPollAttempts = 300; // 10 minutes at 2-second intervals $pollAttempts = 0; - while ($isActive && $pollAttempts < $maxPollAttempts) { + while ($pollAttempts < self::MAX_POLL_ATTEMPTS) { try { - $response = $this->getDeploymentLog($serverId, $siteId, $deploymentId); - - if ($response && isset($response['content'])) { - $newLines = $this->extractNewLines($response['content'], $lastLogPosition); - - if (! empty($newLines)) { - foreach ($newLines as $line) { - if (trim($line) !== '') { // Skip empty lines - if ($onNewLines) { - $onNewLines($line); - } else { - echo $line.PHP_EOL; - } - } - } - - $lastLogPosition += count($newLines); - } + $site = $this->ploi->getSiteDetails($serverId, $siteId)['data'] ?? null; + + if (! $site) { + throw new Exception('Failed to fetch site details.'); } - // Check deployment status to see if we should stop polling - $deployment = $this->getDeployment($serverId, $siteId, $deploymentId); - if ($deployment && ! in_array($deployment['status'], ['pending', 'running', 'deploying'])) { - $isActive = false; + $currentLog = $site['current_deploy_log'] ?? null; + $status = $site['status'] ?? null; - // Show final status - if ($onNewLines) { - $onNewLines("Deployment {$deployment['status']}"); - } + if ($currentLog !== null) { + $hadLogContent = true; + $lastLogPosition += $this->emitNewLines($currentLog, $lastLogPosition, $onNewLines); } - if ($isActive) { - sleep($pollInterval); - $pollAttempts++; + // Deployment finished if the API reports a terminal status, or if it cleared + // the log buffer after we had previously seen content. + $finished = in_array($status, self::FINAL_STATUSES, true) + || ($hadLogContent && $currentLog === null); + + if ($finished) { + // The live `current_deploy_log` buffer is cleared the moment a deployment + // finishes, so its final lines never get polled. Emit anything we missed + // from the persisted deploy log before returning. + $lastLogPosition += $this->flushPersistedLog($serverId, $siteId, $lastLogPosition, $onNewLines); + + return $status; } - // Reset retry count on successful poll + sleep(self::POLL_INTERVAL); + $pollAttempts++; $retryCount = 0; - } catch (Exception $e) { $retryCount++; - if ($retryCount >= $maxRetries) { + if ($retryCount >= self::MAX_RETRIES) { throw new Exception('Max retries reached. Last error: '.$e->getMessage()); } + $message = "Error polling logs (retry $retryCount/".self::MAX_RETRIES.'): '.$e->getMessage(); + if ($onNewLines) { - $onNewLines("Error polling logs (retry {$retryCount}/{$maxRetries}): ".$e->getMessage()); + $onNewLines($message); } else { - echo "Error polling logs (retry {$retryCount}/{$maxRetries}): ".$e->getMessage().PHP_EOL; + echo $message.PHP_EOL; } - sleep(5); // Wait longer on error - } - } - - if ($pollAttempts >= $maxPollAttempts) { - $message = 'Log polling timeout reached (10 minutes). Deployment may still be in progress.'; - if ($onNewLines) { - $onNewLines($message); - } else { - echo $message.PHP_EOL; + sleep(self::RETRY_BACKOFF); } } - } - /** - * Extract new lines from log content based on last position - */ - private function extractNewLines(string $content, int $lastPosition): array - { - $lines = explode("\n", $content); + $timeoutMessage = 'Log polling timeout reached (10 minutes). Deployment may still be in progress.'; - if ($lastPosition < count($lines)) { - return array_slice($lines, $lastPosition); + if ($onNewLines) { + $onNewLines($timeoutMessage); + } else { + echo $timeoutMessage.PHP_EOL; } - return []; - } - - /** - * Get deployment log content - * - * @throws Exception - */ - private function getDeploymentLog(int $serverId, int $siteId, int $deploymentId): ?array - { - $response = $this->makeApiRequest("servers/{$serverId}/sites/{$siteId}/deployments/{$deploymentId}/log"); - - return $response['data'] ?? null; + return null; } /** - * Get deployment status - * - * @throws Exception + * Emit every non-empty line past `$lastPosition` and return how many lines were consumed. */ - private function getDeployment(int $serverId, int $siteId, int $deploymentId): ?array + private function emitNewLines(string $content, int $lastPosition, ?callable $onNewLines): int { - $response = $this->makeApiRequest("servers/{$serverId}/sites/{$siteId}/deployments"); + $newLines = $this->extractNewLines($content, $lastPosition); - $deployments = $response['data'] ?? []; + foreach ($newLines as $line) { + if (trim($line) === '') { + continue; + } - foreach ($deployments as $deployment) { - if ($deployment['id'] == $deploymentId) { - return $deployment; + if ($onNewLines) { + $onNewLines($line); + } else { + echo $line.PHP_EOL; } } - return null; + return count($newLines); } /** - * Make authenticated API request + * Fetch the persisted deploy log and emit any lines the live buffer never streamed. * - * @throws Exception + * Best-effort: a failure here does not affect the already-reported deployment status. */ - private function makeApiRequest(string $endpoint): array + private function flushPersistedLog(int $serverId, int $siteId, int $lastPosition, ?callable $onNewLines): int { - $response = Http::withHeaders([ - 'Authorization' => 'Bearer '.$this->apiKey, - 'Accept' => 'application/json', - 'User-Agent' => 'Ploi CLI', - ])->get($this->baseUrl.'/'.$endpoint); - - if (! $response->successful()) { - throw new Exception('API request failed: '.$response->body()); - } + try { + $logs = $this->ploi->getSiteLogs($serverId, $siteId, 1)['data'] ?? []; - return $response->json(); - } + if (empty($logs)) { + return 0; + } - /** - * Get the latest deployment for a site - * - * @throws Exception - */ - public function getLatestDeployment(int $serverId, int $siteId): ?array - { - $response = $this->makeApiRequest("servers/{$serverId}/sites/{$siteId}/deployments?per_page=1"); + $content = $this->ploi->getSiteLog($serverId, $siteId, $logs[0]['id'])['data']['content'] ?? null; - $deployments = $response['data'] ?? []; + if ($content === null) { + return 0; + } - return $deployments[0] ?? null; + return $this->emitNewLines($content, $lastPosition, $onNewLines); + } catch (Exception $e) { + return 0; + } } /** - * Check if there's an active deployment + * Slice lines past the last reported position. * - * @throws Exception + * @return array */ - public function getActiveDeployment(int $serverId, int $siteId): ?array + private function extractNewLines(string $content, int $lastPosition): array { - $response = $this->makeApiRequest("servers/{$serverId}/sites/{$siteId}/deployments"); - - $deployments = $response['data'] ?? []; + $lines = explode("\n", $content); - foreach ($deployments as $deployment) { - if (in_array($deployment['status'], ['pending', 'running', 'deploying'])) { - return $deployment; - } + if ($lastPosition < count($lines)) { + return array_slice($lines, $lastPosition); } - return null; - } - - /** - * Get all deployments for a site - * - * @throws Exception - */ - public function getDeployments(int $serverId, int $siteId): array - { - $response = $this->makeApiRequest("servers/{$serverId}/sites/{$siteId}/deployments"); - - return $response['data'] ?? []; + return []; } }