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": {