|
|
@@ -0,0 +1,703 @@
|
|
|
+<?php
|
|
|
+// Update pipeline: check, download, verify, extract, deploy, post-update step.
|
|
|
+//
|
|
|
+// Deployment is an overlay copy: every file in the release package is written
|
|
|
+// over the shop root, with each overwritten file copied aside first. Three
|
|
|
+// consequences worth knowing before pressing the button:
|
|
|
+//
|
|
|
+// - files that disappeared between releases are NOT removed;
|
|
|
+// - there is no maintenance mode, the shop stays reachable while copying;
|
|
|
+// - there is no rollback, the aside copies are for manual recovery only.
|
|
|
+//
|
|
|
+// The post-update step runs migrations from migrations/ and then the callback
|
|
|
+// in MANAGE_UPDATE_POST_HOOK. A failure there is reported loudly rather than
|
|
|
+// silently, because the files are live at that point.
|
|
|
+
|
|
|
+declare(strict_types=1);
|
|
|
+
|
|
|
+function manageUpdateWorkDir(): string
|
|
|
+{
|
|
|
+ return rtrim((string) MANAGE_WORK_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
|
|
+}
|
|
|
+
|
|
|
+function manageUpdateBackupRoot(): string
|
|
|
+{
|
|
|
+ return rtrim((string) MANAGE_UPDATE_BACKUP_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// Manifest
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+/**
|
|
|
+ * Fetches and strictly validates the manifest.
|
|
|
+ *
|
|
|
+ * Every field is re-checked here because the response decides which code the
|
|
|
+ * shop will execute next.
|
|
|
+ */
|
|
|
+function manageUpdateFetchManifest(): array
|
|
|
+{
|
|
|
+ $decoded = manageClientRequestJson('GET', 'manifest.php', null, (int) MANAGE_HTTP_TIMEOUT);
|
|
|
+
|
|
|
+ $version = trim((string) ($decoded['version'] ?? $decoded['latest'] ?? ''));
|
|
|
+ $packageUrl = trim((string) ($decoded['package_url'] ?? ''));
|
|
|
+ $sha256 = strtolower(trim((string) ($decoded['sha256'] ?? '')));
|
|
|
+ $size = isset($decoded['size']) ? (int) $decoded['size'] : 0;
|
|
|
+ $publishedAt = trim((string) ($decoded['published_at'] ?? ''));
|
|
|
+
|
|
|
+ if (!manageIsVersionString($version)) {
|
|
|
+ throw new RuntimeException('Die Version im Manifest ist ungültig.');
|
|
|
+ }
|
|
|
+ if (!filter_var($packageUrl, FILTER_VALIDATE_URL)) {
|
|
|
+ throw new RuntimeException('Die Paket-URL im Manifest ist ungültig.');
|
|
|
+ }
|
|
|
+ if (preg_match('/^[a-f0-9]{64}$/', $sha256) !== 1) {
|
|
|
+ throw new RuntimeException('Die Prüfsumme im Manifest ist ungültig.');
|
|
|
+ }
|
|
|
+
|
|
|
+ return [
|
|
|
+ 'version' => $version,
|
|
|
+ 'package_url' => $packageUrl,
|
|
|
+ 'sha256' => $sha256,
|
|
|
+ 'size' => $size,
|
|
|
+ 'published_at' => $publishedAt,
|
|
|
+ ];
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Checks whether a newer release is available.
|
|
|
+ *
|
|
|
+ * If the installed version cannot be determined, available is true, so an
|
|
|
+ * installation with an unreadable version file is not permanently blocked.
|
|
|
+ *
|
|
|
+ * @return array{current: string, latest: string, available: bool, manifest: array}
|
|
|
+ */
|
|
|
+function manageUpdateCheck(): array
|
|
|
+{
|
|
|
+ $manifest = manageUpdateFetchManifest();
|
|
|
+ $current = manageClientVersion();
|
|
|
+
|
|
|
+ $available = $current === ''
|
|
|
+ ? true
|
|
|
+ : version_compare(
|
|
|
+ manageVersionCompareValue($manifest['version']),
|
|
|
+ manageVersionCompareValue($current),
|
|
|
+ '>'
|
|
|
+ );
|
|
|
+
|
|
|
+ return [
|
|
|
+ 'current' => $current,
|
|
|
+ 'latest' => $manifest['version'],
|
|
|
+ 'available' => $available,
|
|
|
+ 'manifest' => $manifest,
|
|
|
+ ];
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// Download and extraction
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+function manageUpdateDownloadPackage(array $manifest, string $targetFile): void
|
|
|
+{
|
|
|
+ manageEnsureDir(dirname($targetFile));
|
|
|
+
|
|
|
+ $version = (string) $manifest['version'];
|
|
|
+ $response = manageClientRequest(
|
|
|
+ 'GET',
|
|
|
+ 'package.php?version=' . rawurlencode($version),
|
|
|
+ null,
|
|
|
+ 'application/json',
|
|
|
+ (int) MANAGE_HTTP_TIMEOUT_LONG
|
|
|
+ );
|
|
|
+
|
|
|
+ if ($response['status'] < 200 || $response['status'] >= 300) {
|
|
|
+ throw new RuntimeException(manageClientErrorMessage($response['status'], $response['body']));
|
|
|
+ }
|
|
|
+ if ($response['body'] === '') {
|
|
|
+ throw new RuntimeException('Das heruntergeladene Paket ist leer.');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (file_put_contents($targetFile, $response['body'], LOCK_EX) === false) {
|
|
|
+ throw new RuntimeException('Das heruntergeladene Paket konnte nicht gespeichert werden.');
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($manifest['size'] > 0 && filesize($targetFile) !== $manifest['size']) {
|
|
|
+ unlink($targetFile);
|
|
|
+ throw new RuntimeException('Die Größe des heruntergeladenen Pakets stimmt nicht überein.');
|
|
|
+ }
|
|
|
+
|
|
|
+ $actualHash = strtolower(hash_file('sha256', $targetFile) ?: '');
|
|
|
+ if ($actualHash !== $manifest['sha256']) {
|
|
|
+ unlink($targetFile);
|
|
|
+ throw new RuntimeException('Die Prüfsumme des Pakets stimmt nicht überein.');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Rejects zip-slip and anything else that would escape the stage directory.
|
|
|
+function manageUpdateValidateZipEntry(string $entry): bool
|
|
|
+{
|
|
|
+ $entry = str_replace('\\', '/', $entry);
|
|
|
+ $normalized = trim($entry, '/');
|
|
|
+
|
|
|
+ if (
|
|
|
+ $normalized === '' ||
|
|
|
+ str_contains($entry, "\0") ||
|
|
|
+ str_starts_with($entry, '/') ||
|
|
|
+ preg_match('/^[A-Za-z]:\//', $entry) === 1
|
|
|
+ ) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ foreach (explode('/', $normalized) as $segment) {
|
|
|
+ if ($segment === '' || $segment === '.' || $segment === '..') {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return true;
|
|
|
+}
|
|
|
+
|
|
|
+function manageUpdateExtractPackage(string $zipFile, string $stageDir): void
|
|
|
+{
|
|
|
+ if (!class_exists('ZipArchive')) {
|
|
|
+ throw new RuntimeException('Die PHP-Erweiterung ZipArchive ist nicht verfügbar.');
|
|
|
+ }
|
|
|
+
|
|
|
+ manageRemoveDir($stageDir);
|
|
|
+ manageEnsureDir($stageDir);
|
|
|
+
|
|
|
+ $zip = new ZipArchive();
|
|
|
+ if ($zip->open($zipFile) !== true) {
|
|
|
+ throw new RuntimeException('Das heruntergeladene Paket ist keine lesbare ZIP-Datei.');
|
|
|
+ }
|
|
|
+
|
|
|
+ $sanityPaths = is_array(MANAGE_UPDATE_SANITY_PATHS) ? MANAGE_UPDATE_SANITY_PATHS : [];
|
|
|
+ $hasAppFile = $sanityPaths === [];
|
|
|
+
|
|
|
+ for ($i = 0; $i < $zip->numFiles; $i++) {
|
|
|
+ $name = (string) $zip->getNameIndex($i);
|
|
|
+ if (!manageUpdateValidateZipEntry($name)) {
|
|
|
+ $zip->close();
|
|
|
+ throw new RuntimeException('Das Paket enthält einen unsicheren Pfad: ' . $name);
|
|
|
+ }
|
|
|
+
|
|
|
+ foreach ($sanityPaths as $sanityPath) {
|
|
|
+ $sanityPath = trim(str_replace('\\', '/', (string) $sanityPath), '/');
|
|
|
+ if ($sanityPath === '') {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if ($name === $sanityPath || str_starts_with($name, $sanityPath . '/')) {
|
|
|
+ $hasAppFile = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Guards against rolling a completely unrelated ZIP over the shop.
|
|
|
+ if (!$hasAppFile) {
|
|
|
+ $zip->close();
|
|
|
+ throw new RuntimeException(
|
|
|
+ 'Das Paket sieht nicht wie ein Release dieses Shops aus (erwartet: ' .
|
|
|
+ implode(', ', array_map('strval', $sanityPaths)) . ').'
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!$zip->extractTo($stageDir)) {
|
|
|
+ $zip->close();
|
|
|
+ throw new RuntimeException('Das Paket konnte nicht entpackt werden.');
|
|
|
+ }
|
|
|
+
|
|
|
+ $zip->close();
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// Deployment
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+function manageUpdateRelativePath(string $path, string $baseDir): string
|
|
|
+{
|
|
|
+ return ltrim(str_replace('\\', '/', substr($path, strlen($baseDir))), '/');
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Whether a path from the package must be left alone.
|
|
|
+ *
|
|
|
+ * A configured entry ending in '/' protects the directory and everything below
|
|
|
+ * it; anything else matches the exact path, but a directory named without the
|
|
|
+ * slash still protects its contents.
|
|
|
+ */
|
|
|
+function manageUpdateShouldSkipPath(string $relativePath): bool
|
|
|
+{
|
|
|
+ $relativePath = trim(str_replace('\\', '/', $relativePath), '/');
|
|
|
+ if ($relativePath === '') {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ $protected = is_array(MANAGE_UPDATE_PROTECTED_PATHS) ? MANAGE_UPDATE_PROTECTED_PATHS : [];
|
|
|
+
|
|
|
+ foreach ($protected as $entry) {
|
|
|
+ $entry = str_replace('\\', '/', (string) $entry);
|
|
|
+ $entry = trim($entry, '/');
|
|
|
+ if ($entry === '') {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($relativePath === $entry || str_starts_with($relativePath, $entry . '/')) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return false;
|
|
|
+}
|
|
|
+
|
|
|
+function manageUpdateCopyWithBackup(string $stageDir, string $appRoot, string $backupDir): array
|
|
|
+{
|
|
|
+ manageEnsureDir($backupDir);
|
|
|
+
|
|
|
+ $copied = 0;
|
|
|
+ $backedUp = 0;
|
|
|
+ $skipped = 0;
|
|
|
+
|
|
|
+ $items = new RecursiveIteratorIterator(
|
|
|
+ new RecursiveDirectoryIterator($stageDir, FilesystemIterator::SKIP_DOTS),
|
|
|
+ RecursiveIteratorIterator::SELF_FIRST
|
|
|
+ );
|
|
|
+
|
|
|
+ foreach ($items as $item) {
|
|
|
+ $relativePath = manageUpdateRelativePath($item->getPathname(), $stageDir);
|
|
|
+ if (manageUpdateShouldSkipPath($relativePath)) {
|
|
|
+ $skipped++;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ $targetPath = $appRoot . DIRECTORY_SEPARATOR . $relativePath;
|
|
|
+
|
|
|
+ if ($item->isDir()) {
|
|
|
+ manageEnsureDir($targetPath);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ manageEnsureDir(dirname($targetPath));
|
|
|
+
|
|
|
+ if (file_exists($targetPath)) {
|
|
|
+ $backupPath = $backupDir . DIRECTORY_SEPARATOR . $relativePath;
|
|
|
+ manageEnsureDir(dirname($backupPath));
|
|
|
+ if (!copy($targetPath, $backupPath)) {
|
|
|
+ throw new RuntimeException('Die Datei konnte nicht gesichert werden: ' . $relativePath);
|
|
|
+ }
|
|
|
+ $backedUp++;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!copy($item->getPathname(), $targetPath)) {
|
|
|
+ throw new RuntimeException('Die Datei konnte nicht ausgerollt werden: ' . $relativePath);
|
|
|
+ }
|
|
|
+
|
|
|
+ @chmod($targetPath, fileperms($item->getPathname()) & 0777);
|
|
|
+ $copied++;
|
|
|
+ }
|
|
|
+
|
|
|
+ return ['copied' => $copied, 'backed_up' => $backedUp, 'skipped' => $skipped];
|
|
|
+}
|
|
|
+
|
|
|
+// Keeps only the aside copies of the run that just finished.
|
|
|
+function manageUpdateCleanupOldBackups(string $keepBackupDir): int
|
|
|
+{
|
|
|
+ $backupRoot = rtrim(manageUpdateBackupRoot(), '/\\');
|
|
|
+ if (!is_dir($backupRoot)) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ $keepRealPath = realpath($keepBackupDir);
|
|
|
+ $backupRootRealPath = realpath($backupRoot);
|
|
|
+ if ($keepRealPath === false || $backupRootRealPath === false) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ $removed = 0;
|
|
|
+ foreach (new DirectoryIterator($backupRootRealPath) as $item) {
|
|
|
+ if ($item->isDot() || !$item->isDir()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ $path = $item->getPathname();
|
|
|
+ if (realpath($path) === $keepRealPath) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ manageRemoveDir($path);
|
|
|
+ if (is_dir($path)) {
|
|
|
+ throw new RuntimeException('Ein altes Sicherungsverzeichnis konnte nicht entfernt werden: ' . $path);
|
|
|
+ }
|
|
|
+ $removed++;
|
|
|
+ }
|
|
|
+
|
|
|
+ return $removed;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Downloads, verifies and deploys one release, then runs the post-update step.
|
|
|
+ *
|
|
|
+ * $options:
|
|
|
+ * force bool redeploy even when no newer version is available
|
|
|
+ * skip_hook bool deploy files only, run neither migrations nor the callback
|
|
|
+ *
|
|
|
+ * Throws if deployment fails. If only the post-update step fails, the function
|
|
|
+ * returns normally with hook['success'] false — the files are live by then, and
|
|
|
+ * the caller has to tell the two cases apart.
|
|
|
+ */
|
|
|
+function manageUpdateApply(array $options = []): array
|
|
|
+{
|
|
|
+ $force = !empty($options['force']);
|
|
|
+ $skipHook = !empty($options['skip_hook']);
|
|
|
+
|
|
|
+ $appRoot = manageClientAppRoot();
|
|
|
+ $check = manageUpdateCheck();
|
|
|
+ $manifest = $check['manifest'];
|
|
|
+
|
|
|
+ if (!$check['available'] && !$force) {
|
|
|
+ throw new RuntimeException(
|
|
|
+ 'Es ist kein neueres Update verfügbar. Mit "erneut ausrollen" kann dasselbe Paket noch einmal ' .
|
|
|
+ 'ausgerollt werden.'
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ $runId = date('Ymd-His');
|
|
|
+ $workDir = manageUpdateWorkDir() . $runId;
|
|
|
+ $stageDir = $workDir . DIRECTORY_SEPARATOR . 'stage';
|
|
|
+ $zipFile = $workDir . DIRECTORY_SEPARATOR . 'package.zip';
|
|
|
+ $backupDir = manageUpdateBackupRoot() . $runId . '-' . $manifest['version'];
|
|
|
+
|
|
|
+ manageEnsureDir($workDir);
|
|
|
+
|
|
|
+ try {
|
|
|
+ manageUpdateDownloadPackage($manifest, $zipFile);
|
|
|
+ manageUpdateExtractPackage($zipFile, $stageDir);
|
|
|
+ $result = manageUpdateCopyWithBackup($stageDir, $appRoot, $backupDir);
|
|
|
+ } finally {
|
|
|
+ manageRemoveDir($workDir);
|
|
|
+ }
|
|
|
+
|
|
|
+ $removedBackups = manageUpdateCleanupOldBackups($backupDir);
|
|
|
+
|
|
|
+ manageClientLog('INFO', 'Update deployed', [
|
|
|
+ 'from_version' => $check['current'],
|
|
|
+ 'to_version' => $manifest['version'],
|
|
|
+ 'copied' => $result['copied'],
|
|
|
+ 'backed_up' => $result['backed_up'],
|
|
|
+ 'backup_dir' => $backupDir,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $report = [
|
|
|
+ 'deployed' => true,
|
|
|
+ 'from_version' => $check['current'],
|
|
|
+ 'to_version' => $manifest['version'],
|
|
|
+ 'copied' => $result['copied'],
|
|
|
+ 'backed_up' => $result['backed_up'],
|
|
|
+ 'skipped' => $result['skipped'],
|
|
|
+ 'removed_backups' => $removedBackups,
|
|
|
+ 'backup_dir' => $backupDir,
|
|
|
+ 'hook' => null,
|
|
|
+ ];
|
|
|
+
|
|
|
+ if ($skipHook) {
|
|
|
+ $report['hook'] = [
|
|
|
+ 'success' => true,
|
|
|
+ 'skipped' => true,
|
|
|
+ 'migrations' => ['applied' => [], 'pending' => count(manageUpdatePendingMigrations())],
|
|
|
+ ];
|
|
|
+
|
|
|
+ return $report;
|
|
|
+ }
|
|
|
+
|
|
|
+ // APP_VERSION is already loaded in this process from the old code, so
|
|
|
+ // to_version comes from the manifest rather than from a re-read.
|
|
|
+ $report['hook'] = manageUpdateRunPostHook([
|
|
|
+ 'from_version' => $check['current'],
|
|
|
+ 'to_version' => $manifest['version'],
|
|
|
+ 'backup_dir' => $backupDir,
|
|
|
+ 'run_id' => $runId,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return $report;
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// Migrations
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+function manageMigrationsEnabled(): bool
|
|
|
+{
|
|
|
+ $dir = MANAGE_MIGRATIONS_DIR;
|
|
|
+
|
|
|
+ return is_string($dir) && trim($dir) !== '';
|
|
|
+}
|
|
|
+
|
|
|
+function manageMigrationsDir(): string
|
|
|
+{
|
|
|
+ return rtrim((string) MANAGE_MIGRATIONS_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
|
|
+}
|
|
|
+
|
|
|
+function manageMigrationsReadState(): array
|
|
|
+{
|
|
|
+ $state = manageReadJson((string) MANAGE_MIGRATIONS_STATE);
|
|
|
+ $applied = isset($state['applied']) && is_array($state['applied'])
|
|
|
+ ? $state['applied']
|
|
|
+ : [];
|
|
|
+
|
|
|
+ return ['applied' => array_values($applied)];
|
|
|
+}
|
|
|
+
|
|
|
+function manageMigrationsAppliedIds(): array
|
|
|
+{
|
|
|
+ $ids = [];
|
|
|
+ foreach (manageMigrationsReadState()['applied'] as $entry) {
|
|
|
+ if (is_array($entry) && ($entry['id'] ?? '') !== '') {
|
|
|
+ $ids[] = (string) $entry['id'];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return $ids;
|
|
|
+}
|
|
|
+
|
|
|
+function manageMigrationsRecordApplied(string $id, int $durationMs): void
|
|
|
+{
|
|
|
+ $state = manageMigrationsReadState();
|
|
|
+ $state['applied'][] = [
|
|
|
+ 'id' => $id,
|
|
|
+ 'applied_at' => date(DATE_ATOM),
|
|
|
+ 'version' => manageClientVersion(),
|
|
|
+ 'duration_ms' => $durationMs,
|
|
|
+ ];
|
|
|
+
|
|
|
+ manageWriteJson((string) MANAGE_MIGRATIONS_STATE, $state);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * All migration files in the installation, sorted by filename.
|
|
|
+ *
|
|
|
+ * The filename without .php is the migration id, so renaming an already applied
|
|
|
+ * migration makes it run again. That is documented, not accidental.
|
|
|
+ */
|
|
|
+function manageMigrationsAvailable(): array
|
|
|
+{
|
|
|
+ if (!manageMigrationsEnabled() || !is_dir(manageMigrationsDir())) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ $migrations = [];
|
|
|
+ foreach (glob(manageMigrationsDir() . '*.php') ?: [] as $path) {
|
|
|
+ if (!is_file($path) || !is_readable($path)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $id = basename($path, '.php');
|
|
|
+ if ($id === '' || $id[0] === '.') {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $migrations[] = ['id' => $id, 'path' => $path];
|
|
|
+ }
|
|
|
+
|
|
|
+ usort($migrations, function ($left, $right) {
|
|
|
+ return strcmp($left['id'], $right['id']);
|
|
|
+ });
|
|
|
+
|
|
|
+ return $migrations;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Migrations that have not been applied yet, in execution order.
|
|
|
+ */
|
|
|
+function manageUpdatePendingMigrations(): array
|
|
|
+{
|
|
|
+ $applied = manageMigrationsAppliedIds();
|
|
|
+ $pending = [];
|
|
|
+
|
|
|
+ foreach (manageMigrationsAvailable() as $migration) {
|
|
|
+ if (!in_array($migration['id'], $applied, true)) {
|
|
|
+ $pending[] = $migration;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return $pending;
|
|
|
+}
|
|
|
+
|
|
|
+// Builds the context handed to every migration and to the post-update hook.
|
|
|
+function manageHookContext(array $extra = []): array
|
|
|
+{
|
|
|
+ return array_merge([
|
|
|
+ 'app_root' => manageClientAppRoot(),
|
|
|
+ 'instance' => (string) MANAGE_INSTANCE,
|
|
|
+ 'from_version' => '',
|
|
|
+ 'to_version' => manageClientVersion(),
|
|
|
+ 'backup_dir' => '',
|
|
|
+ 'run_id' => '',
|
|
|
+ ], $extra);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Loads one migration file and returns its callable.
|
|
|
+ *
|
|
|
+ * Two supported shapes:
|
|
|
+ * return function (array $context): void { ... };
|
|
|
+ * function up(array $context): void { ... } // defined in the file
|
|
|
+ */
|
|
|
+function manageMigrationResolveCallable(array $migration): callable
|
|
|
+{
|
|
|
+ $returned = require $migration['path'];
|
|
|
+
|
|
|
+ if (is_callable($returned)) {
|
|
|
+ return $returned;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (function_exists('up')) {
|
|
|
+ return 'up';
|
|
|
+ }
|
|
|
+
|
|
|
+ throw new RuntimeException(
|
|
|
+ 'Die Migration ' . $migration['id'] . ' liefert keine Funktion zurück und definiert kein up().'
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Runs all pending migrations in order.
|
|
|
+ *
|
|
|
+ * Stops at the first failure; later migrations stay pending. Returns a report
|
|
|
+ * rather than throwing, so a caller can distinguish "deployment succeeded but a
|
|
|
+ * migration failed" from "deployment failed".
|
|
|
+ *
|
|
|
+ * @return array{success: bool, applied: array, failed: string|null, error: string|null, pending: int}
|
|
|
+ */
|
|
|
+function manageUpdateRunMigrations(array $context = []): array
|
|
|
+{
|
|
|
+ $report = [
|
|
|
+ 'success' => true,
|
|
|
+ 'applied' => [],
|
|
|
+ 'failed' => null,
|
|
|
+ 'error' => null,
|
|
|
+ 'pending' => 0,
|
|
|
+ ];
|
|
|
+
|
|
|
+ $pending = manageUpdatePendingMigrations();
|
|
|
+ if ($pending === []) {
|
|
|
+ return $report;
|
|
|
+ }
|
|
|
+
|
|
|
+ $baseContext = manageHookContext($context);
|
|
|
+
|
|
|
+ foreach ($pending as $position => $migration) {
|
|
|
+ $startedAt = microtime(true);
|
|
|
+
|
|
|
+ try {
|
|
|
+ // A file that defines up() twice across two migrations would
|
|
|
+ // collide, which is why the "return a closure" form is the
|
|
|
+ // documented default.
|
|
|
+ $callable = manageMigrationResolveCallable($migration);
|
|
|
+ $callable(array_merge($baseContext, ['migration_id' => $migration['id']]));
|
|
|
+ } catch (Throwable $exception) {
|
|
|
+ $report['success'] = false;
|
|
|
+ $report['failed'] = $migration['id'];
|
|
|
+ $report['error'] = $exception->getMessage();
|
|
|
+ $report['pending'] = count($pending) - $position;
|
|
|
+
|
|
|
+ manageClientLog('ERROR', 'Migration failed', [
|
|
|
+ 'migration' => $migration['id'],
|
|
|
+ 'error' => $exception->getMessage(),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return $report;
|
|
|
+ }
|
|
|
+
|
|
|
+ $durationMs = (int) round((microtime(true) - $startedAt) * 1000);
|
|
|
+ manageMigrationsRecordApplied($migration['id'], $durationMs);
|
|
|
+ $report['applied'][] = $migration['id'];
|
|
|
+
|
|
|
+ manageClientLog('INFO', 'Migration applied', [
|
|
|
+ 'migration' => $migration['id'],
|
|
|
+ 'duration_ms' => $durationMs,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ return $report;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Runs the configured project callback.
|
|
|
+ *
|
|
|
+ * @return array{configured: bool, success: bool, error: string|null}
|
|
|
+ */
|
|
|
+function manageUpdateRunPostHookCallback(array $context = []): array
|
|
|
+{
|
|
|
+ $hook = MANAGE_UPDATE_POST_HOOK;
|
|
|
+ if (!is_array($hook) || ($hook['callback'] ?? null) === null) {
|
|
|
+ return ['configured' => false, 'success' => true, 'error' => null];
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ $file = trim((string) ($hook['file'] ?? ''));
|
|
|
+ if ($file !== '') {
|
|
|
+ if (!is_file($file)) {
|
|
|
+ throw new RuntimeException('Die Hook-Datei wurde nicht gefunden: ' . $file);
|
|
|
+ }
|
|
|
+ require_once $file;
|
|
|
+ }
|
|
|
+
|
|
|
+ $callback = $hook['callback'];
|
|
|
+ if (!is_callable($callback)) {
|
|
|
+ throw new RuntimeException(
|
|
|
+ 'Der Hook-Callback ist nicht aufrufbar: ' . (is_string($callback) ? $callback : gettype($callback))
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ $result = call_user_func($callback, manageHookContext($context));
|
|
|
+ if ($result === false || (is_array($result) && ($result['success'] ?? true) === false)) {
|
|
|
+ $error = is_array($result) ? trim((string) ($result['error'] ?? '')) : '';
|
|
|
+ throw new RuntimeException(
|
|
|
+ 'Der Post-Update-Hook meldet einen Fehler' . ($error !== '' ? ': ' . $error : '.')
|
|
|
+ );
|
|
|
+ }
|
|
|
+ } catch (Throwable $exception) {
|
|
|
+ manageClientLog('ERROR', 'Post-update hook failed', [
|
|
|
+ 'error' => $exception->getMessage(),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return ['configured' => true, 'success' => false, 'error' => $exception->getMessage()];
|
|
|
+ }
|
|
|
+
|
|
|
+ manageClientLog('INFO', 'Post-update hook finished', []);
|
|
|
+
|
|
|
+ return ['configured' => true, 'success' => true, 'error' => null];
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Full post-update step: migrations first, then the project callback.
|
|
|
+ *
|
|
|
+ * Migrations run first so the callback can rely on the new data shape. When a
|
|
|
+ * migration fails the callback is skipped, because running it against a
|
|
|
+ * half-migrated state is worse than not running it at all.
|
|
|
+ *
|
|
|
+ * @return array{success: bool, migrations: array, hook: array, error: string|null, failed_migration: string|null}
|
|
|
+ */
|
|
|
+function manageUpdateRunPostHook(array $context = []): array
|
|
|
+{
|
|
|
+ $migrations = manageUpdateRunMigrations($context);
|
|
|
+
|
|
|
+ if (!$migrations['success']) {
|
|
|
+ return [
|
|
|
+ 'success' => false,
|
|
|
+ 'migrations' => $migrations,
|
|
|
+ 'hook' => ['configured' => false, 'success' => true, 'error' => null, 'skipped' => true],
|
|
|
+ 'error' => $migrations['error'],
|
|
|
+ 'failed_migration' => $migrations['failed'],
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ $hook = manageUpdateRunPostHookCallback(array_merge($context, [
|
|
|
+ 'migrations' => $migrations['applied'],
|
|
|
+ ]));
|
|
|
+
|
|
|
+ return [
|
|
|
+ 'success' => $hook['success'],
|
|
|
+ 'migrations' => $migrations,
|
|
|
+ 'hook' => $hook,
|
|
|
+ 'error' => $hook['error'],
|
|
|
+ 'failed_migration' => null,
|
|
|
+ ];
|
|
|
+}
|