Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.32% covered (warning)
86.32%
82 / 95
40.00% covered (danger)
40.00%
2 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
UpdateManagerService
86.17% covered (warning)
86.17%
81 / 94
40.00% covered (danger)
40.00%
2 / 5
26.65
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 update
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
1 / 1
6
 readManifestFromZip
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
5.15
 executeRollback
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
5.12
 purgeCache
52.63% covered (warning)
52.63%
10 / 19
0.00% covered (danger)
0.00%
0 / 1
14.80
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Updater\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Updater\Dto\UpdateManifestDto;
12use App\Core\Updater\Exception\UpdaterException;
13use App\Core\Updater\Security\ZipSecurityGuard;
14use App\Core\Updater\Validator\ManifestValidator;
15use FilesystemIterator;
16use RecursiveDirectoryIterator;
17use RecursiveIteratorIterator;
18use Throwable;
19use Yiisoft\Files\FileHelper;
20use ZipArchive;
21
22/**
23 * High-level orchestrator service coordinating the complete platform update process.
24 *
25 * @package App\Core\Updater\Service
26 */
27final class UpdateManagerService
28{
29    /**
30     * UpdateManagerService constructor.
31     *
32     * @param string $projectRoot Application root directory.
33     * @param DatabaseMigrationRunner $migrationRunner SQL migration runner.
34     * @param FileOperationsService|null $fileOps File extraction and copy service.
35     * @param RollbackService|null $rollbackService Pre-update backup and restoration service.
36     * @param ManifestValidator|null $validator Environment and manifest validator.
37     * @param ZipSecurityGuard|null $securityGuard Archive security validator.
38     */
39    public function __construct(
40        private readonly string $projectRoot,
41        private readonly DatabaseMigrationRunner $migrationRunner,
42        private readonly ?FileOperationsService $fileOps = null,
43        private readonly ?RollbackService $rollbackService = null,
44        private readonly ?ManifestValidator $validator = null,
45        private readonly ?ZipSecurityGuard $securityGuard = null
46    ) {
47    }
48
49    /**
50     * Executes the complete platform update workflow from a ZIP archive.
51     *
52     * @param string $zipPackagePath Absolute path to the update ZIP package.
53     * @param string $profile Application profile (admin or client).
54     * @param callable|null $progress Optional progress reporter callback.
55     * @return array{
56     *     success: bool,
57     *     from_version: ?string,
58     *     to_version: string,
59     *     files_updated: int,
60     *     files_deleted: int,
61     *     backup_path: string
62     * }
63     * @throws RuntimeException If any stage fails (automatic rollback will be executed).
64     */
65    public function update(string $zipPackagePath, string $profile = 'admin', ?callable $progress = null): array
66    {
67        $report = static function (string $step, string $msg) use ($progress): void {
68            if ($progress !== null) {
69                $progress($step, $msg);
70            }
71        };
72
73        $security = $this->securityGuard ?? new ZipSecurityGuard();
74        $validator = $this->validator ?? new ManifestValidator();
75        $fileOps = $this->fileOps ?? new FileOperationsService($this->projectRoot, $security);
76        $rollback = $this->rollbackService ?? new RollbackService($this->projectRoot, $security);
77
78        // 1. Inspect and parse manifest from ZIP
79        $report('inspect', 'Validating update archive integrity and inspecting manifest...');
80        $security->validateArchive($zipPackagePath);
81        $manifest = $this->readManifestFromZip($zipPackagePath);
82
83        // 2. Validate environment & semver requirements
84        $report('validate', sprintf('Validating system requirements for target version %s...', $manifest->version));
85        $currentVersion = $this->migrationRunner->getCurrentVersion();
86        $errors = $validator->validate($manifest, $currentVersion);
87        if ($errors !== []) {
88            throw new UpdaterException('Manifest validation failed: ' . implode('; ', $errors));
89        }
90
91        // 3. Prepare staging and backup paths
92        $stagingDir = $this->projectRoot . '/storage/updates/staging_' . uniqid('upd_', true);
93        $backupDir = $this->projectRoot . '/storage/backups';
94
95        $report('staging', 'Extracting update package to staging directory...');
96        $fileOps->extractToStaging($zipPackagePath, $stagingDir);
97
98        $report('backup', 'Creating safety snapshot and pre-update backup of modified files...');
99        $backupPath = $rollback->createPreUpdateBackup($manifest, $backupDir);
100
101        $filesReport = ['added_or_updated' => 0, 'deleted' => 0];
102
103        try {
104            // 4. Apply file changes
105            $report('files_apply', 'Applying file modifications, additions, and deletions...');
106            $filesReport = $fileOps->applyChanges($stagingDir, $manifest);
107
108            // 5. Execute database SQL upgrade if present
109            if ($manifest->sqlUp !== null && $manifest->sqlUp !== '') {
110                $sqlUpFile = $stagingDir . '/' . ltrim(str_replace('\\', '/', $manifest->sqlUp), '/');
111                $report(
112                    'database_up',
113                    sprintf('Executing database upgrade script (%s)...', basename($manifest->sqlUp))
114                );
115                $this->migrationRunner->runUpScript($sqlUpFile, $manifest->version, basename($manifest->sqlUp));
116            }
117
118            // 6. Record new system version
119            $packageChecksum = (string) hash_file('sha256', $zipPackagePath);
120            $this->migrationRunner->recordSystemVersion($manifest->version, $profile, $packageChecksum);
121
122            // 7. Purge caches
123            $report('cache_clear', 'Purging runtime cache and opcache...');
124            $this->purgeCache();
125        } catch (Throwable $e) {
126            $report('error', 'Update encountered an error: ' . $e->getMessage() . '. Triggering rollback...');
127            $this->executeRollback($rollback, $backupPath, $manifest, $stagingDir);
128            $fileOps->cleanStaging($stagingDir);
129
130            throw new UpdaterException('Update failed and was rolled back: ' . $e->getMessage(), 0, $e);
131        }
132
133        // 8. Clean staging
134        $fileOps->cleanStaging($stagingDir);
135        $report('completed', sprintf('System successfully updated to version %s.', $manifest->version));
136
137        return [
138            'success' => true,
139            'from_version' => $currentVersion,
140            'to_version' => $manifest->version,
141            'files_updated' => $filesReport['added_or_updated'],
142            'files_deleted' => $filesReport['deleted'],
143            'backup_path' => $backupPath,
144        ];
145    }
146
147    /**
148     * Reads and decodes manifest.json directly from ZIP archive.
149     */
150    private function readManifestFromZip(string $zipPath): UpdateManifestDto
151    {
152        $zip = new ZipArchive();
153        if ($zip->open($zipPath, ZipArchive::RDONLY) !== true) {
154            throw new UpdaterException(sprintf('Failed to read manifest from ZIP: %s', $zipPath));
155        }
156
157        $manifestContent = $zip->getFromName('manifest.json');
158        $zip->close();
159
160        if ($manifestContent === false || trim($manifestContent) === '') {
161            throw new UpdaterException('Archive does not contain a valid manifest.json.');
162        }
163
164        /** @var array<string, mixed> $data */
165        $data = json_decode($manifestContent, true);
166        if (!is_array($data)) {
167            throw new UpdaterException('Manifest JSON syntax error in archive.');
168        }
169
170        return UpdateManifestDto::fromArray($data);
171    }
172
173    /**
174     * Executes automatic database and filesystem rollback.
175     */
176    private function executeRollback(
177        RollbackService $rollback,
178        string $backupPath,
179        UpdateManifestDto $manifest,
180        string $stagingDir
181    ): void {
182        try {
183            // Rollback database if down script exists
184            if ($manifest->sqlDown !== null && $manifest->sqlDown !== '') {
185                $sqlDownFile = $stagingDir . '/' . ltrim(str_replace('\\', '/', $manifest->sqlDown), '/');
186                if (file_exists($sqlDownFile)) {
187                    $this->migrationRunner->runDownScript(
188                        $sqlDownFile,
189                        $manifest->version,
190                        basename($manifest->sqlDown)
191                    );
192                }
193            }
194
195            // Restore filesystem
196            $rollback->restoreFromBackup($backupPath);
197            $this->purgeCache();
198        } catch (Throwable $rollbackException) {
199            // Log catastrophic rollback failure
200            error_log('Critical: Rollback execution failed: ' . $rollbackException->getMessage());
201        }
202    }
203
204    /**
205     * Purges runtime caches and opcode cache.
206     */
207    private function purgeCache(): void
208    {
209        if (function_exists('opcache_reset')) {
210            @opcache_reset();
211        }
212
213        $cacheDirs = [
214            $this->projectRoot . '/storage/cache',
215            $this->projectRoot . '/storage/runtime',
216            $this->projectRoot . '/runtime/cache',
217        ];
218
219        foreach ($cacheDirs as $cacheDir) {
220            if (!is_dir($cacheDir)) {
221                continue;
222            }
223            $iterator = new RecursiveIteratorIterator(
224                new RecursiveDirectoryIterator($cacheDir, FilesystemIterator::SKIP_DOTS),
225                RecursiveIteratorIterator::CHILD_FIRST
226            );
227            foreach ($iterator as $item) {
228                if ($item->isDir()) {
229                    @rmdir($item->getPathname());
230                } elseif ($item->isFile() && $item->getFilename() !== '.gitkeep') {
231                    FileHelper::unlink($item->getPathname());
232                }
233            }
234        }
235    }
236}