diff --git a/hesabixCore/src/Command/UpdateSoftwareCommand.php b/hesabixCore/src/Command/UpdateSoftwareCommand.php index 6210791..92b36c1 100644 --- a/hesabixCore/src/Command/UpdateSoftwareCommand.php +++ b/hesabixCore/src/Command/UpdateSoftwareCommand.php @@ -4,6 +4,7 @@ namespace App\Command; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\Exception\ProcessFailedException; @@ -38,43 +39,58 @@ class UpdateSoftwareCommand extends Command $this->rootDir = dirname($this->appDir); $this->archiveDir = $this->rootDir . '/hesabixArchive'; $this->backupDir = $this->rootDir . '/hesabixBackup'; - $this->stateFile = $this->backupDir . '/' . Uuid::uuid4() . '/update_state.json'; $envConfig = file_exists($this->appDir . '/.env.local.php') ? require $this->appDir . '/.env.local.php' : []; $this->env = $envConfig['APP_ENV'] ?? getenv('APP_ENV') ?: 'prod'; $this->logger->info("Environment detected: " . $this->env); parent::__construct(); } + protected function configure(): void + { + $this->addArgument('state-file', InputArgument::OPTIONAL, 'Path to the state file'); + } + protected function execute(InputInterface $input, OutputInterface $output): int { - $lock = $this->lockFactory->createLock('software-update', 3600); + $lock = $this->lockFactory->createLock('hesabix-update', 3600); if (!$lock->acquire()) { $this->writeOutput($output, 'Another update process is currently running. Please try again later.'); $this->logger->warning('Update attempt blocked due to existing lock.'); return Command::FAILURE; } - $uuid = Uuid::uuid4()->toString(); + $this->stateFile = $input->getArgument('state-file') ?? $this->backupDir . '/update_state_' . Uuid::uuid4() . '.json'; + if (!file_exists(dirname($this->stateFile))) { + mkdir(dirname($this->stateFile), 0755, true); + } + + // اگر فایل وضعیت وجود ندارد، آن را ایجاد کن + if (!file_exists($this->stateFile)) { + file_put_contents($this->stateFile, json_encode([ + 'uuid' => Uuid::uuid4()->toString(), + 'log' => '', + 'completedSteps' => [], + ])); + } + + $uuid = json_decode(file_get_contents($this->stateFile), true)['uuid']; + $this->logger->info("Starting software update with UUID: $uuid in {$this->env} mode"); $this->writeOutput($output, "Starting software update (UUID: $uuid) in {$this->env} mode"); + $state = $this->loadState($uuid); + $state['log'] = $state['log'] ?? ''; // اطمینان از وجود کلید log + if ($this->isUpToDate()) { $this->writeOutput($output, 'The software is already up to date with the remote repository.'); $this->logger->info('No update needed, software is up to date.'); + $state['log'] .= "No update needed, software is up to date.\n"; // اضافه کردن مستقیم به لاگ + $state['completedSteps'] = ['post_update_test']; + $this->saveState($uuid, $state, $output, 'No update needed'); $lock->release(); return Command::SUCCESS; } - $stateDir = dirname($this->stateFile); - if (!is_dir($stateDir)) { - $this->logger->debug("Creating state directory: $stateDir"); - if (!mkdir($stateDir, 0755, true) && !is_dir($stateDir)) { - throw new \RuntimeException("Failed to create state directory: $stateDir"); - } - } - - $state = $this->loadState($uuid); - $state['log'] = $state['log'] ?? ''; $state['completedSteps'] = $state['completedSteps'] ?? []; $gitHeadBefore = null; @@ -93,10 +109,7 @@ class UpdateSoftwareCommand extends Command $this->writeOutput($output, 'Backing up hesabixArchive...'); $archiveBackupDir = $this->backupDir . '/' . Uuid::uuid4(); if (!is_dir($archiveBackupDir)) { - $this->logger->debug("Creating archive backup directory: $archiveBackupDir"); - if (!mkdir($archiveBackupDir, 0755, true) && !is_dir($archiveBackupDir)) { - throw new \RuntimeException("Failed to create archive backup directory: $archiveBackupDir"); - } + mkdir($archiveBackupDir, 0755, true); } $archiveBackup = $archiveBackupDir . '/hesabixArchive_backup_' . time() . '.tar'; $this->runProcess(['tar', '-cf', $archiveBackup, '-C', $this->rootDir, 'hesabixArchive'], $this->rootDir, $output, 3); @@ -137,10 +150,7 @@ class UpdateSoftwareCommand extends Command $this->writeOutput($output, 'Clearing cache...'); $cacheBackupDir = $this->backupDir . '/' . Uuid::uuid4(); if (!is_dir($cacheBackupDir)) { - $this->logger->debug("Creating cache backup directory: $cacheBackupDir"); - if (!mkdir($cacheBackupDir, 0755, true) && !is_dir($cacheBackupDir)) { - throw new \RuntimeException("Failed to create cache backup directory: $cacheBackupDir"); - } + mkdir($cacheBackupDir, 0755, true); } $cacheBackup = $cacheBackupDir . '/cache_backup_' . time(); $this->runProcess(['cp', '-r', $this->appDir . '/var/cache', $cacheBackup], $this->rootDir, new \Symfony\Component\Console\Output\NullOutput()); @@ -156,10 +166,7 @@ class UpdateSoftwareCommand extends Command $this->writeOutput($output, 'Updating database schema...'); $dbBackupDir = $this->backupDir . '/' . Uuid::uuid4(); if (!is_dir($dbBackupDir)) { - $this->logger->debug("Creating database backup directory: $dbBackupDir"); - if (!mkdir($dbBackupDir, 0755, true) && !is_dir($dbBackupDir)) { - throw new \RuntimeException("Failed to create database backup directory: $dbBackupDir"); - } + mkdir($dbBackupDir, 0755, true); } $dbBackup = $dbBackupDir . '/db_backup_' . time() . '.sql'; $this->backupDatabaseToFile($dbBackup, $output); @@ -198,6 +205,11 @@ class UpdateSoftwareCommand extends Command $this->logger->info('Software update completed successfully!'); $this->writeOutput($output, 'Software update completed successfully!'); $this->saveState($uuid, $state, $output, 'Update completed successfully'); + + $this->writeOutput($output, 'Cleaning up all temporary directories...'); + $this->cleanupAllTempDirectories(); + + $lock->release(); return Command::SUCCESS; } catch (\Exception $e) { $this->logger->error('Update failed: ' . $e->getMessage()); @@ -206,15 +218,10 @@ class UpdateSoftwareCommand extends Command $this->writeOutput($output, 'Update process aborted and rolled back.'); $state['error'] = $e->getMessage(); $this->saveState($uuid, $state, $output, 'Update failed and rolled back'); + $lock->release(); return Command::FAILURE; } finally { - $this->cleanupBackups($cacheBackup, $dbBackup, $archiveBackup); $lock->release(); - $stateDir = dirname($this->stateFile); - if (is_dir($stateDir)) { - $this->logger->info('Cleaning up state directory: ' . $stateDir); - $this->runProcess(['rm', '-rf', $stateDir], $this->rootDir, new \Symfony\Component\Console\Output\NullOutput()); - } } } @@ -240,6 +247,7 @@ class UpdateSoftwareCommand extends Command }); } else { $process->mustRun(); + $this->writeOutput($output, $process->getOutput()); } $this->logger->info('Command executed successfully: ' . implode(' ', $command)); return; @@ -319,13 +327,9 @@ class UpdateSoftwareCommand extends Command { $backupDir = dirname($backupFile); if (!is_dir($backupDir)) { - $this->logger->debug("Creating database backup directory: $backupDir"); - if (!mkdir($backupDir, 0755, true) && !is_dir($backupDir)) { - throw new \RuntimeException("Failed to create database backup directory: $backupDir"); - } + mkdir($backupDir, 0755, true); } - // خواندن مستقیم از .env.local.php $envConfig = file_exists($this->appDir . '/.env.local.php') ? require $this->appDir . '/.env.local.php' : []; $dbUrl = $envConfig['DATABASE_URL'] ?? getenv('DATABASE_URL'); if (!$dbUrl) { @@ -342,10 +346,8 @@ class UpdateSoftwareCommand extends Command if (in_array($dbScheme, ['mysql', 'mariadb'])) { $command = [ 'mysqldump', - '-h', - $dbHost, - '-u', - $dbUser, + '-h', $dbHost, + '-u', $dbUser, '-p' . $dbPass, $dbName, '--result-file=' . $backupFile @@ -353,16 +355,12 @@ class UpdateSoftwareCommand extends Command } elseif ($dbScheme === 'pgsql') { $command = [ 'pg_dump', - '-h', - $dbHost, - '-U', - $dbUser, - '-d', - $dbName, + '-h', $dbHost, + '-U', $dbUser, + '-d', $dbName, '--no-owner', '--no-privileges', - '-f', - $backupFile + '-f', $backupFile ]; if ($dbPass) { putenv("PGPASSWORD=$dbPass"); @@ -381,7 +379,6 @@ class UpdateSoftwareCommand extends Command $this->logger->info("Database backup created at: $backupFile (scheme: $dbScheme)"); } - private function restoreArchive(string $backupFile): void { $this->runProcess(['rm', '-rf', $this->archiveDir], $this->rootDir, new \Symfony\Component\Console\Output\NullOutput()); @@ -437,11 +434,10 @@ class UpdateSoftwareCommand extends Command if ($dbBackup) { try { - // خواندن مستقیم از .env.local.php $envConfig = file_exists($this->appDir . '/.env.local.php') ? require $this->appDir . '/.env.local.php' : []; $dbUrl = $envConfig['DATABASE_URL'] ?? getenv('DATABASE_URL'); if (!$dbUrl) { - throw new \RuntimeException('Could not determine DATABASE_URL from .env.local.php or environment for rollback.'); + throw new \RuntimeException('Could not determine DATABASE_URL for rollback.'); } $urlParts = parse_url($dbUrl); @@ -454,10 +450,8 @@ class UpdateSoftwareCommand extends Command if (in_array($dbScheme, ['mysql', 'mariadb'])) { $command = [ 'mysql', - '-h', - $dbHost, - '-u', - $dbUser, + '-h', $dbHost, + '-u', $dbUser, '-p' . $dbPass, $dbName ]; @@ -466,14 +460,10 @@ class UpdateSoftwareCommand extends Command } elseif ($dbScheme === 'pgsql') { $command = [ 'psql', - '-h', - $dbHost, - '-U', - $dbUser, - '-d', - $dbName, - '-f', - $dbBackup + '-h', $dbHost, + '-U', $dbUser, + '-d', $dbName, + '-f', $dbBackup ]; if ($dbPass) { putenv("PGPASSWORD=$dbPass"); @@ -501,25 +491,25 @@ class UpdateSoftwareCommand extends Command } } - private function cleanupBackups(?string $cacheBackup, ?string $dbBackup, ?string $archiveBackup): void + private function cleanupAllTempDirectories(): void { - if ($cacheBackup && is_dir($cacheBackup)) { - $this->runProcess(['rm', '-rf', $cacheBackup], $this->rootDir, new \Symfony\Component\Console\Output\NullOutput()); - } - if ($dbBackup && file_exists($dbBackup)) { - unlink($dbBackup); - } - if ($archiveBackup && file_exists($archiveBackup)) { - unlink($archiveBackup); + $directories = glob($this->backupDir . '/*', GLOB_ONLYDIR); + $protectedDirs = ['databasefiles', 'versions']; + + foreach ($directories as $dir) { + $dirName = basename($dir); + if (!in_array($dirName, $protectedDirs)) { + $this->logger->info("Removing temporary directory: $dir"); + $this->runProcess(['rm', '-rf', $dir], $this->rootDir, new \Symfony\Component\Console\Output\NullOutput()); + } } } private function loadState(string $uuid): array { - $file = $this->stateFile; - if (file_exists($file)) { - $state = json_decode(file_get_contents($file), true); - if ($state['uuid'] === $uuid) { + if (file_exists($this->stateFile)) { + $state = json_decode(file_get_contents($this->stateFile), true); + if ($state && $state['uuid'] === $uuid) { return $state; } } @@ -529,14 +519,7 @@ class UpdateSoftwareCommand extends Command private function saveState(string $uuid, array $state, OutputInterface $output, string $message): void { $state['uuid'] = $uuid; - $state['log'] .= $output->getVerbosity() >= OutputInterface::VERBOSITY_NORMAL ? $message . "\n" : ''; - $stateDir = dirname($this->stateFile); - if (!is_dir($stateDir)) { - $this->logger->debug("Creating state directory: $stateDir"); - if (!mkdir($stateDir, 0755, true) && !is_dir($stateDir)) { - throw new \RuntimeException("Failed to create state directory: $stateDir"); - } - } + $state['log'] = ($state['log'] ?? '') . ($output->getVerbosity() >= OutputInterface::VERBOSITY_NORMAL ? $message . "\n" : ''); file_put_contents($this->stateFile, json_encode($state, JSON_PRETTY_PRINT)); $this->logger->debug('State saved to ' . $this->stateFile); } diff --git a/hesabixCore/src/Controller/System/UpdateCoreController.php b/hesabixCore/src/Controller/System/UpdateCoreController.php new file mode 100644 index 0000000..2f1bd87 --- /dev/null +++ b/hesabixCore/src/Controller/System/UpdateCoreController.php @@ -0,0 +1,210 @@ +connection = $connection; + } + + #[Route('/api/admin/updatecore/run', name: 'api_admin_updatecore_run', methods: ['POST'])] + public function api_admin_updatecore_run(): JsonResponse + { + $projectDir = $this->getParameter('kernel.project_dir'); + $uuid = uniqid(); + $stateFile = $projectDir . '/../backup/update_state_' . $uuid . '.json'; + + if (!file_exists(dirname($stateFile))) { + mkdir(dirname($stateFile), 0755, true); + } + + file_put_contents($stateFile, json_encode([ + 'uuid' => $uuid, + 'log' => 'Update process started at ' . date('Y-m-d H:i:s') . "\n", + 'completedSteps' => [], + ])); + + $process = new Process(['php', 'bin/console', 'hesabix:update', $stateFile], $projectDir); + $process->setTimeout(3600); + $process->run(function ($type, $buffer) use ($stateFile) { + $state = json_decode(file_get_contents($stateFile), true) ?? ['uuid' => uniqid(), 'log' => '']; + $state['log'] .= $buffer; + file_put_contents($stateFile, json_encode($state)); + }); + + $state = json_decode(file_get_contents($stateFile), true) ?? ['uuid' => $uuid, 'log' => '']; + + if (!$process->isSuccessful()) { + $state['error'] = $process->getErrorOutput() ?: 'Unknown error'; + file_put_contents($stateFile, json_encode($state)); + return new JsonResponse([ + 'status' => 'error', + 'message' => 'Update process failed: ' . $state['error'], + 'uuid' => $uuid, + ], 500); + } + + $state['log'] .= $process->getOutput() . $process->getErrorOutput(); + file_put_contents($stateFile, json_encode($state)); + + return new JsonResponse([ + 'status' => 'started', + 'message' => 'Update process started', + 'uuid' => $uuid, + ]); + } + + #[Route('/api/admin/updatecore/status', name: 'api_admin_updatecore_status', methods: ['GET'])] + public function api_admin_updatecore_status(Request $request): JsonResponse + { + $uuid = $request->query->get('uuid'); + if (!$uuid) { + return new JsonResponse([ + 'status' => 'error', + 'message' => 'UUID is required', + 'output' => '', + ], 400); + } + + $stateFile = $this->getParameter('kernel.project_dir') . '/../backup/update_state_' . $uuid . '.json'; + + if (!file_exists($stateFile)) { + return new JsonResponse([ + 'status' => 'idle', + 'message' => 'No update process is currently running', + 'output' => '', + ]); + } + + $state = json_decode(file_get_contents($stateFile), true) ?? ['log' => '']; + $output = $state['log'] ?? ''; + + $isRunning = !isset($state['error']) && + !in_array('post_update_test', $state['completedSteps'] ?? []) && + !str_contains($output, 'No update needed') && + !str_contains($output, 'Software update completed successfully'); + + if ($state['error'] ?? false) { + return new JsonResponse([ + 'status' => 'error', + 'message' => 'Update failed: ' . $state['error'], + 'output' => $output, + ]); + } + + if (!$isRunning) { + return new JsonResponse([ + 'status' => 'success', + 'message' => 'Update completed successfully', + 'output' => $output, + 'commit_hash' => $state['commit_hash'] ?? 'unknown', + ]); + } + + return new JsonResponse([ + 'status' => 'running', + 'message' => 'Update is in progress', + 'output' => $output, + ]); + } + + #[Route('/api/admin/updatecore/commits', name: 'api_admin_updatecore_commits', methods: ['GET'])] + public function api_admin_updatecore_commits(): JsonResponse + { + $projectDir = $this->getParameter('kernel.project_dir'); + + $currentProcess = new Process(['git', 'rev-parse', 'HEAD'], $projectDir); + $currentProcess->run(); + $currentCommit = $currentProcess->isSuccessful() ? trim($currentProcess->getOutput()) : 'unknown'; + + $targetProcess = new Process(['git', 'ls-remote', 'origin', 'HEAD'], $projectDir); + $targetProcess->run(); + $targetOutput = $targetProcess->isSuccessful() ? explode("\t", trim($targetProcess->getOutput()))[0] : 'unknown'; + + return new JsonResponse([ + 'currentCommit' => $currentCommit, + 'targetCommit' => $targetOutput, + ]); + } + + #[Route('/api/admin/updatecore/system-info', name: 'api_admin_updatecore_system_info', methods: ['GET'])] + public function api_admin_updatecore_system_info(): JsonResponse + { + // اطلاعات سیستم‌عامل + $osName = php_uname('s'); + $osRelease = php_uname('r'); + $osVersion = php_uname('v'); + $osMachine = php_uname('m'); + + // اطلاعات پردازنده + $cpuInfo = 'Unknown'; + if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { + $cpuInfo = shell_exec('wmic cpu get caption') ?? 'Unknown'; + } else { + $cpuInfo = shell_exec('cat /proc/cpuinfo | grep "model name" | head -n 1') ?? 'Unknown'; + $cpuInfo = str_replace('model name : ', '', trim($cpuInfo)); + } + + // اطلاعات توزیع لینوکس + $distroName = 'Unknown'; + $distroVersion = 'Unknown'; + if (strtoupper(PHP_OS) === 'LINUX') { + $distroInfo = shell_exec('cat /etc/os-release | grep -E "^(NAME|VERSION)="') ?? ''; + if ($distroInfo) { + preg_match('/NAME="([^"]+)"/', $distroInfo, $nameMatch); + preg_match('/VERSION="([^"]+)"/', $distroInfo, $versionMatch); + $distroName = $nameMatch[1] ?? 'Unknown'; + $distroVersion = $versionMatch[1] ?? 'Unknown'; + } + } + + // اطلاعات وب‌سرور + $webServer = $_SERVER['SERVER_SOFTWARE'] ?? 'Unknown'; + + // اطلاعات بانک اطلاعاتی + $dbVersion = 'Unknown'; + $dbName = 'Unknown'; + try { + $dbParams = $this->connection->getParams(); + $dbName = $dbParams['driver'] ?? 'Unknown'; + + // گرفتن نسخه بانک اطلاعاتی با کوئری SQL + if (str_contains($dbName, 'mysql')) { + $dbVersion = $this->connection->fetchOne('SELECT VERSION()'); + } elseif (str_contains($dbName, 'pgsql')) { + $dbVersion = $this->connection->fetchOne('SHOW server_version'); + } elseif (str_contains($dbName, 'sqlite')) { + $dbVersion = $this->connection->fetchOne('SELECT sqlite_version()'); + } else { + $dbVersion = 'Unsupported database type'; + } + } catch (\Exception $e) { + $dbVersion = 'Error fetching DB version: ' . $e->getMessage(); + $dbName = 'Error fetching DB name'; + } + + return new JsonResponse([ + 'osName' => $osName, + 'osRelease' => $osRelease, + 'osVersion' => $osVersion, + 'osMachine' => $osMachine, + 'cpuInfo' => $cpuInfo, + 'distroName' => $distroName, + 'distroVersion' => $distroVersion, + 'webServer' => $webServer, + 'dbName' => $dbName, + 'dbVersion' => $dbVersion, + ]); + } +} \ No newline at end of file