From 1fa1aa862191262cc2c1f2c885fe7f49845c87af Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 31 Mar 2026 20:58:22 +1300 Subject: [PATCH 1/3] (fix): guard against missing Host header in dispatch --- app/http.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/http.php b/app/http.php index 517a1aa544..1742bc7cdd 100644 --- a/app/http.php +++ b/app/http.php @@ -103,7 +103,7 @@ function dispatch(Server $server, int $fd, int $type, $data = null): int $lines = explode("\n", $data, 3); $request = $lines[0]; if (count($lines) > 1) { - $domain = trim(explode('Host: ', $lines[1])[1]); + $domain = trim(explode('Host: ', $lines[1])[1] ?? ''); } // Sync executions are considered risky From 2f53d09c5b679d68bed17721b209f6e5a12de327 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 31 Mar 2026 20:58:33 +1300 Subject: [PATCH 2/3] (feat): add database migration step to upgrade installer --- app/views/install/installer.phtml | 2 +- app/views/install/installer/css/styles.css | 89 +++++++++++++++++++ app/views/install/installer/js/installer.js | 4 +- .../install/installer/js/modules/context.js | 8 +- .../install/installer/js/modules/progress.js | 20 ++++- app/views/install/installer/js/steps.js | 25 ++++++ .../installer/templates/steps/step-6.phtml | 37 ++++++++ .../Installer/Http/Installer/Install.php | 3 + .../Installer/Http/Installer/View.php | 7 +- src/Appwrite/Platform/Installer/Server.php | 1 + src/Appwrite/Platform/Tasks/Install.php | 51 +++++++++++ .../Platform/Modules/Installer/ModuleTest.php | 2 +- 12 files changed, 238 insertions(+), 11 deletions(-) create mode 100644 app/views/install/installer/templates/steps/step-6.phtml diff --git a/app/views/install/installer.phtml b/app/views/install/installer.phtml index 05bc1b80ed..d33838c8c6 100644 --- a/app/views/install/installer.phtml +++ b/app/views/install/installer.phtml @@ -13,7 +13,7 @@ $enabledDatabases = $enabledDatabases ?? ['mongodb', 'mariadb', 'postgresql']; $isLocalInstall = $isLocalInstall ?? false; -$cardStep = min(4, $step); +$cardStep = ($step === 5) ? 4 : $step; $stepFile = __DIR__ . "/installer/templates/steps/step-{$cardStep}.phtml"; if (!is_file($stepFile)) { $stepFile = __DIR__ . "/installer/templates/steps/step-1.phtml"; diff --git a/app/views/install/installer/css/styles.css b/app/views/install/installer/css/styles.css index eedce90834..8fd28a12a3 100644 --- a/app/views/install/installer/css/styles.css +++ b/app/views/install/installer/css/styles.css @@ -1812,3 +1812,92 @@ body { gap: var(--gap-s); } } + +.migration-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--gap-l); + padding: var(--space-6); + background: var(--bgcolor-neutral-default); + border-radius: var(--border-radius-m); + outline: var(--border-width-s) solid var(--border-neutral); + outline-offset: calc(var(--border-width-s) * -1); + cursor: pointer; + transition: outline-color 0.15s ease-in-out; +} + +.migration-option:hover { + outline-color: var(--border-neutral-stronger); +} + +.migration-option-content { + display: flex; + flex-direction: column; + gap: 2px; +} + +.migration-switch { + flex-shrink: 0; +} + +.migration-switch-track { + position: relative; + display: block; + width: 32px; + height: 20px; + border-radius: 10px; + background: var(--bgcolor-neutral-invert-weaker); + transition: background 0.15s ease-in-out; +} + +.migration-switch-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--bgcolor-neutral-primary); + transition: transform 0.15s ease-in-out; +} + +#run-migration:checked ~ .migration-switch-track { + background: var(--bgcolor-neutral-invert-weak); +} + +#run-migration:checked ~ .migration-switch-track .migration-switch-thumb { + transform: translateX(12px); +} + +#run-migration:focus-visible ~ .migration-switch-track { + box-shadow: 0 0 0 var(--border-width-l) var(--border-focus); +} + +.migration-hint { + display: flex; + align-items: flex-start; + gap: var(--gap-s); + padding: 0 var(--space-2); +} + +.migration-hint-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + color: var(--fgcolor-neutral-tertiary); + margin-top: 1px; +} + +.migration-hint-icon svg { + width: 100%; + height: 100%; +} + +.migration-code { + padding: 1px 4px; + border-radius: var(--border-radius-xs, 4px); + background: var(--bgcolor-neutral-secondary); + font-family: monospace; + font-size: inherit; +} diff --git a/app/views/install/installer/js/installer.js b/app/views/install/installer/js/installer.js index 463b7f6221..4433d67b99 100644 --- a/app/views/install/installer/js/installer.js +++ b/app/views/install/installer/js/installer.js @@ -12,7 +12,7 @@ const { validateInstallRequest } = window.InstallerStepsProgress || {}; const isUpgrade = document.body?.dataset.upgrade === 'true'; - const stepFlow = isUpgrade ? [1, 4, 5] : [1, 2, 3, 4, 5]; + const stepFlow = isUpgrade ? [1, 6, 4, 5] : [1, 2, 3, 4, 5]; const cardSteps = stepFlow.filter((step) => step !== 5); const normalizeStep = (step) => { @@ -53,7 +53,7 @@ let pendingStep = null; let pendingPushState = false; - const clampStep = (step) => Math.max(1, Math.min(5, step)); + const clampStep = (step) => Math.max(1, Math.min(6, step)); const isInstallLocked = () => Boolean(window.InstallerSteps?.isInstallLocked?.()); const scrollToFirstError = (panel) => { diff --git a/app/views/install/installer/js/modules/context.js b/app/views/install/installer/js/modules/context.js index 4917a1bfe9..6f215da899 100644 --- a/app/views/install/installer/js/modules/context.js +++ b/app/views/install/installer/js/modules/context.js @@ -14,6 +14,7 @@ ENV_VARS: 'env-vars', DOCKER_CONTAINERS: 'docker-containers', ACCOUNT_SETUP: 'account-setup', + MIGRATION: 'migration', SSL_CERTIFICATE: 'ssl-certificate', REDIRECT: 'redirect' }); @@ -52,6 +53,11 @@ id: STEP_IDS.DOCKER_CONTAINERS, inProgress: 'Restarting Docker containers...', done: 'Docker containers restarted' + }, + { + id: STEP_IDS.MIGRATION, + inProgress: 'Running database migration...', + done: 'Database migration completed' } ] : [ { @@ -95,7 +101,7 @@ const clampStep = (step) => { const numeric = Number(step); if (Number.isNaN(numeric)) return 1; - return Math.max(1, Math.min(5, numeric)); + return Math.max(1, Math.min(6, numeric)); }; window.InstallerStepsContext = Object.freeze({ diff --git a/app/views/install/installer/js/modules/progress.js b/app/views/install/installer/js/modules/progress.js index d066908b03..9087a192ef 100644 --- a/app/views/install/installer/js/modules/progress.js +++ b/app/views/install/installer/js/modules/progress.js @@ -373,7 +373,8 @@ opensslKey: (formState?.opensslKey || '').trim(), assistantOpenAIKey: normalizedAssistantKey, accountEmail: normalizedAccountEmail, - accountPassword: normalizedAccountPassword + accountPassword: normalizedAccountPassword, + runMigration: formState?.runMigration ?? false }; }; @@ -1069,14 +1070,25 @@ startInstallStream(newInstallId); }; + const recoverToLastStep = () => { + clearInstallId?.(); + clearInstallLock?.(); + const url = new URL(window.location.href); + const lastStep = url.searchParams.get('step'); + // Stay on the current URL so the user keeps their place; + // only navigate away if we're already on step 5 (the + // progress screen) since there's nothing to show. + if (!lastStep || String(lastStep) === '5') { + window.location.href = '/?step=1'; + } + }; + const lock = getInstallLock?.(); const existingInstallId = lock?.installId || getStoredInstallId?.(); if (existingInstallId) { resumeInstall(existingInstallId).then((resumed) => { if (!resumed) { - clearInstallId?.(); - clearInstallLock?.(); - window.location.href = '/?step=1'; + recoverToLastStep(); } }); } else { diff --git a/app/views/install/installer/js/steps.js b/app/views/install/installer/js/steps.js index c9430b7afd..fb76b5f72d 100644 --- a/app/views/install/installer/js/steps.js +++ b/app/views/install/installer/js/steps.js @@ -329,6 +329,30 @@ } }; + const initStep6 = (root) => { + if (!root) return; + syncInstallLockFlag?.(); + applyLockPayload?.(); + applyBodyDefaults?.(); + + const checkbox = root.querySelector('#run-migration'); + if (checkbox) { + if (formState.runMigration !== undefined) { + checkbox.checked = formState.runMigration; + } else { + formState.runMigration = checkbox.checked; + } + checkbox.addEventListener('change', () => { + formState.runMigration = checkbox.checked; + dispatchStateChange?.('runMigration'); + }); + } + + if (isInstallLocked?.()) { + disableControls?.(root); + } + }; + const initStep = (step, container) => { if (!container) return; const root = container.querySelector('.step-layout') || container; @@ -346,6 +370,7 @@ if (normalized === 3) initStep3(root); if (normalized === 4) initStep4(root); if (normalized === 5) Progress.initStep5?.(root); + if (normalized === 6) initStep6(root); }; window.InstallerSteps = { diff --git a/app/views/install/installer/templates/steps/step-6.phtml b/app/views/install/installer/templates/steps/step-6.phtml new file mode 100644 index 0000000000..ca253c9907 --- /dev/null +++ b/app/views/install/installer/templates/steps/step-6.phtml @@ -0,0 +1,37 @@ + +
+
+
+

Database migration

+

+ Run database migration after the update to apply schema changes. +

+
+ +
+ + +
+ + + + + To run manually later: docker compose exec appwrite migrate + +
+
+
+
diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Install.php b/src/Appwrite/Platform/Installer/Http/Installer/Install.php index fa978892d3..482a30eab4 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Install.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Install.php @@ -43,6 +43,7 @@ class Install extends Action ->param('database', '', new WhiteList(['mongodb', 'mariadb', 'postgresql']), 'Database adapter', true) ->param('installId', '', new Text(64, 0), 'Installation ID', true) ->param('retryStep', null, new Nullable(new WhiteList([Server::STEP_DOCKER_COMPOSE, Server::STEP_ENV_VARS, Server::STEP_DOCKER_CONTAINERS], true)), 'Retry from step', true) + ->param('runMigration', false, new \Utopia\Validator\Boolean(true), 'Run database migration after upgrade', true) ->inject('request') ->inject('response') ->inject('swooleResponse') @@ -64,6 +65,7 @@ class Install extends Action string $database, string $installId, ?string $retryStep, + bool $runMigration, Request $request, Response $response, SwooleResponse $swooleResponse, @@ -355,6 +357,7 @@ class Install extends Action $config->isUpgrade(), $account, $onComplete, + $runMigration, ); $onComplete(); diff --git a/src/Appwrite/Platform/Installer/Http/Installer/View.php b/src/Appwrite/Platform/Installer/Http/Installer/View.php index ce308aa906..dea356eaaf 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/View.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/View.php @@ -24,7 +24,7 @@ class View extends Action ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/') ->desc('Serve installer UI') - ->param('step', 1, new Integer(true), 'Step number (1-5)', true) + ->param('step', 1, new Integer(true), 'Step number (1-6)', true) ->param('partial', null, new Nullable(new Text(1, 0)), 'Render partial step only', true) ->inject('request') ->inject('response') @@ -52,10 +52,13 @@ class View extends Action $defaultEmailCertificates = 'walterobrien@example.com'; } - $step = max(1, min(5, $step)); + $step = max(1, min(6, $step)); if ($isUpgrade && ($step === 2 || $step === 3)) { $step = 4; } + if (!$isUpgrade && $step === 6) { + $step = 4; + } $partialFile = $paths['views'] . "/installer/templates/steps/step-{$step}.phtml"; if (!is_file($partialFile)) { diff --git a/src/Appwrite/Platform/Installer/Server.php b/src/Appwrite/Platform/Installer/Server.php index 8996b9a4c3..6d9cd5412f 100644 --- a/src/Appwrite/Platform/Installer/Server.php +++ b/src/Appwrite/Platform/Installer/Server.php @@ -29,6 +29,7 @@ class Server public const string STEP_DOCKER_COMPOSE = 'docker-compose'; public const string STEP_DOCKER_CONTAINERS = 'docker-containers'; public const string STEP_ACCOUNT_SETUP = 'account-setup'; + public const string STEP_MIGRATION = 'migration'; public const string STEP_SSL_CERTIFICATE = 'ssl-certificate'; public const string STATUS_IN_PROGRESS = 'in-progress'; diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index a1cc1d094e..95846aad87 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -514,6 +514,7 @@ class Install extends Action bool $isUpgrade = false, array $account = [], ?callable $onComplete = null, + bool $runMigration = false, ): void { $isLocalInstall = $this->isLocalInstall(); $this->applyLocalPaths($isLocalInstall, false); @@ -636,6 +637,16 @@ class Install extends Action $this->createInitialAdminAccount($account, $progress, $apiUrl, $domain); } + if ($isUpgrade && $runMigration) { + // Allow the containers-completed SSE event to flush + // before blocking on migration exec + usleep(200_000); + $currentStep = InstallerServer::STEP_MIGRATION; + $this->runDatabaseMigration($progress, $isLocalInstall); + } elseif ($isUpgrade) { + $this->updateProgress($progress, InstallerServer::STEP_MIGRATION, InstallerServer::STATUS_COMPLETED, messageOverride: 'Migration skipped'); + } + // Signal completion before tracking so the SSE stream // finishes and the frontend can redirect immediately. if ($onComplete) { @@ -745,6 +756,46 @@ class Install extends Action } } + private function runDatabaseMigration(?callable $progress, bool $isLocalInstall): void + { + $this->updateProgress( + $progress, + InstallerServer::STEP_MIGRATION, + InstallerServer::STATUS_IN_PROGRESS, + messageOverride: 'Running database migration...' + ); + + // Allow the SSE chunk to flush before the blocking exec + usleep(100_000); + + // Static command — no user input involved + $command = $isLocalInstall + ? 'docker compose exec appwrite migrate 2>&1' + : 'docker exec appwrite migrate 2>&1'; + + $output = []; + \exec($command, $output, $exit); + + if ($exit !== 0) { + $message = trim(implode("\n", $output)); + $this->updateProgress( + $progress, + InstallerServer::STEP_MIGRATION, + InstallerServer::STATUS_ERROR, + details: ['output' => $message], + messageOverride: 'Migration failed: ' . ($message ?: 'exit code ' . $exit) + ); + throw new \RuntimeException('Database migration failed', 0, $message !== '' ? new \RuntimeException($message) : null); + } + + $this->updateProgress( + $progress, + InstallerServer::STEP_MIGRATION, + InstallerServer::STATUS_COMPLETED, + messageOverride: 'Database migration completed' + ); + } + private function trackSelfHostedInstall(array $input, bool $isUpgrade, string $version, array $account): void { if ($this->isLocalInstall()) { diff --git a/tests/unit/Platform/Modules/Installer/ModuleTest.php b/tests/unit/Platform/Modules/Installer/ModuleTest.php index 0b7e7effcb..ff77a7d010 100644 --- a/tests/unit/Platform/Modules/Installer/ModuleTest.php +++ b/tests/unit/Platform/Modules/Installer/ModuleTest.php @@ -134,7 +134,7 @@ class ModuleTest extends TestCase $this->assertActionParams($action, [ 'appDomain', 'httpPort', 'httpsPort', 'emailCertificates', 'opensslKey', 'assistantOpenAIKey', 'accountEmail', 'accountPassword', 'database', - 'installId', 'retryStep', + 'installId', 'retryStep', 'runMigration', ]); $this->assertActionInjects($action, ['request', 'response', 'swooleResponse', 'installerState', 'installerConfig', 'installerPaths']); } From b47ac00ca8161b1a73fa2c7c933a9a4f220f57ef Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 31 Mar 2026 21:08:29 +1300 Subject: [PATCH 3/3] (refactor): rename migrate param and add --migrate flag to upgrade task --- app/views/install/installer/js/modules/progress.js | 2 +- app/views/install/installer/js/steps.js | 10 +++++----- .../install/installer/templates/steps/step-6.phtml | 2 +- .../Platform/Installer/Http/Installer/Install.php | 6 +++--- src/Appwrite/Platform/Tasks/Install.php | 7 ++++--- src/Appwrite/Platform/Tasks/Upgrade.php | 5 ++++- tests/unit/Platform/Modules/Installer/ModuleTest.php | 2 +- 7 files changed, 19 insertions(+), 15 deletions(-) diff --git a/app/views/install/installer/js/modules/progress.js b/app/views/install/installer/js/modules/progress.js index 9087a192ef..868f820c95 100644 --- a/app/views/install/installer/js/modules/progress.js +++ b/app/views/install/installer/js/modules/progress.js @@ -374,7 +374,7 @@ assistantOpenAIKey: normalizedAssistantKey, accountEmail: normalizedAccountEmail, accountPassword: normalizedAccountPassword, - runMigration: formState?.runMigration ?? false + migrate: formState?.migrate ?? false }; }; diff --git a/app/views/install/installer/js/steps.js b/app/views/install/installer/js/steps.js index fb76b5f72d..b34389b561 100644 --- a/app/views/install/installer/js/steps.js +++ b/app/views/install/installer/js/steps.js @@ -337,14 +337,14 @@ const checkbox = root.querySelector('#run-migration'); if (checkbox) { - if (formState.runMigration !== undefined) { - checkbox.checked = formState.runMigration; + if (formState.migrate !== undefined) { + checkbox.checked = formState.migrate; } else { - formState.runMigration = checkbox.checked; + formState.migrate = checkbox.checked; } checkbox.addEventListener('change', () => { - formState.runMigration = checkbox.checked; - dispatchStateChange?.('runMigration'); + formState.migrate = checkbox.checked; + dispatchStateChange?.('migrate'); }); } diff --git a/app/views/install/installer/templates/steps/step-6.phtml b/app/views/install/installer/templates/steps/step-6.phtml index ca253c9907..9a8838ae3a 100644 --- a/app/views/install/installer/templates/steps/step-6.phtml +++ b/app/views/install/installer/templates/steps/step-6.phtml @@ -17,7 +17,7 @@ $isUpgrade = $isUpgrade ?? false; Recommended when upgrading to a new version - + diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Install.php b/src/Appwrite/Platform/Installer/Http/Installer/Install.php index 482a30eab4..8aaaf621bb 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Install.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Install.php @@ -43,7 +43,7 @@ class Install extends Action ->param('database', '', new WhiteList(['mongodb', 'mariadb', 'postgresql']), 'Database adapter', true) ->param('installId', '', new Text(64, 0), 'Installation ID', true) ->param('retryStep', null, new Nullable(new WhiteList([Server::STEP_DOCKER_COMPOSE, Server::STEP_ENV_VARS, Server::STEP_DOCKER_CONTAINERS], true)), 'Retry from step', true) - ->param('runMigration', false, new \Utopia\Validator\Boolean(true), 'Run database migration after upgrade', true) + ->param('migrate', false, new \Utopia\Validator\Boolean(true), 'Run database migration after upgrade', true) ->inject('request') ->inject('response') ->inject('swooleResponse') @@ -65,7 +65,7 @@ class Install extends Action string $database, string $installId, ?string $retryStep, - bool $runMigration, + bool $migrate, Request $request, Response $response, SwooleResponse $swooleResponse, @@ -357,7 +357,7 @@ class Install extends Action $config->isUpgrade(), $account, $onComplete, - $runMigration, + $migrate, ); $onComplete(); diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index 95846aad87..1be10f537f 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -36,6 +36,7 @@ class Install extends Action private const string GROWTH_API_URL = 'https://growth.appwrite.io/v1'; protected bool $isUpgrade = false; + protected bool $migrate = false; protected string $hostPath = ''; protected ?bool $isLocalInstall = null; protected ?array $installerConfig = null; @@ -323,7 +324,7 @@ class Install extends Action $shouldGenerateSecrets = !$existingInstallation && !$isUpgrade; $input = $this->prepareEnvironmentVariables($userInput, $vars, $shouldGenerateSecrets); - $this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart, null, null, $isUpgrade); + $this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart, null, null, $isUpgrade, migrate: $this->migrate); } @@ -514,7 +515,7 @@ class Install extends Action bool $isUpgrade = false, array $account = [], ?callable $onComplete = null, - bool $runMigration = false, + bool $migrate = false, ): void { $isLocalInstall = $this->isLocalInstall(); $this->applyLocalPaths($isLocalInstall, false); @@ -637,7 +638,7 @@ class Install extends Action $this->createInitialAdminAccount($account, $progress, $apiUrl, $domain); } - if ($isUpgrade && $runMigration) { + if ($isUpgrade && $migrate) { // Allow the containers-completed SSE event to flush // before blocking on migration exec usleep(200_000); diff --git a/src/Appwrite/Platform/Tasks/Upgrade.php b/src/Appwrite/Platform/Tasks/Upgrade.php index 1d61180963..6ef9c7134d 100644 --- a/src/Appwrite/Platform/Tasks/Upgrade.php +++ b/src/Appwrite/Platform/Tasks/Upgrade.php @@ -30,6 +30,7 @@ class Upgrade extends Install ->param('interactive', 'Y', new Text(1), 'Run an interactive session', true) ->param('no-start', false, new Boolean(true), 'Run an interactive session', true) ->param('database', 'mongodb', new Text(length: 0), 'Database to use (mongodb|mariadb|postgresql)', true) + ->param('migrate', true, new Boolean(true), 'Run database migration after upgrade', true) ->callback($this->action(...)); } @@ -40,9 +41,11 @@ class Upgrade extends Install string $image, string $interactive, bool $noStart, - string $database + string $database, + bool $migrate = true, ): void { $this->isUpgrade = true; + $this->migrate = $migrate; $isLocalInstall = $this->isLocalInstall(); $this->applyLocalPaths($isLocalInstall, true); diff --git a/tests/unit/Platform/Modules/Installer/ModuleTest.php b/tests/unit/Platform/Modules/Installer/ModuleTest.php index ff77a7d010..507a4e25f6 100644 --- a/tests/unit/Platform/Modules/Installer/ModuleTest.php +++ b/tests/unit/Platform/Modules/Installer/ModuleTest.php @@ -134,7 +134,7 @@ class ModuleTest extends TestCase $this->assertActionParams($action, [ 'appDomain', 'httpPort', 'httpsPort', 'emailCertificates', 'opensslKey', 'assistantOpenAIKey', 'accountEmail', 'accountPassword', 'database', - 'installId', 'retryStep', 'runMigration', + 'installId', 'retryStep', 'migrate', ]); $this->assertActionInjects($action, ['request', 'response', 'swooleResponse', 'installerState', 'installerConfig', 'installerPaths']); }