writeProgress(2, 'Avvio controllo aggiornamenti', 'running'); $baseUrl = rtrim((string) config('distribution.update_url', ''), '/'); if ($baseUrl === '') { $this->error('NETGESCON_UPDATE_URL non configurato.'); $this->writeProgress(100, 'NETGESCON_UPDATE_URL non configurato', 'failed', 1); return self::FAILURE; } $channel = (string) ($this->option('channel') ?: config('distribution.default_channel', 'free')); $code = (string) ($this->option('code') ?: config('distribution.licensed_code', '')); $auth = (string) ($this->option('auth') ?: config('distribution.licensed_auth', '')); $nodeCode = (string) config('distribution.node_code', ''); $manifestUrl = $baseUrl . '/api/v1/distribution/updates/manifest'; $query = ['channel' => $channel]; if ($channel === 'licensed') { if ($code === '' || $auth === '') { $this->error('Canale licensed richiede --code e --auth (o variabili NETGESCON_LICENSED_*).'); $this->writeProgress(100, 'Credenziali licensed mancanti', 'failed', 1); return self::FAILURE; } $query['code'] = $code; $query['auth'] = $auth; } $timeout = (int) config('distribution.http_timeout_seconds', 60); $resolveEntries = $this->resolveEntries(); $this->info("Controllo aggiornamenti su {$manifestUrl} (channel={$channel})..."); if ($resolveEntries !== []) { $this->line('Override resolve attivo: ' . implode(', ', $resolveEntries)); } $this->writeProgress(12, 'Contatto server distribution', 'running'); $response = Http::timeout($timeout) ->withOptions($this->httpOptions(false, $resolveEntries)) ->acceptJson() ->withHeaders(array_filter([ 'X-Netgescon-Node' => $nodeCode !== '' ? $nodeCode : null, ])) ->get($manifestUrl, $query); if (! $response->successful()) { if ($response->status() === 404) { $healthUrl = $baseUrl . '/api/v1/distribution/health'; $health = Http::timeout($timeout) ->withOptions($this->httpOptions(false, $resolveEntries)) ->acceptJson() ->get($healthUrl); if ($health->successful()) { $this->error('Manifest endpoint non trovato (HTTP 404), ma health distribution risponde OK.'); $this->error('Verifica che il server update esponga /api/v1/distribution/updates/manifest.'); $this->writeProgress(100, 'Manifest endpoint non disponibile sul server update', 'failed', 1); return self::FAILURE; } } $this->error('Manifest non raggiungibile: HTTP ' . $response->status()); $this->writeProgress(100, 'Manifest non raggiungibile: HTTP ' . $response->status(), 'failed', 1); return self::FAILURE; } /** @var array $manifest */ $manifest = $response->json(); $remoteVersion = (string) ($manifest['version'] ?? ''); $packageUrl = (string) ($manifest['package_url'] ?? ''); $hash = strtolower((string) ($manifest['sha256'] ?? '')); $migrateRequired = (bool) ($manifest['migrate_required'] ?? false); if ($remoteVersion === '' || $packageUrl === '' || $hash === '') { $this->error('Manifest incompleto: richiesti version/package_url/sha256.'); $this->writeProgress(100, 'Manifest incompleto', 'failed', 1); return self::FAILURE; } $currentVersion = (string) config('netgescon.version', '0.0.0'); $hasNewVersion = version_compare($remoteVersion, $currentVersion, '>'); $this->line('Versione corrente: ' . $currentVersion); $this->line('Versione remota: ' . $remoteVersion); if (! $hasNewVersion && ! $this->option('force')) { $this->info('Nessun aggiornamento necessario.'); $this->writeProgress(100, 'Nessun aggiornamento necessario', 'completed', 0); return self::SUCCESS; } if (! $this->option('apply')) { $this->info('Update disponibile. Esegui con --apply per installarlo.'); $this->writeProgress(100, 'Update disponibile (apply non richiesto)', 'completed', 0); return self::SUCCESS; } if ($this->option('dry-run')) { $this->warn('Dry-run: update rilevato, nessuna modifica applicata.'); $this->writeProgress(100, 'Dry-run completato', 'completed', 0); return self::SUCCESS; } $tmpRoot = storage_path('app/update-temp'); $backupRoot = storage_path('app/update-backups'); File::ensureDirectoryExists($tmpRoot); File::ensureDirectoryExists($backupRoot); $this->writeProgress(25, 'Preparazione cartelle temporanee', 'running'); $stamp = date('Ymd-His'); $zipPath = $tmpRoot . '/netgescon-update-' . $stamp . '.zip'; $extractDir = $tmpRoot . '/extract-' . $stamp; $envBackup = $backupRoot . '/.env-' . $stamp; $this->info('Download pacchetto...'); $this->writeProgress(35, 'Download pacchetto update', 'running'); $download = Http::timeout($timeout) ->withOptions($this->httpOptions(true, $resolveEntries)) ->get($packageUrl); if (! $download->successful()) { $this->error('Download fallito: HTTP ' . $download->status()); $this->writeProgress(100, 'Download fallito: HTTP ' . $download->status(), 'failed', 1); return self::FAILURE; } File::put($zipPath, $download->body()); $calcHash = strtolower(hash_file('sha256', $zipPath) ?: ''); if ($calcHash !== $hash) { $this->error('Hash SHA256 non valido. Atteso=' . $hash . ' calcolato=' . $calcHash); $this->writeProgress(100, 'Verifica hash fallita', 'failed', 1); return self::FAILURE; } $this->writeProgress(50, 'Pacchetto verificato', 'running'); $this->info('Backup .env...'); $this->writeProgress(58, 'Backup ambiente corrente', 'running'); if (File::exists(base_path('.env'))) { File::copy(base_path('.env'), $envBackup); } $this->info('Estrazione pacchetto...'); $this->writeProgress(66, 'Estrazione pacchetto', 'running'); File::ensureDirectoryExists($extractDir); $zip = new ZipArchive(); if ($zip->open($zipPath) !== true) { $this->error('Impossibile aprire ZIP update.'); $this->writeProgress(100, 'Impossibile aprire ZIP update', 'failed', 1); return self::FAILURE; } $zip->extractTo($extractDir); $zip->close(); $this->info('Applicazione file (esclusi .env e storage)...'); $this->writeProgress(78, 'Applicazione file', 'running'); $this->syncDir($extractDir, base_path()); if (File::exists($envBackup)) { File::copy($envBackup, base_path('.env')); } if ($this->packageContainsComposerFiles($extractDir)) { $this->info('Aggiornamento dipendenze PHP...'); $this->writeProgress(86, 'Aggiornamento dipendenze PHP', 'running'); $composer = Process::path(base_path()) ->timeout(1800) ->run(['composer', 'install', '--no-dev', '--optimize-autoloader']); if (! $composer->successful()) { $this->error('Composer install fallito durante update.'); $this->line(trim((string) ($composer->errorOutput() !== '' ? $composer->errorOutput() : $composer->output()))); $this->writeProgress(100, 'Composer install fallito durante update', 'failed', 1); return self::FAILURE; } } $this->call('optimize:clear'); $this->writeProgress(90, 'Pulizia cache applicazione', 'running'); if ($migrateRequired) { $this->warn('Manifest richiede migrate: esecuzione php artisan migrate --force'); $this->writeProgress(95, 'Esecuzione migration', 'running'); $this->call('migrate', ['--force' => true]); } $this->info('Aggiornamento applicato con successo.'); $this->writeProgress(100, 'Aggiornamento completato', 'completed', 0); return self::SUCCESS; } private function writeProgress(int $percent, string $message, string $status, ?int $exitCode = null): void { $path = trim((string) $this->option('progress-file')); if ($path === '') { return; } $dir = dirname($path); if (! is_dir($dir)) { @mkdir($dir, 0775, true); } $payload = [ 'timestamp' => now()->toIso8601String(), 'percent' => max(0, min(100, $percent)), 'message' => $message, 'status' => $status, 'exit_code' => $exitCode, ]; @file_put_contents($path, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } private function syncDir(string $from, string $to): void { $items = File::allFiles($from); foreach ($items as $item) { $path = $item->getPathname(); $relative = ltrim(str_replace($from, '', $path), '/'); if ($relative === '.env' || str_starts_with($relative, 'storage/') || str_starts_with($relative, '.git/')) { continue; } $target = $to . '/' . $relative; File::ensureDirectoryExists(dirname($target)); File::copy($path, $target); } } private function packageContainsComposerFiles(string $extractDir): bool { return File::exists($extractDir . '/composer.json') || File::exists($extractDir . '/composer.lock'); } /** * @return array */ private function resolveEntries(): array { $raw = trim((string) config('distribution.http_resolve', '')); if ($raw === '') { return []; } $entries = array_values(array_filter(array_map('trim', explode(',', $raw)), static fn(string $v): bool => $v !== '')); $valid = []; foreach ($entries as $entry) { if (preg_match('/^[^:\s]+:\d{1,5}:\d{1,3}(?:\.\d{1,3}){3}$/', $entry) === 1) { $valid[] = $entry; continue; } $this->warn('Entry NETGESCON_UPDATE_RESOLVE ignorata (formato non valido): ' . $entry); } return $valid; } /** * @param array $resolveEntries * @return array */ private function httpOptions(bool $stream, array $resolveEntries): array { $options = ['stream' => $stream]; if ($resolveEntries !== []) { $options['curl'] = [ CURLOPT_RESOLVE => $resolveEntries, ]; } return $options; } }