netgescon-day0/app/Console/Commands/NetgesconDistributionPullCommand.php

303 lines
12 KiB
PHP

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use ZipArchive;
class NetgesconDistributionPullCommand extends Command
{
protected $signature = 'netgescon:update
{--channel= : Canale update (free/licensed)}
{--code= : Codice autorizzazione licensed}
{--auth= : Token autorizzazione licensed}
{--apply : Applica update se disponibile}
{--dry-run : Simula senza scrivere file}
{--force : Applica anche se versione uguale o inferiore}
{--progress-file= : File JSON dove scrivere lo stato avanzamento}';
protected $description = 'Controlla e applica aggiornamenti NetGescon dal server distribuzione';
public function handle(): int
{
$this->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<string,mixed> $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<int, string>
*/
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<int, string> $resolveEntries
* @return array<string, mixed>
*/
private function httpOptions(bool $stream, array $resolveEntries): array
{
$options = ['stream' => $stream];
if ($resolveEntries !== []) {
$options['curl'] = [
CURLOPT_RESOLVE => $resolveEntries,
];
}
return $options;
}
}