Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
18.73% covered (danger)
18.73%
53 / 283
23.53% covered (danger)
23.53%
4 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
UpdateRunner
18.73% covered (danger)
18.73%
53 / 283
23.53% covered (danger)
23.53%
4 / 17
2624.77
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
 run
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
3.00
 runDryRun
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 displayDryRunReport
0.00% covered (danger)
0.00%
0 / 95
0.00% covered (danger)
0.00%
0 / 1
702
 truncateString
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 formatValue
88.89% covered (success)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 shortenPath
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 taskHealthCheck
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
3.10
 taskUpdateCheck
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 taskDownloadPackage
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 taskExtractPackage
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 taskCreateTemporaryBackup
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 taskInstallPackage
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 taskUpdateDatabase
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 displayMigrationResults
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 taskCleanup
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 withProgress
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3/**
4 * The Upgrade runner class
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2025-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2025-10-18
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Setup;
21
22use DateTime;
23use DateTimeInterface;
24use Exception;
25use phpMyFAQ\Administration\RemoteApiClient;
26use phpMyFAQ\Configuration;
27use phpMyFAQ\Setup\Migration\MigrationResult;
28use phpMyFAQ\System;
29use phpMyFAQ\Translation;
30use Symfony\Component\Console\Command\Command;
31use Symfony\Component\Console\Style\SymfonyStyle;
32use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
33use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
34use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
35use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
36use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
37use Throwable;
38
39/* @mago-expect lint:kan-defect - the dry-run report renders every operation type inline; split planned with the update rework */
40final class UpdateRunner
41{
42    public function __construct(
43        private readonly Configuration $configuration,
44        private readonly System $system,
45    ) {
46    }
47
48    public function run(SymfonyStyle $symfonyStyle): int
49    {
50        $steps = [
51            'taskHealthCheck',
52            'taskUpdateCheck',
53            'taskDownloadPackage',
54            'taskExtractPackage',
55            'taskCreateTemporaryBackup',
56            'taskInstallPackage',
57            'taskUpdateDatabase',
58            'taskCleanup',
59        ];
60
61        foreach ($steps as $step) {
62            $result = $this->{$step}($symfonyStyle);
63            if (Command::SUCCESS !== $result) {
64                return Command::FAILURE;
65            }
66        }
67
68        return Command::SUCCESS;
69    }
70
71    /**
72     * Runs the update in dry-run mode, showing what would be executed without making changes.
73     */
74    public function runDryRun(SymfonyStyle $symfonyStyle, string $fromVersion): int
75    {
76        $symfonyStyle->title('phpMyFAQ Migration Dry-Run Report');
77        $symfonyStyle->text(sprintf('Current version: %s', $fromVersion));
78        $symfonyStyle->text(sprintf('Target version: %s', System::getVersion()));
79        $symfonyStyle->newLine();
80
81        $update = new Update($this->system, $this->configuration);
82        $update->version = $fromVersion;
83        $update->dryRun = true;
84
85        try {
86            $result = $update->applyUpdates();
87
88            // Check if applyUpdates returned false (indicating failure)
89            if ($result === false) {
90                $symfonyStyle->error('Dry-run failed: One or more migrations did not succeed.');
91                return Command::FAILURE;
92            }
93
94            // Only proceed if applyUpdates succeeded
95            $report = $update->getDryRunResults();
96
97            $this->displayDryRunReport($symfonyStyle, $report);
98
99            return Command::SUCCESS;
100        } catch (Exception $exception) {
101            $symfonyStyle->error('Dry-run failed: ' . $exception->getMessage());
102            return Command::FAILURE;
103        }
104    }
105
106    /**
107     * Displays the dry-run report with all operations grouped by type.
108     *
109     * @param array<string, mixed> $report
110     */
111    private function displayDryRunReport(SymfonyStyle $symfonyStyle, array $report): void
112    {
113        $migrations = $report['migrations'] ?? [];
114        if (!is_array($migrations) || $migrations === []) {
115            $symfonyStyle->success('No migrations to apply. Database is up to date.');
116            return;
117        }
118
119        foreach ($migrations as $version => $migrationData) {
120            if (!is_array($migrationData)) {
121                continue;
122            }
123
124            $symfonyStyle->section(sprintf('Migration: %s', (string) $version));
125            $symfonyStyle->text((string) ($migrationData['description'] ?? ''));
126            $symfonyStyle->newLine();
127
128            // Group operations by type
129            /** @var array<string, list<array<array-key, mixed>>> $byType */
130            $byType = [];
131            $operations = $migrationData['operations'] ?? [];
132            if (is_array($operations)) {
133                foreach ($operations as $op) {
134                    if (!is_array($op)) {
135                        continue;
136                    }
137
138                    $byType[(string) ($op['type'] ?? '')][] = $op;
139                }
140            }
141
142            // SQL Operations
143            if (($byType['sql'] ?? []) !== []) {
144                $symfonyStyle->text(sprintf('<fg=cyan>SQL Operations (%d):</>', count($byType['sql'])));
145                $rows = [];
146                foreach ($byType['sql'] as $i => $op) {
147                    $query = $this->truncateString((string) ($op['query'] ?? ''), 80);
148                    $rows[] = [$i + 1, (string) ($op['description'] ?? ''), $query];
149                }
150                $symfonyStyle->table(['#', 'Description', 'Query'], $rows);
151            }
152
153            // Config Add Operations
154            if (($byType['config_add'] ?? []) !== []) {
155                $symfonyStyle->text(sprintf(
156                    '<fg=green>Configuration Additions (%d):</>',
157                    count($byType['config_add']),
158                ));
159                $rows = [];
160                foreach ($byType['config_add'] as $i => $op) {
161                    $rows[] = [$i + 1, (string) ($op['key'] ?? ''), $this->formatValue($op['value'] ?? null)];
162                }
163                $symfonyStyle->table(['#', 'Key', 'Value'], $rows);
164            }
165
166            // Config Delete Operations
167            if (($byType['config_delete'] ?? []) !== []) {
168                $symfonyStyle->text(sprintf(
169                    '<fg=red>Configuration Deletions (%d):</>',
170                    count($byType['config_delete']),
171                ));
172                $rows = [];
173                foreach ($byType['config_delete'] as $i => $op) {
174                    $rows[] = [$i + 1, (string) ($op['key'] ?? '')];
175                }
176                $symfonyStyle->table(['#', 'Key'], $rows);
177            }
178
179            // Config Rename Operations
180            if (($byType['config_rename'] ?? []) !== []) {
181                $symfonyStyle->text(sprintf(
182                    '<fg=yellow>Configuration Renames (%d):</>',
183                    count($byType['config_rename']),
184                ));
185                $rows = [];
186                foreach ($byType['config_rename'] as $i => $op) {
187                    $rows[] = [$i + 1, (string) ($op['oldKey'] ?? ''), (string) ($op['newKey'] ?? '')];
188                }
189                $symfonyStyle->table(['#', 'Old Key', 'New Key'], $rows);
190            }
191
192            // Config Update Operations
193            if (($byType['config_update'] ?? []) !== []) {
194                $symfonyStyle->text(sprintf(
195                    '<fg=blue>Configuration Updates (%d):</>',
196                    count($byType['config_update']),
197                ));
198                $rows = [];
199                foreach ($byType['config_update'] as $i => $op) {
200                    $rows[] = [$i + 1, (string) ($op['key'] ?? ''), $this->formatValue($op['value'] ?? null)];
201                }
202                $symfonyStyle->table(['#', 'Key', 'New Value'], $rows);
203            }
204
205            // File Operations
206            $fileOps = array_merge($byType['file_copy'] ?? [], $byType['directory_copy'] ?? []);
207            if ($fileOps !== []) {
208                $symfonyStyle->text(sprintf('<fg=magenta>File Operations (%d):</>', count($fileOps)));
209                $rows = [];
210                foreach ($fileOps as $i => $op) {
211                    $source = $this->shortenPath((string) ($op['source'] ?? ''));
212                    $dest = $this->shortenPath((string) ($op['destination'] ?? ''));
213                    $rows[] = [$i + 1, (string) ($op['type'] ?? ''), $source, $dest];
214                }
215                $symfonyStyle->table(['#', 'Type', 'Source', 'Destination'], $rows);
216            }
217
218            // Permission Operations
219            if (($byType['permission_grant'] ?? []) !== []) {
220                $symfonyStyle->text(sprintf(
221                    '<fg=white>Permission Grants (%d):</>',
222                    count($byType['permission_grant']),
223                ));
224                $rows = [];
225                foreach ($byType['permission_grant'] as $i => $op) {
226                    $rows[] = [
227                        $i + 1,
228                        (string) ($op['permissionName'] ?? ''),
229                        (string) ($op['permissionDescription'] ?? ''),
230                    ];
231                }
232                $symfonyStyle->table(['#', 'Permission', 'Description'], $rows);
233            }
234        }
235
236        // Summary
237        $summary = $report['summary'] ?? [];
238        $summary = is_array($summary) ? $summary : [];
239        $symfonyStyle->section('Summary');
240        $symfonyStyle->text(sprintf('Total Migrations: %d', (int) ($summary['migrationCount'] ?? 0)));
241        $symfonyStyle->text(sprintf('Total Operations: %d', (int) ($summary['totalOperations'] ?? 0)));
242
243        $operationsByType = $summary['operationsByType'] ?? [];
244        if (is_array($operationsByType) && $operationsByType !== []) {
245            $symfonyStyle->newLine();
246            $symfonyStyle->text('Operations by Type:');
247            foreach ($operationsByType as $type => $count) {
248                $symfonyStyle->text(sprintf('  - %s: %d', (string) $type, (int) $count));
249            }
250        }
251
252        $symfonyStyle->newLine();
253        $symfonyStyle->warning('This was a dry-run. No changes were made to the database.');
254    }
255
256    private function truncateString(string $str, int $maxLength): string
257    {
258        $str = preg_replace(pattern: '/\s+/', replacement: ' ', subject: trim($str)) ?? '';
259        if (strlen($str) > $maxLength) {
260            return substr(string: $str, offset: 0, length: $maxLength - 3) . '...';
261        }
262
263        return $str;
264    }
265
266    private function formatValue(mixed $value): string
267    {
268        if (is_bool($value)) {
269            return $value ? 'true' : 'false';
270        }
271        if (is_string($value)) {
272            if (strlen($value) > 40) {
273                return "'" . substr(string: $value, offset: 0, length: 37) . "...'";
274            }
275
276            return "'{$value}'";
277        }
278        if (is_null($value)) {
279            return 'null';
280        }
281        return (string) $value;
282    }
283
284    private function shortenPath(string $path): string
285    {
286        if (defined('PMF_ROOT_DIR')) {
287            $path = str_replace(search: (string) PMF_ROOT_DIR, replace: '', subject: $path);
288        }
289
290        if (strlen($path) > 50) {
291            return '...' . substr(string: $path, offset: -47);
292        }
293
294        return $path;
295    }
296
297    private string $version = '';
298    private string $installedVersion = '';
299
300    private function taskHealthCheck(SymfonyStyle $symfonyStyle): int
301    {
302        $upgrade = new Upgrade($this->system, $this->configuration);
303        if (!$upgrade->isMaintenanceEnabled()) {
304            $symfonyStyle->warning(Translation::getString(key: 'msgNotInMaintenanceMode'));
305        }
306
307        try {
308            $upgrade->checkFilesystem();
309        } catch (Throwable $throwable) {
310            $symfonyStyle->error(message: 'Error during health check: ' . $throwable->getMessage());
311            return Command::FAILURE;
312        }
313
314        $symfonyStyle->success(message: 'Health-Check successful.');
315        return Command::SUCCESS;
316    }
317
318    private function taskUpdateCheck(SymfonyStyle $symfonyStyle): int
319    {
320        $dateLastChecked = new DateTime()->format(DateTimeInterface::ATOM);
321        $branch = (string) $this->configuration->get(item: 'upgrade.releaseEnvironment');
322
323        try {
324            $api = new RemoteApiClient($this->configuration, $this->system);
325            $versions = $api->getVersions();
326            $this->configuration->set(key: 'upgrade.dateLastChecked', value: $dateLastChecked);
327
328            $available = version_compare(version1: $versions['installed'], version2: $versions[$branch], operator: '<');
329
330            // Always store the installed version for migrations
331            $this->installedVersion = $versions['installed'];
332
333            if ($available) {
334                $this->version = $versions[$branch];
335                $symfonyStyle->success(message: Translation::getString(key: 'msgCurrentVersion') . $versions[$branch]);
336                return Command::SUCCESS;
337            }
338
339            $this->version = $versions['installed'];
340            $symfonyStyle->success(
341                message: Translation::getString(key: 'versionIsUpToDate') . ' (' . $this->version . ')',
342            );
343        } catch (Exception|TransportExceptionInterface|DecodingExceptionInterface $exception) {
344            $symfonyStyle->error(message: 'Error during update check: ' . $exception->getMessage());
345            return Command::FAILURE;
346        }
347
348        return Command::SUCCESS;
349    }
350
351    /**
352     * @throws TransportExceptionInterface
353     * @throws ServerExceptionInterface
354     * @throws RedirectionExceptionInterface
355     * @throws \phpMyFAQ\Core\Exception
356     * @throws ClientExceptionInterface
357     * @throws \JsonException
358     */
359    private function taskDownloadPackage(SymfonyStyle $symfonyStyle): int
360    {
361        $upgrade = new Upgrade($this->system, $this->configuration);
362        $pathToPackage = $upgrade->downloadPackage($this->version);
363
364        if (!$upgrade->isNightly()) {
365            $result = $upgrade->verifyPackage($pathToPackage, $this->version);
366            if (!$result) {
367                $symfonyStyle->error(message: Translation::getString(key: 'verificationFailure'));
368                return Command::FAILURE;
369            }
370        }
371
372        $this->configuration->set(key: 'upgrade.lastDownloadedPackage', value: urlencode($pathToPackage));
373
374        $symfonyStyle->success(message: Translation::getString(key: 'downloadSuccessful'));
375        return Command::SUCCESS;
376    }
377
378    private function taskExtractPackage(SymfonyStyle $symfonyStyle): int
379    {
380        $upgrade = new Upgrade($this->system, $this->configuration);
381        $pathToPackage = urldecode((string) $this->configuration->get(item: 'upgrade.lastDownloadedPackage'));
382
383        $result = $this->withProgress($symfonyStyle, static function (callable $setProgress) use (
384            $upgrade,
385            $pathToPackage,
386        ): bool {
387            $progressCallback = static function (int $progress) use ($setProgress): void {
388                $setProgress($progress);
389            };
390
391            return $upgrade->extractPackage($pathToPackage, $progressCallback);
392        });
393
394        if ($result) {
395            $symfonyStyle->success(message: Translation::getString(key: 'extractSuccessful'));
396            return Command::SUCCESS;
397        }
398
399        $symfonyStyle->error(message: Translation::getString(key: 'extractFailure'));
400        return Command::FAILURE;
401    }
402
403    private function taskCreateTemporaryBackup(SymfonyStyle $symfonyStyle): int
404    {
405        $upgrade = new Upgrade($this->system, $this->configuration);
406        $backupHash = bin2hex(random_bytes(16));
407        $backupFile = $backupHash . '.zip';
408
409        $result = $this->withProgress($symfonyStyle, static function (callable $setProgress) use (
410            $upgrade,
411            $backupFile,
412        ): bool {
413            $progressCallback = static function (int $progress) use ($setProgress): void {
414                $setProgress($progress);
415            };
416
417            return $upgrade->createTemporaryBackup($backupFile, $progressCallback);
418        });
419
420        if ($result) {
421            $symfonyStyle->success(message: 'Backup successful: ' . $backupFile);
422            return Command::SUCCESS;
423        }
424
425        $symfonyStyle->error(message: 'Backup failed.');
426        return Command::FAILURE;
427    }
428
429    private function taskInstallPackage(SymfonyStyle $symfonyStyle): int
430    {
431        $upgrade = new Upgrade($this->system, $this->configuration);
432        $environmentConfigurator = new EnvironmentConfigurator($this->configuration);
433
434        $result = $this->withProgress($symfonyStyle, static function (callable $setProgress) use (
435            $upgrade,
436            $environmentConfigurator,
437        ): bool {
438            $progressCallback = static function (int $progress) use ($setProgress): void {
439                $setProgress($progress);
440            };
441
442            $installed = $upgrade->installPackage($progressCallback);
443            return $installed && $environmentConfigurator->adjustRewriteBaseHtaccess();
444        });
445
446        if ($result) {
447            $symfonyStyle->success(message: 'Package successfully installed.');
448            return Command::SUCCESS;
449        }
450
451        $symfonyStyle->error(message: 'Package installation failed.');
452        return Command::FAILURE;
453    }
454
455    private function taskUpdateDatabase(SymfonyStyle $symfonyStyle): int
456    {
457        $update = new Update($this->system, $this->configuration);
458        // Use the installed version (current version) for migrations, not the target version
459        $update->version = $this->installedVersion;
460
461        $progressBar = $symfonyStyle->createProgressBar(max: 100);
462        $progressBar->start();
463
464        try {
465            $result = $update->applyUpdates();
466            $progressBar->finish();
467            $symfonyStyle->newLine(count: 2);
468
469            if ($result) {
470                $this->configuration->set(key: 'main.maintenanceMode', value: 'false');
471                $this->displayMigrationResults($symfonyStyle, $update->migrationResults);
472                $symfonyStyle->success(message: 'Database successfully updated.');
473                return Command::SUCCESS;
474            }
475
476            $this->configuration->set(key: 'main.maintenanceMode', value: 'false');
477            $this->displayMigrationResults($symfonyStyle, $update->migrationResults);
478            $symfonyStyle->error(message: 'Update database failed.');
479            return Command::FAILURE;
480        } catch (Exception $exception) {
481            $progressBar->finish();
482            $symfonyStyle->newLine(count: 2);
483            $this->configuration->set(key: 'main.maintenanceMode', value: 'false');
484            $symfonyStyle->error(message: 'Update database failed: ' . $exception->getMessage());
485            return Command::FAILURE;
486        }
487    }
488
489    /**
490     * Displays migration results in a formatted table.
491     *
492     * @param MigrationResult[] $results
493     */
494    private function displayMigrationResults(SymfonyStyle $symfonyStyle, array $results): void
495    {
496        if ($results === []) {
497            $symfonyStyle->note('No migrations were applied.');
498            return;
499        }
500
501        $tableRows = [];
502        foreach ($results as $result) {
503            $status = $result->isSuccess() ? '<fg=green>SUCCESS</>' : '<fg=red>FAILED</>';
504            $tableRows[] = [
505                $result->getVersion(),
506                $result->getDescription(),
507                $result->getOperationCount(),
508                sprintf('%.2fms', $result->getExecutionTimeMs()),
509                $status,
510            ];
511        }
512
513        $symfonyStyle->table(['Version', 'Description', 'Operations', 'Time', 'Status'], $tableRows);
514    }
515
516    private function taskCleanup(SymfonyStyle $symfonyStyle): int
517    {
518        $upgrade = new Upgrade($this->system, $this->configuration);
519        $upgrade->cleanUp();
520
521        $symfonyStyle->success(message: 'Cleanup successful.');
522        return Command::SUCCESS;
523    }
524
525    private function withProgress(SymfonyStyle $symfonyStyle, callable $fn): bool
526    {
527        $progressBar = $symfonyStyle->createProgressBar(max: 100);
528        $progressBar->start();
529
530        $setProgress = static function (int $progress) use ($progressBar): void {
531            $progressBar->setProgress($progress);
532        };
533
534        try {
535            $result = $fn($setProgress) === true;
536        } finally {
537            $progressBar->finish();
538            $symfonyStyle->newLine(count: 2);
539        }
540
541        return $result;
542    }
543}