diff --git a/hesabixCore/.env b/hesabixCore/.env index 848cc47..a5efe09 100644 --- a/hesabixCore/.env +++ b/hesabixCore/.env @@ -26,3 +26,9 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 ###> nelmio/cors-bundle ### CORS_ALLOW_ORIGIN='*' ###< nelmio/cors-bundle ### + +###> symfony/lock ### +# Choose one of the stores below +# postgresql+advisory://db_user:db_password@localhost/db_name +LOCK_DSN=flock +###< symfony/lock ### diff --git a/hesabixCore/composer.json b/hesabixCore/composer.json index d1c70fd..b1f4b83 100644 --- a/hesabixCore/composer.json +++ b/hesabixCore/composer.json @@ -33,6 +33,7 @@ "symfony/form": "7.1.*", "symfony/framework-bundle": "7.1.*", "symfony/http-client": "7.1.*", + "symfony/lock": "7.1.*", "symfony/mailer": "7.1.*", "symfony/mime": "7.1.*", "symfony/monolog-bundle": "^3.0", diff --git a/hesabixCore/composer.lock b/hesabixCore/composer.lock index a68be19..5e6fb69 100644 --- a/hesabixCore/composer.lock +++ b/hesabixCore/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "66bf4d4239acf870e6cf14109284dd59", + "content-hash": "b5f9aa88af7c562b82e1bcf67a995845", "packages": [ { "name": "composer/pcre", @@ -5893,6 +5893,84 @@ ], "time": "2025-01-29T07:34:05+00:00" }, + { + "name": "symfony/lock", + "version": "v7.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/lock.git", + "reference": "1b898398007d80b4f32128df4b4f0c07c0368cf4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/lock/zipball/1b898398007d80b4f32128df4b4f0c07c0368cf4", + "reference": "1b898398007d80b4f32128df4b4f0c07c0368cf4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Lock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jérémy Derussé", + "email": "jeremy@derusse.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Creates and manages locks, a mechanism to provide exclusive access to a shared resource", + "homepage": "https://symfony.com", + "keywords": [ + "cas", + "flock", + "locking", + "mutex", + "redlock", + "semaphore" + ], + "support": { + "source": "https://github.com/symfony/lock/tree/v7.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-10-25T15:34:21+00:00" + }, { "name": "symfony/mailer", "version": "v7.1.11", diff --git a/hesabixCore/config/packages/lock.yaml b/hesabixCore/config/packages/lock.yaml new file mode 100644 index 0000000..574879f --- /dev/null +++ b/hesabixCore/config/packages/lock.yaml @@ -0,0 +1,2 @@ +framework: + lock: '%env(LOCK_DSN)%' diff --git a/hesabixCore/config/services.yaml b/hesabixCore/config/services.yaml index 0e18016..fe07298 100644 --- a/hesabixCore/config/services.yaml +++ b/hesabixCore/config/services.yaml @@ -14,6 +14,25 @@ services: _defaults: autowire: true # Automatically injects dependencies in your services. autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + public: false + + App\Command\UpdateSoftwareCommand: + arguments: + $logger: '@Psr\Log\LoggerInterface' + $projectDir: '%kernel.project_dir%' + $lockFactory: '@Symfony\Component\Lock\LockFactory' + tags: + - { name: 'console.command' } + # تنظیمات Lock + Symfony\Component\Lock\LockFactory: + arguments: + $store: '@lock.store.flock' + + lock.store.flock: + class: Symfony\Component\Lock\Store\FlockStore + arguments: + - '%kernel.project_dir%/var/lock' + doctrine.orm.default_attribute_driver: class: Doctrine\ORM\Mapping\Driver\AttributeDriver arguments: diff --git a/hesabixCore/src/Command/ReleaseUpdateLockCommand.php b/hesabixCore/src/Command/ReleaseUpdateLockCommand.php new file mode 100644 index 0000000..acc0f94 --- /dev/null +++ b/hesabixCore/src/Command/ReleaseUpdateLockCommand.php @@ -0,0 +1,28 @@ +lockFactory = $lockFactory; + parent::__construct(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $lock = $this->lockFactory->createLock('software-update'); + $lock->release(); + $output->writeln('Update lock released successfully.'); + return Command::SUCCESS; + } +} \ No newline at end of file diff --git a/hesabixCore/src/Command/UpdateSoftwareCommand.php b/hesabixCore/src/Command/UpdateSoftwareCommand.php new file mode 100644 index 0000000..7c2c589 --- /dev/null +++ b/hesabixCore/src/Command/UpdateSoftwareCommand.php @@ -0,0 +1,360 @@ +logger = $logger; + $this->rootDir = dirname($projectDir); + $this->appDir = $projectDir; + $this->archiveDir = $this->rootDir . '/hesabixArchive'; + $this->backupDir = $this->rootDir . '/../backup'; + $this->stateFile = $this->backupDir . '/update_state.json'; + $this->lockFactory = $lockFactory; + parent::__construct(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $lock = $this->lockFactory->createLock('software-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->logger->info("Starting software update with UUID: $uuid"); + $this->writeOutput($output, "Starting software update (UUID: $uuid)..."); + + if (!is_dir($this->backupDir)) { + mkdir($this->backupDir, 0755, true); + } + + $state = $this->loadState($uuid); + $state['log'] = $state['log'] ?? ''; + $state['completedSteps'] = $state['completedSteps'] ?? []; + + $gitHeadBefore = null; + $cacheBackup = null; + $dbBackup = null; + $archiveBackup = null; + + try { + if (!in_array('pre_checks', $state['completedSteps'])) { + $this->preUpdateChecks($output); + $state['completedSteps'][] = 'pre_checks'; + $this->saveState($uuid, $state); + } + + if (!in_array('archive_backup', $state['completedSteps'])) { + $this->writeOutput($output, 'Backing up hesabixArchive...'); + $archiveBackup = $this->backupArchive(); + $state['archiveBackup'] = $archiveBackup; + $archiveHashBefore = $this->getDirectoryHash($this->archiveDir); + $state['archiveHashBefore'] = $archiveHashBefore; + $state['completedSteps'][] = 'archive_backup'; + $this->saveState($uuid, $state); + } else { + $archiveBackup = $state['archiveBackup']; + $archiveHashBefore = $state['archiveHashBefore']; + } + + if (!in_array('git_pull', $state['completedSteps'])) { + $this->writeOutput($output, 'Pulling latest changes from GitHub...'); + $gitHeadBefore = $this->getCurrentGitHead(); + $this->runProcess(['git', 'pull'], $this->rootDir, $output, 3); + $state['gitHeadBefore'] = $gitHeadBefore; + $state['completedSteps'][] = 'git_pull'; + $this->saveState($uuid, $state); + } else { + $gitHeadBefore = $state['gitHeadBefore']; + } + + if (!in_array('composer_install', $state['completedSteps'])) { + $this->writeOutput($output, 'Installing dependencies...'); + $this->runProcess(['composer', 'install', '--no-dev', '--optimize-autoloader'], $this->appDir, $output, 3); + $state['completedSteps'][] = 'composer_install'; + $this->saveState($uuid, $state); + } + + if (!in_array('cache_clear', $state['completedSteps'])) { + $this->writeOutput($output, 'Clearing cache...'); + $cacheDir = $this->appDir . '/var/cache'; + $cacheBackup = $this->backupCache($cacheDir); + $state['cacheBackup'] = $cacheBackup; + $this->runProcess(['php', 'bin/console', 'cache:clear', '--env=prod'], $this->appDir, $output, 3); + $state['completedSteps'][] = 'cache_clear'; + $this->saveState($uuid, $state); + } else { + $cacheBackup = $state['cacheBackup']; + } + + if (!in_array('db_update', $state['completedSteps'])) { + $this->writeOutput($output, 'Updating database schema...'); + $dbBackup = $this->backupDatabase(); + $state['dbBackup'] = $dbBackup; + $this->runProcess(['php', 'bin/console', 'doctrine:schema:update', '--force', '--no-interaction'], $this->appDir, $output, 3); + $state['completedSteps'][] = 'db_update'; + $this->saveState($uuid, $state); + } else { + $dbBackup = $state['dbBackup']; + } + + if (!in_array('archive_check', $state['completedSteps'])) { + $archiveHashAfter = $this->getDirectoryHash($this->archiveDir); + if ($archiveHashBefore !== $archiveHashAfter) { + $this->writeOutput($output, 'hesabixArchive has changed, restoring from backup...'); + $this->restoreArchive($archiveBackup); + $this->writeOutput($output, 'hesabixArchive restored successfully.'); + } else { + $this->writeOutput($output, 'hesabixArchive unchanged, no restore needed.'); + } + $state['completedSteps'][] = 'archive_check'; + $this->saveState($uuid, $state); + } + + if (!in_array('post_update_test', $state['completedSteps'])) { + $this->postUpdateTest($output); + $state['completedSteps'][] = 'post_update_test'; + $this->saveState($uuid, $state); + } + + $version = $this->getPackageVersion(); + $this->writeOutput($output, "Software updated to version: $version"); + $state['version'] = $version; + + $this->logger->info('Software update completed successfully!'); + $this->writeOutput($output, 'Software update completed successfully!'); + $this->saveState($uuid, $state); + return Command::SUCCESS; + } catch (\Exception $e) { + $this->logger->error('Update failed: ' . $e->getMessage()); + $this->writeOutput($output, 'An error occurred: ' . $e->getMessage() . ''); + $this->rollback($gitHeadBefore, $cacheBackup, $dbBackup, $archiveBackup, $output); + $this->writeOutput($output, 'Update process aborted and rolled back.'); + $state['error'] = $e->getMessage(); + $this->saveState($uuid, $state); + return Command::FAILURE; + } finally { + $this->cleanupBackups($cacheBackup, $dbBackup, $archiveBackup); + $lock->release(); + if (file_exists($this->stateFile)) { + unlink($this->stateFile); + } + } + } + + private function writeOutput(OutputInterface $output, string $message): void + { + $output->writeln($message); + if (PHP_SAPI === 'cli' && $output instanceof \Symfony\Component\Console\Output\StreamOutput && $output->getStream()) { + ob_flush(); + flush(); + } + } + + private function runProcess(array $command, string $workingDir, OutputInterface $output, int $retries = 3): void + { + $attempt = 0; + while ($attempt < $retries) { + try { + $process = new Process($command, $workingDir); + $process->setTimeout(3600); + $process->mustRun(function ($type, $buffer) use ($output) { + $this->writeOutput($output, $buffer); + }); + $this->logger->info('Command executed successfully: ' . implode(' ', $command)); + return; + } catch (ProcessFailedException $e) { + $attempt++; + $errorMessage = $e->getProcess()->getErrorOutput() ?: $e->getMessage(); + $this->logger->warning("Attempt $attempt failed for " . implode(' ', $command) . ": $errorMessage"); + $this->writeOutput($output, "Attempt $attempt failed: $errorMessage"); + if ($attempt === $retries) { + throw new \RuntimeException('Command "' . implode(' ', $command) . '" failed after ' . $retries . ' attempts: ' . $errorMessage); + } + sleep(5); + } + } + } + + private function preUpdateChecks(OutputInterface $output): void + { + $this->writeOutput($output, 'Running pre-update checks...'); + $this->runProcess(['git', 'fetch'], $this->rootDir, $output); + $this->writeOutput($output, 'Git repository accessible.'); + $this->runProcess(['php', 'bin/console', 'doctrine:query:sql', 'SELECT 1'], $this->appDir, $output); + $this->writeOutput($output, 'Database connection OK.'); + } + + private function postUpdateTest(OutputInterface $output): void + { + $this->writeOutput($output, 'Running post-update tests...'); + $this->runProcess(['php', 'bin/console', 'cache:warmup', '--env=prod'], $this->appDir, $output); + $this->writeOutput($output, 'Application tested and warmed up successfully.'); + } + + private function getPackageVersion(): string + { + $packageJson = json_decode(file_get_contents($this->appDir . '/package.json'), true); + return $packageJson['version'] ?? 'unknown'; + } + + private function getCurrentGitHead(): string + { + $process = new Process(['git', 'rev-parse', 'HEAD'], $this->rootDir); + $process->run(); + return trim($process->getOutput()); + } + + private function backupCache(string $cacheDir): string + { + $backupDir = $this->backupDir . '/cache_backup_' . time(); + $this->runProcess(['cp', '-r', $cacheDir, $backupDir], $this->rootDir, new \Symfony\Component\Console\Output\NullOutput()); + return $backupDir; + } + + private function backupDatabase(): string + { + $backupFile = $this->backupDir . '/db_backup_' . time() . '.sql'; + $this->runProcess(['php', 'bin/console', 'dbal:database:dump', '--file=' . $backupFile], $this->appDir, new \Symfony\Component\Console\Output\NullOutput()); + return $backupFile; + } + + private function backupArchive(): string + { + $tarFile = $this->backupDir . '/hesabixArchive_backup_' . time() . '.tar'; + $this->runProcess(['tar', '-cf', $tarFile, '-C', $this->rootDir, 'hesabixArchive'], $this->rootDir, new \Symfony\Component\Console\Output\NullOutput()); + if (!file_exists($tarFile)) { + throw new \RuntimeException('Failed to create tar backup of hesabixArchive.'); + } + return $tarFile; + } + + private function restoreArchive(string $backupFile): void + { + $this->runProcess(['rm', '-rf', $this->archiveDir], $this->rootDir, new \Symfony\Component\Console\Output\NullOutput()); + $this->runProcess(['mkdir', $this->archiveDir], $this->rootDir, new \Symfony\Component\Console\Output\NullOutput()); + $this->runProcess(['tar', '-xf', $backupFile, '-C', $this->rootDir], $this->rootDir, new \Symfony\Component\Console\Output\NullOutput()); + if (!is_dir($this->archiveDir)) { + throw new \RuntimeException('Failed to restore hesabixArchive from tar backup.'); + } + } + + private function getDirectoryHash(string $dir): string + { + if (!is_dir($dir)) { + return ''; + } + $files = []; + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir)); + foreach ($iterator as $file) { + if ($file->isFile()) { + $files[] = $file->getPathname(); + } + } + sort($files); + $hash = ''; + foreach ($files as $file) { + $hash .= md5_file($file); + } + return md5($hash); + } + + private function rollback(?string $gitHeadBefore, ?string $cacheBackup, ?string $dbBackup, ?string $archiveBackup, OutputInterface $output): void + { + $this->writeOutput($output, 'Rolling back changes...'); + + if ($gitHeadBefore) { + try { + $this->runProcess(['git', 'reset', '--hard', $gitHeadBefore], $this->rootDir, $output); + $this->logger->info('Git rolled back to ' . $gitHeadBefore); + } catch (\Exception $e) { + $this->logger->error('Git rollback failed: ' . $e->getMessage()); + } + } + + if ($cacheBackup) { + try { + $this->runProcess(['rm', '-rf', $this->appDir . '/var/cache'], $this->rootDir, $output); + $this->runProcess(['cp', '-r', $cacheBackup, $this->appDir . '/var/cache'], $this->rootDir, $output); + $this->logger->info('Cache rolled back'); + } catch (\Exception $e) { + $this->logger->error('Cache rollback failed: ' . $e->getMessage()); + } + } + + if ($dbBackup) { + try { + $this->runProcess(['php', 'bin/console', 'dbal:database:import', $dbBackup], $this->appDir, $output); + $this->logger->info('Database rolled back'); + } catch (\Exception $e) { + $this->logger->error('Database rollback failed: ' . $e->getMessage()); + } + } + + if ($archiveBackup) { + try { + $this->restoreArchive($archiveBackup); + $this->logger->info('hesabixArchive rolled back'); + } catch (\Exception $e) { + $this->logger->error('Archive rollback failed: ' . $e->getMessage()); + } + } + } + + private function cleanupBackups(?string $cacheBackup, ?string $dbBackup, ?string $archiveBackup): 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); + } + } + + 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) { + return $state; + } + } + return ['uuid' => $uuid, 'log' => '', 'completedSteps' => []]; + } + + private function saveState(string $uuid, array $state): void + { + $state['uuid'] = $uuid; + $state['log'] .= $this->getOutput()->getOutput() . "\n"; + file_put_contents($this->stateFile, json_encode($state)); + } +} \ No newline at end of file diff --git a/hesabixCore/symfony.lock b/hesabixCore/symfony.lock index 3e51f72..e2a367d 100644 --- a/hesabixCore/symfony.lock +++ b/hesabixCore/symfony.lock @@ -156,6 +156,18 @@ "./src/Kernel.php" ] }, + "symfony/lock": { + "version": "7.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "5.2", + "ref": "8e937ff2b4735d110af1770f242c1107fdab4c8e" + }, + "files": [ + "config/packages/lock.yaml" + ] + }, "symfony/mailer": { "version": "6.2", "recipe": {