281 lines
11 KiB
PHP
281 lines
11 KiB
PHP
<?php
|
|
namespace App\Console\Commands;
|
|
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Http;
|
|
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'));
|
|
}
|
|
|
|
$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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
}
|