| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309 |
- <?php
- declare(strict_types=1);
- // Post-update hook and migration runner.
- //
- // Runs as the last step of manageUpdateApply(), after the files are in place.
- // Two independent mechanisms, either or both:
- //
- // 1. MANAGE_UPDATE_POST_HOOK – a project callback (clear a cache, rebuild an
- // index, chmod a new directory)
- // 2. MANAGE_MIGRATIONS_DIR – ordered, once-only migration scripts shipped
- // inside the release package
- //
- // There is no rollback in this client, so a failure here must be loud rather
- // than silent: the run stops at the first failing migration, the remaining ones
- // stay pending, and the result is reported through the CLI exit code and the
- // GUI banner. `manage-client.php migrate` retries once the cause is fixed.
- 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 manageMigrationsStateFile(): string
- {
- return (string) MANAGE_MIGRATIONS_STATE;
- }
- function manageMigrationsReadState(): array
- {
- $state = manageReadJson(manageMigrationsStateFile());
- $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(manageMigrationsStateFile(), $state);
- }
- /**
- * All migration files in the package, 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, static function (array $left, array $right): int {
- 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
- {
- $context = array_merge([
- "app_root" => manageClientAppRoot(),
- "instance" => (string) MANAGE_INSTANCE,
- "from_version" => "",
- "to_version" => manageClientVersion(),
- "backup_dir" => "",
- "run_id" => "",
- ], $extra);
- // A database-backed project gets a ready connection, so a migration never
- // has to duplicate the credentials that are already configured for backups.
- if (manageDatabaseConfigured()) {
- $context["pdo"] = manageDatabaseConnect();
- }
- return $context;
- }
- /**
- * 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(
- "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 {
- // Each migration is loaded in its own function scope. 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("Hook-Datei wurde nicht gefunden: " . $file);
- }
- require_once $file;
- }
- $callback = $hook["callback"];
- if (!is_callable($callback)) {
- throw new RuntimeException(
- "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(
- "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 schema. 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,
- ];
- }
|