manage-update.php 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703
  1. <?php
  2. // Update pipeline: check, download, verify, extract, deploy, post-update step.
  3. //
  4. // Deployment is an overlay copy: every file in the release package is written
  5. // over the shop root, with each overwritten file copied aside first. Three
  6. // consequences worth knowing before pressing the button:
  7. //
  8. // - files that disappeared between releases are NOT removed;
  9. // - there is no maintenance mode, the shop stays reachable while copying;
  10. // - there is no rollback, the aside copies are for manual recovery only.
  11. //
  12. // The post-update step runs migrations from migrations/ and then the callback
  13. // in MANAGE_UPDATE_POST_HOOK. A failure there is reported loudly rather than
  14. // silently, because the files are live at that point.
  15. declare(strict_types=1);
  16. function manageUpdateWorkDir(): string
  17. {
  18. return rtrim((string) MANAGE_WORK_DIR, '/\\') . DIRECTORY_SEPARATOR;
  19. }
  20. function manageUpdateBackupRoot(): string
  21. {
  22. return rtrim((string) MANAGE_UPDATE_BACKUP_DIR, '/\\') . DIRECTORY_SEPARATOR;
  23. }
  24. // ---------------------------------------------------------------------------
  25. // Manifest
  26. // ---------------------------------------------------------------------------
  27. /**
  28. * Fetches and strictly validates the manifest.
  29. *
  30. * Every field is re-checked here because the response decides which code the
  31. * shop will execute next.
  32. */
  33. function manageUpdateFetchManifest(): array
  34. {
  35. $decoded = manageClientRequestJson('GET', 'manifest.php', null, (int) MANAGE_HTTP_TIMEOUT);
  36. $version = trim((string) ($decoded['version'] ?? $decoded['latest'] ?? ''));
  37. $packageUrl = trim((string) ($decoded['package_url'] ?? ''));
  38. $sha256 = strtolower(trim((string) ($decoded['sha256'] ?? '')));
  39. $size = isset($decoded['size']) ? (int) $decoded['size'] : 0;
  40. $publishedAt = trim((string) ($decoded['published_at'] ?? ''));
  41. if (!manageIsVersionString($version)) {
  42. throw new RuntimeException('Die Version im Manifest ist ungültig.');
  43. }
  44. if (!filter_var($packageUrl, FILTER_VALIDATE_URL)) {
  45. throw new RuntimeException('Die Paket-URL im Manifest ist ungültig.');
  46. }
  47. if (preg_match('/^[a-f0-9]{64}$/', $sha256) !== 1) {
  48. throw new RuntimeException('Die Prüfsumme im Manifest ist ungültig.');
  49. }
  50. return [
  51. 'version' => $version,
  52. 'package_url' => $packageUrl,
  53. 'sha256' => $sha256,
  54. 'size' => $size,
  55. 'published_at' => $publishedAt,
  56. ];
  57. }
  58. /**
  59. * Checks whether a newer release is available.
  60. *
  61. * If the installed version cannot be determined, available is true, so an
  62. * installation with an unreadable version file is not permanently blocked.
  63. *
  64. * @return array{current: string, latest: string, available: bool, manifest: array}
  65. */
  66. function manageUpdateCheck(): array
  67. {
  68. $manifest = manageUpdateFetchManifest();
  69. $current = manageClientVersion();
  70. $available = $current === ''
  71. ? true
  72. : version_compare(
  73. manageVersionCompareValue($manifest['version']),
  74. manageVersionCompareValue($current),
  75. '>'
  76. );
  77. return [
  78. 'current' => $current,
  79. 'latest' => $manifest['version'],
  80. 'available' => $available,
  81. 'manifest' => $manifest,
  82. ];
  83. }
  84. // ---------------------------------------------------------------------------
  85. // Download and extraction
  86. // ---------------------------------------------------------------------------
  87. function manageUpdateDownloadPackage(array $manifest, string $targetFile): void
  88. {
  89. manageEnsureDir(dirname($targetFile));
  90. $version = (string) $manifest['version'];
  91. $response = manageClientRequest(
  92. 'GET',
  93. 'package.php?version=' . rawurlencode($version),
  94. null,
  95. 'application/json',
  96. (int) MANAGE_HTTP_TIMEOUT_LONG
  97. );
  98. if ($response['status'] < 200 || $response['status'] >= 300) {
  99. throw new RuntimeException(manageClientErrorMessage($response['status'], $response['body']));
  100. }
  101. if ($response['body'] === '') {
  102. throw new RuntimeException('Das heruntergeladene Paket ist leer.');
  103. }
  104. if (file_put_contents($targetFile, $response['body'], LOCK_EX) === false) {
  105. throw new RuntimeException('Das heruntergeladene Paket konnte nicht gespeichert werden.');
  106. }
  107. if ($manifest['size'] > 0 && filesize($targetFile) !== $manifest['size']) {
  108. unlink($targetFile);
  109. throw new RuntimeException('Die Größe des heruntergeladenen Pakets stimmt nicht überein.');
  110. }
  111. $actualHash = strtolower(hash_file('sha256', $targetFile) ?: '');
  112. if ($actualHash !== $manifest['sha256']) {
  113. unlink($targetFile);
  114. throw new RuntimeException('Die Prüfsumme des Pakets stimmt nicht überein.');
  115. }
  116. }
  117. // Rejects zip-slip and anything else that would escape the stage directory.
  118. function manageUpdateValidateZipEntry(string $entry): bool
  119. {
  120. $entry = str_replace('\\', '/', $entry);
  121. $normalized = trim($entry, '/');
  122. if (
  123. $normalized === '' ||
  124. str_contains($entry, "\0") ||
  125. str_starts_with($entry, '/') ||
  126. preg_match('/^[A-Za-z]:\//', $entry) === 1
  127. ) {
  128. return false;
  129. }
  130. foreach (explode('/', $normalized) as $segment) {
  131. if ($segment === '' || $segment === '.' || $segment === '..') {
  132. return false;
  133. }
  134. }
  135. return true;
  136. }
  137. function manageUpdateExtractPackage(string $zipFile, string $stageDir): void
  138. {
  139. if (!class_exists('ZipArchive')) {
  140. throw new RuntimeException('Die PHP-Erweiterung ZipArchive ist nicht verfügbar.');
  141. }
  142. manageRemoveDir($stageDir);
  143. manageEnsureDir($stageDir);
  144. $zip = new ZipArchive();
  145. if ($zip->open($zipFile) !== true) {
  146. throw new RuntimeException('Das heruntergeladene Paket ist keine lesbare ZIP-Datei.');
  147. }
  148. $sanityPaths = is_array(MANAGE_UPDATE_SANITY_PATHS) ? MANAGE_UPDATE_SANITY_PATHS : [];
  149. $hasAppFile = $sanityPaths === [];
  150. for ($i = 0; $i < $zip->numFiles; $i++) {
  151. $name = (string) $zip->getNameIndex($i);
  152. if (!manageUpdateValidateZipEntry($name)) {
  153. $zip->close();
  154. throw new RuntimeException('Das Paket enthält einen unsicheren Pfad: ' . $name);
  155. }
  156. foreach ($sanityPaths as $sanityPath) {
  157. $sanityPath = trim(str_replace('\\', '/', (string) $sanityPath), '/');
  158. if ($sanityPath === '') {
  159. continue;
  160. }
  161. if ($name === $sanityPath || str_starts_with($name, $sanityPath . '/')) {
  162. $hasAppFile = true;
  163. }
  164. }
  165. }
  166. // Guards against rolling a completely unrelated ZIP over the shop.
  167. if (!$hasAppFile) {
  168. $zip->close();
  169. throw new RuntimeException(
  170. 'Das Paket sieht nicht wie ein Release dieses Shops aus (erwartet: ' .
  171. implode(', ', array_map('strval', $sanityPaths)) . ').'
  172. );
  173. }
  174. if (!$zip->extractTo($stageDir)) {
  175. $zip->close();
  176. throw new RuntimeException('Das Paket konnte nicht entpackt werden.');
  177. }
  178. $zip->close();
  179. }
  180. // ---------------------------------------------------------------------------
  181. // Deployment
  182. // ---------------------------------------------------------------------------
  183. function manageUpdateRelativePath(string $path, string $baseDir): string
  184. {
  185. return ltrim(str_replace('\\', '/', substr($path, strlen($baseDir))), '/');
  186. }
  187. /**
  188. * Whether a path from the package must be left alone.
  189. *
  190. * A configured entry ending in '/' protects the directory and everything below
  191. * it; anything else matches the exact path, but a directory named without the
  192. * slash still protects its contents.
  193. */
  194. function manageUpdateShouldSkipPath(string $relativePath): bool
  195. {
  196. $relativePath = trim(str_replace('\\', '/', $relativePath), '/');
  197. if ($relativePath === '') {
  198. return true;
  199. }
  200. $protected = is_array(MANAGE_UPDATE_PROTECTED_PATHS) ? MANAGE_UPDATE_PROTECTED_PATHS : [];
  201. foreach ($protected as $entry) {
  202. $entry = str_replace('\\', '/', (string) $entry);
  203. $entry = trim($entry, '/');
  204. if ($entry === '') {
  205. continue;
  206. }
  207. if ($relativePath === $entry || str_starts_with($relativePath, $entry . '/')) {
  208. return true;
  209. }
  210. }
  211. return false;
  212. }
  213. function manageUpdateCopyWithBackup(string $stageDir, string $appRoot, string $backupDir): array
  214. {
  215. manageEnsureDir($backupDir);
  216. $copied = 0;
  217. $backedUp = 0;
  218. $skipped = 0;
  219. $items = new RecursiveIteratorIterator(
  220. new RecursiveDirectoryIterator($stageDir, FilesystemIterator::SKIP_DOTS),
  221. RecursiveIteratorIterator::SELF_FIRST
  222. );
  223. foreach ($items as $item) {
  224. $relativePath = manageUpdateRelativePath($item->getPathname(), $stageDir);
  225. if (manageUpdateShouldSkipPath($relativePath)) {
  226. $skipped++;
  227. continue;
  228. }
  229. $targetPath = $appRoot . DIRECTORY_SEPARATOR . $relativePath;
  230. if ($item->isDir()) {
  231. manageEnsureDir($targetPath);
  232. continue;
  233. }
  234. manageEnsureDir(dirname($targetPath));
  235. if (file_exists($targetPath)) {
  236. $backupPath = $backupDir . DIRECTORY_SEPARATOR . $relativePath;
  237. manageEnsureDir(dirname($backupPath));
  238. if (!copy($targetPath, $backupPath)) {
  239. throw new RuntimeException('Die Datei konnte nicht gesichert werden: ' . $relativePath);
  240. }
  241. $backedUp++;
  242. }
  243. if (!copy($item->getPathname(), $targetPath)) {
  244. throw new RuntimeException('Die Datei konnte nicht ausgerollt werden: ' . $relativePath);
  245. }
  246. @chmod($targetPath, fileperms($item->getPathname()) & 0777);
  247. $copied++;
  248. }
  249. return ['copied' => $copied, 'backed_up' => $backedUp, 'skipped' => $skipped];
  250. }
  251. // Keeps only the aside copies of the run that just finished.
  252. function manageUpdateCleanupOldBackups(string $keepBackupDir): int
  253. {
  254. $backupRoot = rtrim(manageUpdateBackupRoot(), '/\\');
  255. if (!is_dir($backupRoot)) {
  256. return 0;
  257. }
  258. $keepRealPath = realpath($keepBackupDir);
  259. $backupRootRealPath = realpath($backupRoot);
  260. if ($keepRealPath === false || $backupRootRealPath === false) {
  261. return 0;
  262. }
  263. $removed = 0;
  264. foreach (new DirectoryIterator($backupRootRealPath) as $item) {
  265. if ($item->isDot() || !$item->isDir()) {
  266. continue;
  267. }
  268. $path = $item->getPathname();
  269. if (realpath($path) === $keepRealPath) {
  270. continue;
  271. }
  272. manageRemoveDir($path);
  273. if (is_dir($path)) {
  274. throw new RuntimeException('Ein altes Sicherungsverzeichnis konnte nicht entfernt werden: ' . $path);
  275. }
  276. $removed++;
  277. }
  278. return $removed;
  279. }
  280. /**
  281. * Downloads, verifies and deploys one release, then runs the post-update step.
  282. *
  283. * $options:
  284. * force bool redeploy even when no newer version is available
  285. * skip_hook bool deploy files only, run neither migrations nor the callback
  286. *
  287. * Throws if deployment fails. If only the post-update step fails, the function
  288. * returns normally with hook['success'] false — the files are live by then, and
  289. * the caller has to tell the two cases apart.
  290. */
  291. function manageUpdateApply(array $options = []): array
  292. {
  293. $force = !empty($options['force']);
  294. $skipHook = !empty($options['skip_hook']);
  295. $appRoot = manageClientAppRoot();
  296. $check = manageUpdateCheck();
  297. $manifest = $check['manifest'];
  298. if (!$check['available'] && !$force) {
  299. throw new RuntimeException(
  300. 'Es ist kein neueres Update verfügbar. Mit "erneut ausrollen" kann dasselbe Paket noch einmal ' .
  301. 'ausgerollt werden.'
  302. );
  303. }
  304. $runId = date('Ymd-His');
  305. $workDir = manageUpdateWorkDir() . $runId;
  306. $stageDir = $workDir . DIRECTORY_SEPARATOR . 'stage';
  307. $zipFile = $workDir . DIRECTORY_SEPARATOR . 'package.zip';
  308. $backupDir = manageUpdateBackupRoot() . $runId . '-' . $manifest['version'];
  309. manageEnsureDir($workDir);
  310. try {
  311. manageUpdateDownloadPackage($manifest, $zipFile);
  312. manageUpdateExtractPackage($zipFile, $stageDir);
  313. $result = manageUpdateCopyWithBackup($stageDir, $appRoot, $backupDir);
  314. } finally {
  315. manageRemoveDir($workDir);
  316. }
  317. $removedBackups = manageUpdateCleanupOldBackups($backupDir);
  318. manageClientLog('INFO', 'Update deployed', [
  319. 'from_version' => $check['current'],
  320. 'to_version' => $manifest['version'],
  321. 'copied' => $result['copied'],
  322. 'backed_up' => $result['backed_up'],
  323. 'backup_dir' => $backupDir,
  324. ]);
  325. $report = [
  326. 'deployed' => true,
  327. 'from_version' => $check['current'],
  328. 'to_version' => $manifest['version'],
  329. 'copied' => $result['copied'],
  330. 'backed_up' => $result['backed_up'],
  331. 'skipped' => $result['skipped'],
  332. 'removed_backups' => $removedBackups,
  333. 'backup_dir' => $backupDir,
  334. 'hook' => null,
  335. ];
  336. if ($skipHook) {
  337. $report['hook'] = [
  338. 'success' => true,
  339. 'skipped' => true,
  340. 'migrations' => ['applied' => [], 'pending' => count(manageUpdatePendingMigrations())],
  341. ];
  342. return $report;
  343. }
  344. // APP_VERSION is already loaded in this process from the old code, so
  345. // to_version comes from the manifest rather than from a re-read.
  346. $report['hook'] = manageUpdateRunPostHook([
  347. 'from_version' => $check['current'],
  348. 'to_version' => $manifest['version'],
  349. 'backup_dir' => $backupDir,
  350. 'run_id' => $runId,
  351. ]);
  352. return $report;
  353. }
  354. // ---------------------------------------------------------------------------
  355. // Migrations
  356. // ---------------------------------------------------------------------------
  357. function manageMigrationsEnabled(): bool
  358. {
  359. $dir = MANAGE_MIGRATIONS_DIR;
  360. return is_string($dir) && trim($dir) !== '';
  361. }
  362. function manageMigrationsDir(): string
  363. {
  364. return rtrim((string) MANAGE_MIGRATIONS_DIR, '/\\') . DIRECTORY_SEPARATOR;
  365. }
  366. function manageMigrationsReadState(): array
  367. {
  368. $state = manageReadJson((string) MANAGE_MIGRATIONS_STATE);
  369. $applied = isset($state['applied']) && is_array($state['applied'])
  370. ? $state['applied']
  371. : [];
  372. return ['applied' => array_values($applied)];
  373. }
  374. function manageMigrationsAppliedIds(): array
  375. {
  376. $ids = [];
  377. foreach (manageMigrationsReadState()['applied'] as $entry) {
  378. if (is_array($entry) && ($entry['id'] ?? '') !== '') {
  379. $ids[] = (string) $entry['id'];
  380. }
  381. }
  382. return $ids;
  383. }
  384. function manageMigrationsRecordApplied(string $id, int $durationMs): void
  385. {
  386. $state = manageMigrationsReadState();
  387. $state['applied'][] = [
  388. 'id' => $id,
  389. 'applied_at' => date(DATE_ATOM),
  390. 'version' => manageClientVersion(),
  391. 'duration_ms' => $durationMs,
  392. ];
  393. manageWriteJson((string) MANAGE_MIGRATIONS_STATE, $state);
  394. }
  395. /**
  396. * All migration files in the installation, sorted by filename.
  397. *
  398. * The filename without .php is the migration id, so renaming an already applied
  399. * migration makes it run again. That is documented, not accidental.
  400. */
  401. function manageMigrationsAvailable(): array
  402. {
  403. if (!manageMigrationsEnabled() || !is_dir(manageMigrationsDir())) {
  404. return [];
  405. }
  406. $migrations = [];
  407. foreach (glob(manageMigrationsDir() . '*.php') ?: [] as $path) {
  408. if (!is_file($path) || !is_readable($path)) {
  409. continue;
  410. }
  411. $id = basename($path, '.php');
  412. if ($id === '' || $id[0] === '.') {
  413. continue;
  414. }
  415. $migrations[] = ['id' => $id, 'path' => $path];
  416. }
  417. usort($migrations, function ($left, $right) {
  418. return strcmp($left['id'], $right['id']);
  419. });
  420. return $migrations;
  421. }
  422. /**
  423. * Migrations that have not been applied yet, in execution order.
  424. */
  425. function manageUpdatePendingMigrations(): array
  426. {
  427. $applied = manageMigrationsAppliedIds();
  428. $pending = [];
  429. foreach (manageMigrationsAvailable() as $migration) {
  430. if (!in_array($migration['id'], $applied, true)) {
  431. $pending[] = $migration;
  432. }
  433. }
  434. return $pending;
  435. }
  436. // Builds the context handed to every migration and to the post-update hook.
  437. function manageHookContext(array $extra = []): array
  438. {
  439. return array_merge([
  440. 'app_root' => manageClientAppRoot(),
  441. 'instance' => (string) MANAGE_INSTANCE,
  442. 'from_version' => '',
  443. 'to_version' => manageClientVersion(),
  444. 'backup_dir' => '',
  445. 'run_id' => '',
  446. ], $extra);
  447. }
  448. /**
  449. * Loads one migration file and returns its callable.
  450. *
  451. * Two supported shapes:
  452. * return function (array $context): void { ... };
  453. * function up(array $context): void { ... } // defined in the file
  454. */
  455. function manageMigrationResolveCallable(array $migration): callable
  456. {
  457. $returned = require $migration['path'];
  458. if (is_callable($returned)) {
  459. return $returned;
  460. }
  461. if (function_exists('up')) {
  462. return 'up';
  463. }
  464. throw new RuntimeException(
  465. 'Die Migration ' . $migration['id'] . ' liefert keine Funktion zurück und definiert kein up().'
  466. );
  467. }
  468. /**
  469. * Runs all pending migrations in order.
  470. *
  471. * Stops at the first failure; later migrations stay pending. Returns a report
  472. * rather than throwing, so a caller can distinguish "deployment succeeded but a
  473. * migration failed" from "deployment failed".
  474. *
  475. * @return array{success: bool, applied: array, failed: string|null, error: string|null, pending: int}
  476. */
  477. function manageUpdateRunMigrations(array $context = []): array
  478. {
  479. $report = [
  480. 'success' => true,
  481. 'applied' => [],
  482. 'failed' => null,
  483. 'error' => null,
  484. 'pending' => 0,
  485. ];
  486. $pending = manageUpdatePendingMigrations();
  487. if ($pending === []) {
  488. return $report;
  489. }
  490. $baseContext = manageHookContext($context);
  491. foreach ($pending as $position => $migration) {
  492. $startedAt = microtime(true);
  493. try {
  494. // A file that defines up() twice across two migrations would
  495. // collide, which is why the "return a closure" form is the
  496. // documented default.
  497. $callable = manageMigrationResolveCallable($migration);
  498. $callable(array_merge($baseContext, ['migration_id' => $migration['id']]));
  499. } catch (Throwable $exception) {
  500. $report['success'] = false;
  501. $report['failed'] = $migration['id'];
  502. $report['error'] = $exception->getMessage();
  503. $report['pending'] = count($pending) - $position;
  504. manageClientLog('ERROR', 'Migration failed', [
  505. 'migration' => $migration['id'],
  506. 'error' => $exception->getMessage(),
  507. ]);
  508. return $report;
  509. }
  510. $durationMs = (int) round((microtime(true) - $startedAt) * 1000);
  511. manageMigrationsRecordApplied($migration['id'], $durationMs);
  512. $report['applied'][] = $migration['id'];
  513. manageClientLog('INFO', 'Migration applied', [
  514. 'migration' => $migration['id'],
  515. 'duration_ms' => $durationMs,
  516. ]);
  517. }
  518. return $report;
  519. }
  520. /**
  521. * Runs the configured project callback.
  522. *
  523. * @return array{configured: bool, success: bool, error: string|null}
  524. */
  525. function manageUpdateRunPostHookCallback(array $context = []): array
  526. {
  527. $hook = MANAGE_UPDATE_POST_HOOK;
  528. if (!is_array($hook) || ($hook['callback'] ?? null) === null) {
  529. return ['configured' => false, 'success' => true, 'error' => null];
  530. }
  531. try {
  532. $file = trim((string) ($hook['file'] ?? ''));
  533. if ($file !== '') {
  534. if (!is_file($file)) {
  535. throw new RuntimeException('Die Hook-Datei wurde nicht gefunden: ' . $file);
  536. }
  537. require_once $file;
  538. }
  539. $callback = $hook['callback'];
  540. if (!is_callable($callback)) {
  541. throw new RuntimeException(
  542. 'Der Hook-Callback ist nicht aufrufbar: ' . (is_string($callback) ? $callback : gettype($callback))
  543. );
  544. }
  545. $result = call_user_func($callback, manageHookContext($context));
  546. if ($result === false || (is_array($result) && ($result['success'] ?? true) === false)) {
  547. $error = is_array($result) ? trim((string) ($result['error'] ?? '')) : '';
  548. throw new RuntimeException(
  549. 'Der Post-Update-Hook meldet einen Fehler' . ($error !== '' ? ': ' . $error : '.')
  550. );
  551. }
  552. } catch (Throwable $exception) {
  553. manageClientLog('ERROR', 'Post-update hook failed', [
  554. 'error' => $exception->getMessage(),
  555. ]);
  556. return ['configured' => true, 'success' => false, 'error' => $exception->getMessage()];
  557. }
  558. manageClientLog('INFO', 'Post-update hook finished', []);
  559. return ['configured' => true, 'success' => true, 'error' => null];
  560. }
  561. /**
  562. * Full post-update step: migrations first, then the project callback.
  563. *
  564. * Migrations run first so the callback can rely on the new data shape. When a
  565. * migration fails the callback is skipped, because running it against a
  566. * half-migrated state is worse than not running it at all.
  567. *
  568. * @return array{success: bool, migrations: array, hook: array, error: string|null, failed_migration: string|null}
  569. */
  570. function manageUpdateRunPostHook(array $context = []): array
  571. {
  572. $migrations = manageUpdateRunMigrations($context);
  573. if (!$migrations['success']) {
  574. return [
  575. 'success' => false,
  576. 'migrations' => $migrations,
  577. 'hook' => ['configured' => false, 'success' => true, 'error' => null, 'skipped' => true],
  578. 'error' => $migrations['error'],
  579. 'failed_migration' => $migrations['failed'],
  580. ];
  581. }
  582. $hook = manageUpdateRunPostHookCallback(array_merge($context, [
  583. 'migrations' => $migrations['applied'],
  584. ]));
  585. return [
  586. 'success' => $hook['success'],
  587. 'migrations' => $migrations,
  588. 'hook' => $hook,
  589. 'error' => $hook['error'],
  590. 'failed_migration' => null,
  591. ];
  592. }