From 1acfef5f5d790e3dbd60f6f8193a7832de2a61f3 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 24 Mar 2026 21:25:47 +1300 Subject: [PATCH 01/14] (fix): set installer session cookie domain to match Appwrite convention --- .../Installer/Http/Installer/Complete.php | 52 +++++++++++++++++-- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Complete.php b/src/Appwrite/Platform/Installer/Http/Installer/Complete.php index 92a00651fe..69f7d4b072 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Complete.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Complete.php @@ -48,9 +48,10 @@ class Complete extends Action @touch(Server::INSTALLER_COMPLETE_FILE); - if (!$sessionSecret && $installId !== '') { - $data = $state->readProgressFile($installId); - $details = $data['details'][Server::STEP_ACCOUNT_SETUP] ?? []; + $progressData = ($installId !== '') ? $state->readProgressFile($installId) : []; + + if (!$sessionSecret) { + $details = $progressData['details'][Server::STEP_ACCOUNT_SETUP] ?? []; if (!empty($details['sessionSecret'])) { $sessionSecret = $details['sessionSecret']; $sessionId = $sessionId ?: ($details['sessionId'] ?? ''); @@ -68,8 +69,11 @@ class Complete extends Action $expires = $timestamp; } } - $response->addCookie('a_session_console', $sessionSecret, $expires, '/', '', $isHttps, true, $sameSite); - $response->addCookie('a_session_console_legacy', $sessionSecret, $expires, '/', '', $isHttps, true, $sameSite); + $appDomain = $progressData['payload']['appDomain'] ?? ''; + $cookieDomain = $this->buildCookieDomain($appDomain ?: $request->getHostname()); + + $response->addCookie('a_session_console', $sessionSecret, $expires, '/', $cookieDomain, $isHttps, true, $sameSite); + $response->addCookie('a_session_console_legacy', $sessionSecret, $expires, '/', $cookieDomain, $isHttps, true, $sameSite); if ($sessionId) { $response->addHeader('X-Appwrite-Session', $sessionId); } @@ -79,4 +83,42 @@ class Complete extends Action $response->json(['success' => true]); } + + /** + * Compute the cookie domain to match Appwrite's convention in general.php. + * + * For localhost and IP addresses the domain is left empty (host-only cookie). + * For real hostnames, the domain is prefixed with a dot so the cookie matches + * Appwrite's default `'.' . $request->getHostname()` behaviour and lives in + * the same cookie-jar slot — preventing stale ghost cookies after logout. + */ + private function buildCookieDomain(string $raw): string + { + $hostname = $this->extractHostname($raw); + if ($hostname === '' || $hostname === 'localhost' || $hostname === '0.0.0.0' || $hostname === 'traefik') { + return ''; + } + if (filter_var($hostname, FILTER_VALIDATE_IP) !== false) { + return ''; + } + return '.' . $hostname; + } + + /** + * Extract the bare hostname from an appDomain value, stripping any port + * suffix or IPv6 bracket notation. + */ + private function extractHostname(string $domain): string + { + $domain = trim($domain); + if ($domain === '') { + return ''; + } + if (str_starts_with($domain, '[')) { + $end = strpos($domain, ']'); + return $end !== false ? substr($domain, 1, $end - 1) : ''; + } + $parts = explode(':', $domain); + return count($parts) <= 2 ? strtolower($parts[0]) : strtolower($domain); + } } From 4fffeda59629b43ec7ae9116c8b59f9640d5422d Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 24 Mar 2026 21:25:51 +1300 Subject: [PATCH 02/14] (chore): bump console image to 7.8.25 and drop postgresql from allowed databases --- app/views/install/compose.phtml | 4 ++-- docker-compose.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index 0f4df352bd..45cc7815f8 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -13,7 +13,7 @@ $organization = $this->getParam('organization', ''); $image = $this->getParam('image', ''); $enableAssistant = $this->getParam('enableAssistant', false); $dbService = $this->getParam('database', 'mongodb'); -$allowedDbServices = ['mariadb', 'mongodb', 'postgresql']; +$allowedDbServices = ['mariadb', 'mongodb']; if (!\in_array($dbService, $allowedDbServices, true)) { $dbService = 'mongodb'; } @@ -194,7 +194,7 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); appwrite-console: <<: *x-logging container_name: appwrite-console - image: /console:7.6.4 + image: /console:7.8.25 restart: unless-stopped networks: - appwrite diff --git a/docker-compose.yml b/docker-compose.yml index f3cb982526..5aac5c2fb1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -254,7 +254,7 @@ services: appwrite-console: <<: *x-logging container_name: appwrite-console - image: appwrite/console:7.5.7 + image: appwrite/console:7.8.25 restart: unless-stopped networks: - appwrite From 76684874e95bf38019041b25902f69379a3e877c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 24 Mar 2026 21:25:57 +1300 Subject: [PATCH 03/14] =?UTF-8?q?(feat):=20installer=20improvements=20?= =?UTF-8?q?=E2=80=94=20reset,=20state=20resilience,=20container=20progress?= =?UTF-8?q?,=20SSL=20email=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views/install/installer/css/styles.css | 23 ++++ .../install/installer/js/modules/progress.js | 77 +++++++++++- .../install/installer/js/modules/state.js | 50 ++++++-- app/views/install/installer/js/modules/ui.js | 3 + app/views/install/installer/js/steps.js | 5 +- .../installer/templates/steps/step-5.phtml | 10 ++ .../Installer/Http/Installer/Install.php | 44 +++++-- .../Installer/Http/Installer/Reset.php | 110 ++++++++++++++++++ .../Installer/Http/Installer/Status.php | 2 + .../Platform/Installer/Runtime/State.php | 14 ++- .../Platform/Installer/Services/Http.php | 2 + src/Appwrite/Platform/Tasks/Install.php | 92 +++++++++++++-- .../Platform/Modules/Installer/ModuleTest.php | 20 +++- 13 files changed, 410 insertions(+), 42 deletions(-) create mode 100644 src/Appwrite/Platform/Installer/Http/Installer/Reset.php diff --git a/app/views/install/installer/css/styles.css b/app/views/install/installer/css/styles.css index b1d8fe5089..b667d29914 100644 --- a/app/views/install/installer/css/styles.css +++ b/app/views/install/installer/css/styles.css @@ -691,6 +691,18 @@ body { transform: translateY(10px); } +.install-counter { + margin-left: auto; + opacity: 0; + transition: opacity 0.2s ease; + white-space: nowrap; + user-select: none; +} + +.install-row[data-status='in-progress'] .install-counter:not(:empty) { + opacity: 1; +} + .install-row-toggle { margin-left: auto; width: 32px; @@ -897,6 +909,17 @@ body { gap: var(--gap-m); } +.install-global-actions { + display: flex; + justify-content: center; + gap: var(--gap-m); + padding: var(--space-4) 0; +} + +.install-global-actions.is-hidden { + display: none; +} + .install-error-details .button { align-self: center; margin-top: 0; diff --git a/app/views/install/installer/js/modules/progress.js b/app/views/install/installer/js/modules/progress.js index bb1fa2f551..2eaba4ca36 100644 --- a/app/views/install/installer/js/modules/progress.js +++ b/app/views/install/installer/js/modules/progress.js @@ -114,7 +114,7 @@ return step.inProgress; }; - const updateInstallRow = (row, step, status, message) => { + const updateInstallRow = (row, step, status, message, details) => { if (!row || !step) return; row.dataset.status = status; row.dataset.step = step.id; @@ -138,6 +138,15 @@ } } + const counter = row.querySelector('[data-install-counter]'); + if (counter) { + const started = details?.containerStarted; + const total = details?.containerTotal; + counter.textContent = (status === STATUS.IN_PROGRESS && started > 0 && total > 0) + ? `${started}/${total}` + : ''; + } + // Show/hide "Navigate to Console" button for account setup errors const consoleBtn = row.querySelector('[data-install-console]'); if (consoleBtn) { @@ -349,7 +358,7 @@ const normalizedDomain = (formState?.appDomain || '').trim() || 'localhost'; const normalizedHttpPort = (formState?.httpPort || '').trim() || '80'; const normalizedHttpsPort = (formState?.httpsPort || '').trim() || '443'; - const normalizedEmail = (formState?.emailCertificates || '').trim(); + const normalizedEmail = (formState?.emailCertificates || '').trim() || (formState?.accountEmail || '').trim(); const normalizedAssistantKey = (formState?.assistantOpenAIKey || '').trim(); const normalizedAccountEmail = (formState?.accountEmail || '').trim(); const normalizedAccountPassword = (formState?.accountPassword || '').trim(); @@ -529,7 +538,7 @@ if (!state) return; const row = ensureRow(step); if (row) { - updateInstallRow(row, step, state.status || STATUS.IN_PROGRESS, state.message); + updateInstallRow(row, step, state.status || STATUS.IN_PROGRESS, state.message, state.details); if (state.status === STATUS?.ERROR) { updateInstallErrorDetails(row, { message: state.message, @@ -579,6 +588,9 @@ } } } + if (payload.status === STATUS.ERROR) { + showGlobalActions(); + } scheduleFallback(); }; @@ -616,6 +628,7 @@ const applySnapshot = (snapshot) => { if (!snapshot || !snapshot.steps) return; + let hasErrors = false; INSTALLATION_STEPS.forEach((step) => { const detail = snapshot.steps[step.id]; if (!detail) return; @@ -624,8 +637,14 @@ message: detail.message, details: snapshot.details?.[step.id] }); + if (detail.status === STATUS.ERROR) { + hasErrors = true; + } }); renderProgress(); + if (hasErrors) { + showGlobalActions(); + } }; const checkAllCompleted = () => { @@ -966,6 +985,58 @@ } }); + const globalActions = root.querySelector('[data-install-global-actions]'); + + const showGlobalActions = () => { + if (globalActions) { + globalActions.classList.remove('is-hidden'); + } + }; + + const performReset = async (hard) => { + const installId = activeInstall?.installId || getInstallLock?.()?.installId || getStoredInstallId?.(); + + try { + const res = await fetch('/install/reset', { + method: 'POST', + headers: withCsrfHeader({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ installId: installId || '', hard }) + }); + if (hard && !res.ok) { + const data = await res.json().catch(() => ({})); + showToast?.({ + status: 'error', + title: 'Reset failed', + description: data?.message || 'Could not stop containers. Try running "docker compose down -v" manually.', + dismissible: true + }); + return; + } + } catch (e) {} + + clearInstallLock?.(); + clearInstallId?.(); + cleanupInstallFlow(); + window.location.href = '/?step=1'; + }; + + const startOverButton = root.querySelector('[data-install-start-over]'); + if (startOverButton) { + startOverButton.addEventListener('click', () => performReset(false)); + } + + const hardResetButton = root.querySelector('[data-install-hard-reset]'); + if (hardResetButton) { + hardResetButton.addEventListener('click', () => { + const confirmed = window.confirm( + 'This will stop all containers, remove all volumes (including database data, uploads, and certificates), and delete configuration files.\n\nThis action cannot be undone. Continue?' + ); + if (confirmed) { + performReset(true); + } + }); + } + // When the user switches back to this tab, check if installation // finished while the tab was in the background. document.addEventListener('visibilitychange', () => { diff --git a/app/views/install/installer/js/modules/state.js b/app/views/install/installer/js/modules/state.js index 9fcf9969a8..3c7fcd2427 100644 --- a/app/views/install/installer/js/modules/state.js +++ b/app/views/install/installer/js/modules/state.js @@ -7,6 +7,8 @@ const INSTALL_LOCK_KEY = 'appwrite-install-lock'; const INSTALL_ID_KEY = 'appwrite-install-id'; + const INSTALL_LOCK_LOCAL_KEY = 'appwrite-install-lock-backup'; + const INSTALL_ID_LOCAL_KEY = 'appwrite-install-id-backup'; const formState = { appDomain: null, @@ -55,13 +57,24 @@ const getInstallLock = () => { try { const raw = sessionStorage.getItem(INSTALL_LOCK_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object') return null; - return parsed; - } catch (error) { - return null; - } + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') return parsed; + } + } catch (error) {} + + try { + const raw = localStorage.getItem(INSTALL_LOCK_LOCAL_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + sessionStorage.setItem(INSTALL_LOCK_KEY, raw); + return parsed; + } + } + } catch (error) {} + + return null; }; const setInstallLock = (installId, payload) => { @@ -79,6 +92,9 @@ try { sessionStorage.setItem(INSTALL_LOCK_KEY, JSON.stringify(lock)); } catch (error) {} + try { + localStorage.setItem(INSTALL_LOCK_LOCAL_KEY, JSON.stringify(lock)); + } catch (error) {} if (document.body) { document.body.dataset.installLocked = 'true'; } @@ -89,6 +105,9 @@ try { sessionStorage.removeItem(INSTALL_LOCK_KEY); } catch (error) {} + try { + localStorage.removeItem(INSTALL_LOCK_LOCAL_KEY); + } catch (error) {} if (document.body) { delete document.body.dataset.installLocked; } @@ -121,22 +140,31 @@ const getStoredInstallId = () => { try { - return sessionStorage.getItem(INSTALL_ID_KEY); - } catch (error) { - return null; - } + const val = sessionStorage.getItem(INSTALL_ID_KEY); + if (val) return val; + } catch (error) {} + try { + return localStorage.getItem(INSTALL_ID_LOCAL_KEY); + } catch (error) {} + return null; }; const storeInstallId = (installId) => { try { sessionStorage.setItem(INSTALL_ID_KEY, installId); } catch (error) {} + try { + localStorage.setItem(INSTALL_ID_LOCAL_KEY, installId); + } catch (error) {} }; const clearInstallId = () => { try { sessionStorage.removeItem(INSTALL_ID_KEY); } catch (error) {} + try { + localStorage.removeItem(INSTALL_ID_LOCAL_KEY); + } catch (error) {} }; window.InstallerStepsState = { diff --git a/app/views/install/installer/js/modules/ui.js b/app/views/install/installer/js/modules/ui.js index bde4cb7c44..a41a657602 100644 --- a/app/views/install/installer/js/modules/ui.js +++ b/app/views/install/installer/js/modules/ui.js @@ -240,6 +240,9 @@ if (key === 'database') { value = toDatabaseLabel(formState?.database); } + if (key === 'emailCertificates' && !value) { + value = formState?.accountEmail; + } if (value) { node.textContent = value; } diff --git a/app/views/install/installer/js/steps.js b/app/views/install/installer/js/steps.js index 2a71d075cc..c9430b7afd 100644 --- a/app/views/install/installer/js/steps.js +++ b/app/views/install/installer/js/steps.js @@ -390,10 +390,7 @@ if (!parsePort(httpPort, 'HTTP')) valid = false; if (!parsePort(httpsPort, 'HTTPS')) valid = false; - if (!sslEmail || !sslEmail.value.trim()) { - setFieldError?.(sslEmail, 'Please enter an email address for SSL certificates'); - valid = false; - } else if (!isValidEmail?.(sslEmail.value.trim())) { + if (sslEmail && sslEmail.value.trim() && !isValidEmail?.(sslEmail.value.trim())) { setFieldError?.(sslEmail, 'Please enter a valid email address'); valid = false; } diff --git a/app/views/install/installer/templates/steps/step-5.phtml b/app/views/install/installer/templates/steps/step-5.phtml index 8fa810b259..088b96bea8 100644 --- a/app/views/install/installer/templates/steps/step-5.phtml +++ b/app/views/install/installer/templates/steps/step-5.phtml @@ -30,6 +30,7 @@ $isUpgrade = $isUpgrade ?? false; + @@ -50,4 +51,13 @@ $isUpgrade = $isUpgrade ?? false; + + diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Install.php b/src/Appwrite/Platform/Installer/Http/Installer/Install.php index 0b2fa17c0d..e29222a703 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Install.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Install.php @@ -35,7 +35,7 @@ class Install extends Action ->param('appDomain', '', new AppDomain(), 'Application domain (hostname, IP, or bracket IPv6 with optional port)') ->param('httpPort', 80, new Range(1, 65535), 'HTTP port') ->param('httpsPort', 443, new Range(1, 65535), 'HTTPS port') - ->param('emailCertificates', '', new Email(), 'Email for SSL certificates') + ->param('emailCertificates', '', new Email(allowEmpty: true), 'Email for SSL certificates', true) ->param('opensslKey', '', new Text(64, 0), 'Secret API key', true) ->param('assistantOpenAIKey', '', new Text(256, 0), 'OpenAI API key for assistant', true) ->param('accountEmail', '', new Email(allowEmpty: true), 'Account email address', true) @@ -90,6 +90,9 @@ class Install extends Action $appDomain = trim($appDomain); $emailCertificates = trim($emailCertificates); + if ($emailCertificates === '') { + $emailCertificates = trim($accountEmail); + } $opensslKey = trim($opensslKey); $assistantOpenAIKey = trim($assistantOpenAIKey); @@ -140,6 +143,8 @@ class Install extends Action @unlink(Server::INSTALLER_COMPLETE_FILE); + $state->clearStaleLockIfNeeded(); + try { $lockResult = $state->reserveGlobalLock($installId); } catch (\Throwable $e) { @@ -175,15 +180,23 @@ class Install extends Action if (file_exists($existingPath)) { $existing = $state->readProgressFile($installId); if (!empty($existing['steps']) && $retryStep === null) { - $state->updateGlobalLock($installId, Server::STATUS_ERROR); - if ($wantsStream) { - $this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, ['message' => 'Installation already started']); - $swooleResponse->end(); + $previousHadError = isset($existing['error']); + $allCompleted = !$previousHadError && $this->allStepsCompleted($existing['steps']); + + if ($previousHadError || $allCompleted) { + @unlink($existingPath); + $existing = null; } else { - $response->setStatusCode(Response::STATUS_CODE_CONFLICT); - $response->json(['success' => false, 'message' => 'Installation already started']); + $state->updateGlobalLock($installId, Server::STATUS_ERROR); + if ($wantsStream) { + $this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, ['message' => 'Installation already started']); + $swooleResponse->end(); + } else { + $response->setStatusCode(Response::STATUS_CODE_CONFLICT); + $response->json(['success' => false, 'message' => 'Installation already started']); + } + return; } - return; } } @@ -207,7 +220,8 @@ class Install extends Action '_APP_ASSISTANT_OPENAI_API_KEY' => $assistantOpenAIKey, ]; - if ($this->hasPayload($existing)) { + $previousHadError = is_array($existing) && isset($existing['error']); + if ($this->hasPayload($existing) && !$previousHadError) { $stored = $existing['payload']; $inputValues = [ 'httpPort' => (string) $httpPort, @@ -368,8 +382,6 @@ class Install extends Action $state->updateGlobalLock($installId, Server::STATUS_ERROR); } - @unlink(Server::INSTALLER_CONFIG_FILE); - if ($wantsStream) { $this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, [ 'message' => $e->getMessage(), @@ -392,6 +404,16 @@ class Install extends Action return is_array($data) && isset($data['payload']) && is_array($data['payload']); } + private function allStepsCompleted(array $steps): bool + { + foreach ($steps as $step) { + if (($step['status'] ?? '') !== Server::STATUS_COMPLETED) { + return false; + } + } + return true; + } + private function deriveNameFromEmail(string $email): string { $parts = explode('@', $email); diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Reset.php b/src/Appwrite/Platform/Installer/Http/Installer/Reset.php new file mode 100644 index 0000000000..8e5b877473 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Http/Installer/Reset.php @@ -0,0 +1,110 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/install/reset') + ->desc('Reset installation state') + ->param('installId', '', new Text(64, 0), 'Installation ID', true) + ->param('hard', false, new Boolean(true), 'Remove all data including volumes and config files', true) + ->inject('request') + ->inject('response') + ->inject('installerState') + ->inject('installerConfig') + ->callback($this->action(...)); + } + + public function action(string $installId, bool $hard, Request $request, Response $response, State $state, Config $config): void + { + if (!Validate::validateCsrf($request)) { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['success' => false, 'message' => 'Invalid CSRF token']); + return; + } + + $installId = $state->sanitizeInstallId($installId); + + if ($installId !== '') { + @unlink($state->progressFilePath($installId)); + $state->updateGlobalLock($installId, Server::STATUS_COMPLETED); + } + + // Use direct clearStaleLock (not throttled) since reset is an + // explicit user action that should guarantee all stale state is gone. + $state->clearStaleLock(); + + if ($hard) { + $error = $this->performHardReset($config); + if ($error !== null) { + $response->setStatusCode(Response::STATUS_CODE_INTERNAL_SERVER_ERROR); + $response->json(['success' => false, 'message' => $error]); + return; + } + } + + $response->json(['success' => true]); + } + + private function performHardReset(Config $config): ?string + { + $isLocal = $config->isLocal(); + $composeFileName = $isLocal ? 'docker-compose.web-installer.yml' : 'docker-compose.yml'; + $envFileName = $isLocal ? '.env.web-installer' : '.env'; + $path = $isLocal ? '/usr/src/code' : '/usr/src/code/appwrite'; + + $composeFile = $path . '/' . $composeFileName; + + if (file_exists($composeFile)) { + $command = array_map(escapeshellarg(...), [ + 'docker', 'compose', + '-f', $composeFile, + ...($isLocal ? ['--project-name', 'appwrite'] : []), + '--project-directory', $path, + 'down', '-v', '--remove-orphans', + ]); + + $output = []; + @exec(implode(' ', $command) . ' 2>&1', $output, $exitCode); + + if ($exitCode !== 0) { + return 'Failed to stop containers: ' . trim(implode("\n", $output)); + } + + @unlink($composeFile); + } + + $envFile = $path . '/' . $envFileName; + if (file_exists($envFile)) { + @unlink($envFile); + } + + @unlink(Server::INSTALLER_CONFIG_FILE); + @unlink(Server::INSTALLER_LOCK_FILE); + + $tempDir = sys_get_temp_dir(); + foreach ((array) glob($tempDir . '/appwrite-install-*.json') as $file) { + @unlink($file); + } + + return null; + } +} diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Status.php b/src/Appwrite/Platform/Installer/Http/Installer/Status.php index e53a501f4c..d6ffa64c8f 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Status.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Status.php @@ -28,6 +28,8 @@ class Status extends Action public function action(string $installId, Response $response, State $state): void { + $state->clearStaleLockIfNeeded(); + $installId = $state->sanitizeInstallId($installId); if ($installId === '') { $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); diff --git a/src/Appwrite/Platform/Installer/Runtime/State.php b/src/Appwrite/Platform/Installer/Runtime/State.php index 5552eb5632..75efd7027c 100644 --- a/src/Appwrite/Platform/Installer/Runtime/State.php +++ b/src/Appwrite/Platform/Installer/Runtime/State.php @@ -13,13 +13,15 @@ class State private const string PATTERN_IPV6_WITH_PORT = '/^\[(.+)](?::(\d+))?$/'; private const int CONFIG_FILE_PERMISSION = 0600; - private const int GLOBAL_LOCK_TIMEOUT_SECONDS = 3600; + private const int GLOBAL_LOCK_TIMEOUT_SECONDS = 300; + private const int STALE_LOCK_CHECK_INTERVAL_SECONDS = 30; private const int PORT_MIN = 1; private const int PORT_MAX = 65535; private array $paths; private bool $bootstrapped = false; + private int $lastStaleLockClearAt = 0; public function __construct(array $paths) { @@ -254,6 +256,16 @@ class State } } + public function clearStaleLockIfNeeded(): void + { + $now = time(); + if ($now - $this->lastStaleLockClearAt < self::STALE_LOCK_CHECK_INTERVAL_SECONDS) { + return; + } + $this->lastStaleLockClearAt = $now; + $this->clearStaleLock(); + } + public function reserveGlobalLock(string $installId): string { return (string) $this->withGlobalLock(function ($handle, $lock) use ($installId) { diff --git a/src/Appwrite/Platform/Installer/Services/Http.php b/src/Appwrite/Platform/Installer/Services/Http.php index 0de977b177..b410e67a26 100644 --- a/src/Appwrite/Platform/Installer/Services/Http.php +++ b/src/Appwrite/Platform/Installer/Services/Http.php @@ -5,6 +5,7 @@ namespace Appwrite\Platform\Installer\Services; use Appwrite\Platform\Installer\Http\Installer\Certificate\Get as CertificateGet; use Appwrite\Platform\Installer\Http\Installer\Complete; use Appwrite\Platform\Installer\Http\Installer\Install; +use Appwrite\Platform\Installer\Http\Installer\Reset; use Appwrite\Platform\Installer\Http\Installer\Shutdown; use Appwrite\Platform\Installer\Http\Installer\Status; use Appwrite\Platform\Installer\Http\Installer\Validate; @@ -22,6 +23,7 @@ class Http extends Service $this->addAction(Validate::getName(), new Validate()); $this->addAction(Complete::getName(), new Complete()); $this->addAction(Shutdown::getName(), new Shutdown()); + $this->addAction(Reset::getName(), new Reset()); $this->addAction(Install::getName(), new Install()); $this->addAction(CertificateGet::getName(), new CertificateGet()); } diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index af768444f2..bc0547379f 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -599,7 +599,7 @@ class Install extends Action if (!$noStart && $startIndex <= 2) { $currentStep = InstallerServer::STEP_DOCKER_CONTAINERS; $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_IN_PROGRESS, $messages); - $this->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI); + $this->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI, $progress, $isUpgrade); if (!$isLocalInstall) { $this->connectInstallerToAppwriteNetwork(); @@ -732,6 +732,8 @@ class Install extends Action $name = $account['name'] ?? 'Admin'; $email = $account['email'] ?? 'admin@selfhosted.local'; + $hostIp = gethostbyname($domain); + $payload = [ 'action' => $type, 'account' => 'self-hosted', @@ -744,6 +746,11 @@ class Install extends Action 'email' => $email, 'domain' => $domain, 'database' => $database, + 'hostIp' => $hostIp !== $domain ? $hostIp : null, + 'os' => php_uname('s') . ' ' . php_uname('r'), + 'arch' => php_uname('m'), + 'cpus' => ((int) trim((string) \shell_exec('nproc'))) ?: null, + 'ram' => (int) round(((float) trim((string) \shell_exec('grep MemTotal /proc/meminfo | awk \'{print $2}\''))) / 1024), ]), ]; @@ -776,12 +783,16 @@ class Install extends Action $healthPath = '/v1/health/version'; - // Local dev: reach Traefik via localhost on the host. - // Docker: reach Appwrite directly via Docker internal DNS (network connect is guaranteed). - $candidate = $isLocalInstall - ? 'http://localhost:' . $httpPort . $healthPath - : self::APPWRITE_API_URL . $healthPath; - $candidates = [$candidate]; + if ($isLocalInstall) { + $candidates = [ + 'http://localhost:' . $httpPort . $healthPath, + ]; + } else { + $candidates = [ + self::APPWRITE_API_URL . $healthPath, + 'http://host.docker.internal:' . $httpPort . $healthPath, + ]; + } $lastErrors = []; @@ -964,7 +975,7 @@ class Install extends Action } } - protected function runDockerCompose(array $input, bool $isLocalInstall, bool $useExistingConfig, bool $isCLI): void + protected function runDockerCompose(array $input, bool $isLocalInstall, bool $useExistingConfig, bool $isCLI, ?callable $progress = null, bool $isUpgrade = false): void { $env = ''; if (!$useExistingConfig) { @@ -1004,8 +1015,16 @@ class Install extends Action $command[] = '-d'; $command[] = '--remove-orphans'; $command[] = '--renew-anon-volumes'; - $commandLine = $env . implode(' ', array_map(escapeshellarg(...), $command)) . ' 2>&1'; - \exec($commandLine, $output, $exit); + $commandLine = $env . implode(' ', array_map(escapeshellarg(...), $command)); + + if ($progress) { + $totalServices = $this->countComposeServices($composeFile); + $result = $this->execWithContainerProgress($commandLine, $totalServices, $progress, $isUpgrade); + $output = $result['output']; + $exit = $result['exit']; + } else { + \exec($commandLine . ' 2>&1', $output, $exit); + } if ($exit !== 0) { $message = trim(implode("\n", $output)); @@ -1017,6 +1036,59 @@ class Install extends Action } } + private function countComposeServices(string $composeFile): int + { + $content = @file_get_contents($composeFile); + if ($content === false) { + return 0; + } + $count = preg_match_all('/^\s*container_name:/m', $content); + return $count !== false ? $count : 0; + } + + private function execWithContainerProgress(string $commandLine, int $totalServices, callable $progress, bool $isUpgrade): array + { + $verb = $isUpgrade ? 'Restarting' : 'Starting'; + $message = "$verb Docker containers..."; + $started = 0; + $output = []; + + $process = proc_open( + $commandLine . ' 2>&1', + [1 => ['pipe', 'w']], + $pipes + ); + + if (!is_resource($process)) { + return ['output' => [], 'exit' => 1]; + } + + while (($line = fgets($pipes[1])) !== false) { + $trimmed = rtrim($line, "\n\r"); + $output[] = $trimmed; + + if (str_contains($trimmed, 'Container') && (str_contains($trimmed, 'Started') || str_contains($trimmed, 'Running'))) { + $started++; + if ($totalServices > 0) { + try { + $progress( + InstallerServer::STEP_DOCKER_CONTAINERS, + InstallerServer::STATUS_IN_PROGRESS, + $message, + ['containerStarted' => $started, 'containerTotal' => $totalServices] + ); + } catch (\Throwable) { + } + } + } + } + + fclose($pipes[1]); + $exit = proc_close($process); + + return ['output' => $output, 'exit' => $exit]; + } + protected function isLocalInstall(): bool { if ($this->isLocalInstall === null) { diff --git a/tests/unit/Platform/Modules/Installer/ModuleTest.php b/tests/unit/Platform/Modules/Installer/ModuleTest.php index f3b4b9d9ae..0b7e7effcb 100644 --- a/tests/unit/Platform/Modules/Installer/ModuleTest.php +++ b/tests/unit/Platform/Modules/Installer/ModuleTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Platform\Modules\Installer; use Appwrite\Platform\Installer\Http\Installer\Complete; use Appwrite\Platform\Installer\Http\Installer\Error; use Appwrite\Platform\Installer\Http\Installer\Install; +use Appwrite\Platform\Installer\Http\Installer\Reset; use Appwrite\Platform\Installer\Http\Installer\Shutdown; use Appwrite\Platform\Installer\Http\Installer\Status; use Appwrite\Platform\Installer\Http\Installer\Validate; @@ -41,12 +42,13 @@ class ModuleTest extends TestCase $service = reset($services); $actions = $service->getActions(); - $this->assertCount(7, $actions); + $this->assertCount(8, $actions); $this->assertArrayHasKey('installerView', $actions); $this->assertArrayHasKey('installerStatus', $actions); $this->assertArrayHasKey('installerValidate', $actions); $this->assertArrayHasKey('installerComplete', $actions); $this->assertArrayHasKey('installerShutdown', $actions); + $this->assertArrayHasKey('installerReset', $actions); $this->assertArrayHasKey('installerInstall', $actions); $this->assertArrayHasKey('installerCertificateGet', $actions); } @@ -109,6 +111,18 @@ class ModuleTest extends TestCase $this->assertActionInjects($action, ['request', 'response', 'swooleServer']); } + public function testResetAction(): void + { + $action = $this->getAction('installerReset'); + + $this->assertEquals('installerReset', Reset::getName()); + $this->assertEquals(Action::HTTP_REQUEST_METHOD_POST, $action->getHttpMethod()); + $this->assertEquals('/install/reset', $action->getHttpPath()); + $this->assertEquals(Action::TYPE_DEFAULT, $action->getType()); + $this->assertActionParams($action, ['installId', 'hard']); + $this->assertActionInjects($action, ['request', 'response', 'installerState', 'installerConfig']); + } + public function testInstallAction(): void { $action = $this->getAction('installerInstall'); @@ -207,6 +221,7 @@ class ModuleTest extends TestCase $this->assertEquals('installerValidate', Validate::getName()); $this->assertEquals('installerComplete', Complete::getName()); $this->assertEquals('installerShutdown', Shutdown::getName()); + $this->assertEquals('installerReset', Reset::getName()); $this->assertEquals('installerInstall', Install::getName()); $this->assertEquals('installerError', Error::getName()); } @@ -222,6 +237,7 @@ class ModuleTest extends TestCase $this->assertInstanceOf(Validate::class, $actions['installerValidate']); $this->assertInstanceOf(Complete::class, $actions['installerComplete']); $this->assertInstanceOf(Shutdown::class, $actions['installerShutdown']); + $this->assertInstanceOf(Reset::class, $actions['installerReset']); $this->assertInstanceOf(Install::class, $actions['installerInstall']); } @@ -240,7 +256,7 @@ class ModuleTest extends TestCase public function testPostRoutesUsePostMethod(): void { - $postActions = ['installerValidate', 'installerComplete', 'installerShutdown', 'installerInstall']; + $postActions = ['installerValidate', 'installerComplete', 'installerShutdown', 'installerReset', 'installerInstall']; foreach ($postActions as $name) { $action = $this->getAction($name); $this->assertEquals( From 5ca30d37f7cc42abf3bb47273191600577ee0de8 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 24 Mar 2026 21:36:32 +1300 Subject: [PATCH 04/14] (fix): tolerate console signup restriction in installer account creation --- src/Appwrite/Platform/Tasks/Install.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index bc0547379f..b814e5f0fa 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -658,8 +658,9 @@ class Install extends Action messageOverride: 'Creating Appwrite account' ); - // Create the account — tolerate "already exists" so we can still - // create a session (common when re-running the installer). + // Create the account — tolerate "already exists" and "console + // is restricted" errors so we can still create a session + // (common when re-running the installer or upgrading). $userId = null; try { $userId = $this->makeApiCall('/v1/account', [ @@ -669,7 +670,10 @@ class Install extends Action 'name' => $name ], false, $apiUrl, $domain); } catch (\Throwable $e) { - if (\stripos($e->getMessage(), 'already exists') === false) { + $message = $e->getMessage(); + $accountExists = \stripos($message, 'already exists') !== false + || \stripos($message, 'console is restricted') !== false; + if (!$accountExists) { throw $e; } } From b9b5d396b8579d5867cfc50a2676953a04831884 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 24 Mar 2026 23:43:04 +1300 Subject: [PATCH 05/14] Update console --- app/views/install/compose.phtml | 2 +- docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index 45cc7815f8..741d085445 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -194,7 +194,7 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); appwrite-console: <<: *x-logging container_name: appwrite-console - image: /console:7.8.25 + image: /console:7.8.26 restart: unless-stopped networks: - appwrite diff --git a/docker-compose.yml b/docker-compose.yml index 5aac5c2fb1..aa2bfdd16a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -254,7 +254,7 @@ services: appwrite-console: <<: *x-logging container_name: appwrite-console - image: appwrite/console:7.8.25 + image: appwrite/console:7.8.26 restart: unless-stopped networks: - appwrite From 0cf206cacf1883f084c3a0c3d2a829351cc6e493 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 24 Mar 2026 23:53:27 +1300 Subject: [PATCH 06/14] (fix): installer progress counter display and dynamic step messages --- app/views/install/installer/css/styles.css | 1 + app/views/install/installer/js/modules/progress.js | 6 +++--- app/views/install/installer/templates/steps/step-5.phtml | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/views/install/installer/css/styles.css b/app/views/install/installer/css/styles.css index b667d29914..17c8444926 100644 --- a/app/views/install/installer/css/styles.css +++ b/app/views/install/installer/css/styles.css @@ -697,6 +697,7 @@ body { transition: opacity 0.2s ease; white-space: nowrap; user-select: none; + color: #6b7280; } .install-row[data-status='in-progress'] .install-counter:not(:empty) { diff --git a/app/views/install/installer/js/modules/progress.js b/app/views/install/installer/js/modules/progress.js index 2eaba4ca36..ac44093593 100644 --- a/app/views/install/installer/js/modules/progress.js +++ b/app/views/install/installer/js/modules/progress.js @@ -111,7 +111,7 @@ return normalized.summary || 'Installation failed.'; } if (status === STATUS.COMPLETED) return step.done; - return step.inProgress; + return message || step.inProgress; }; const updateInstallRow = (row, step, status, message, details) => { @@ -140,9 +140,9 @@ const counter = row.querySelector('[data-install-counter]'); if (counter) { - const started = details?.containerStarted; + const started = details?.containerStarted ?? 0; const total = details?.containerTotal; - counter.textContent = (status === STATUS.IN_PROGRESS && started > 0 && total > 0) + counter.textContent = (status === STATUS.IN_PROGRESS && total > 0 && started < total) ? `${started}/${total}` : ''; } diff --git a/app/views/install/installer/templates/steps/step-5.phtml b/app/views/install/installer/templates/steps/step-5.phtml index 088b96bea8..cd5de5f4ab 100644 --- a/app/views/install/installer/templates/steps/step-5.phtml +++ b/app/views/install/installer/templates/steps/step-5.phtml @@ -30,7 +30,7 @@ $isUpgrade = $isUpgrade ?? false; - + From d0978d891fb2afc9ea8c0812fb2588bd9549bd34 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 24 Mar 2026 23:53:56 +1300 Subject: [PATCH 07/14] (fix): installer step ordering, initial container count, and proc_close timeout --- src/Appwrite/Platform/Tasks/Install.php | 63 ++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 7 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index b814e5f0fa..a41d849de4 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -601,16 +601,16 @@ class Install extends Action $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_IN_PROGRESS, $messages); $this->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI, $progress, $isUpgrade); + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages); + $currentStep = $isUpgrade ? InstallerServer::STEP_DOCKER_CONTAINERS : InstallerServer::STEP_ACCOUNT_SETUP; + if (!$isLocalInstall) { $this->connectInstallerToAppwriteNetwork(); } $domain = $input['_APP_DOMAIN'] ?? 'localhost'; - // Wait for Appwrite API to be healthy before marking containers as ready - $apiUrl = $this->waitForApiReady($domain, $httpPort, $isLocalInstall, $progress, InstallerServer::STEP_DOCKER_CONTAINERS); - - $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages); + $apiUrl = $this->waitForApiReady($domain, $httpPort, $isLocalInstall, $progress, $currentStep); if (!$isUpgrade) { $this->createInitialAdminAccount($account, $progress, $apiUrl, $domain); @@ -777,7 +777,7 @@ class Install extends Action * - host.docker.internal:{port} — reaches host-published ports from inside a container * - localhost:{port} — works when running directly on the host (local dev) */ - private function waitForApiReady(string $domain, string $httpPort, bool $isLocalInstall, ?callable $progress, string $step = InstallerServer::STEP_DOCKER_CONTAINERS): string + private function waitForApiReady(string $domain, string $httpPort, bool $isLocalInstall, ?callable $progress, string $step = InstallerServer::STEP_ACCOUNT_SETUP): string { $client = new Client(); $client @@ -818,7 +818,7 @@ class Install extends Action $progress( $step, InstallerServer::STATUS_IN_PROGRESS, - 'Waiting for Appwrite to be ready (' . ($i + 1) . '/' . self::HEALTH_CHECK_ATTEMPTS . ')', + 'Waiting for Appwrite to be ready...', [] ); } catch (\Throwable) { @@ -1023,6 +1023,18 @@ class Install extends Action if ($progress) { $totalServices = $this->countComposeServices($composeFile); + if ($totalServices > 0) { + $verb = $isUpgrade ? 'Restarting' : 'Starting'; + try { + $progress( + InstallerServer::STEP_DOCKER_CONTAINERS, + InstallerServer::STATUS_IN_PROGRESS, + "$verb Docker containers...", + ['containerStarted' => 0, 'containerTotal' => $totalServices] + ); + } catch (\Throwable) { + } + } $result = $this->execWithContainerProgress($commandLine, $totalServices, $progress, $isUpgrade); $output = $result['output']; $exit = $result['exit']; @@ -1088,11 +1100,48 @@ class Install extends Action } fclose($pipes[1]); - $exit = proc_close($process); + + $exit = $this->procCloseWithTimeout($process, 60); return ['output' => $output, 'exit' => $exit]; } + /** + * Wait up to $timeoutSeconds for a process to exit, then kill it. + * + * proc_close() blocks indefinitely which can hang the installer if + * docker compose refuses to exit after all containers are running. + * + * @param resource $process + */ + private function procCloseWithTimeout($process, int $timeoutSeconds): int + { + $deadline = time() + $timeoutSeconds; + + while (time() < $deadline) { + $status = proc_get_status($process); + if (!$status['running']) { + proc_close($process); + return $status['exitcode']; + } + usleep(250_000); + } + + // Process still running after timeout — kill it and move on. + // The containers are already up; the compose process is just lingering. + $pid = proc_get_status($process)['pid'] ?? 0; + if ($pid > 0) { + @posix_kill($pid, SIGTERM); + usleep(500_000); + if (proc_get_status($process)['running']) { + @posix_kill($pid, SIGKILL); + } + } + proc_close($process); + + return 0; + } + protected function isLocalInstall(): bool { if ($this->isLocalInstall === null) { From 22e19698957b0dad89498227d986da60e68a75a9 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 24 Mar 2026 23:53:56 +1300 Subject: [PATCH 08/14] (fix): installer step ordering, initial container count, and proc_close timeout --- app/views/install/installer/css/styles.css | 2 +- src/Appwrite/Platform/Tasks/Install.php | 69 +++++++++++++++++++--- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/app/views/install/installer/css/styles.css b/app/views/install/installer/css/styles.css index 17c8444926..7f253eed46 100644 --- a/app/views/install/installer/css/styles.css +++ b/app/views/install/installer/css/styles.css @@ -697,7 +697,7 @@ body { transition: opacity 0.2s ease; white-space: nowrap; user-select: none; - color: #6b7280; + color: var(--fgcolor-neutral-secondary); } .install-row[data-status='in-progress'] .install-counter:not(:empty) { diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index b814e5f0fa..0fc2801e91 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -25,6 +25,7 @@ class Install extends Action private const int HEALTH_CHECK_ATTEMPTS = 30; private const int HEALTH_CHECK_DELAY_SECONDS = 1; + private const int PROC_CLOSE_TIMEOUT_SECONDS = 60; private const string PATTERN_ENV_VAR_NAME = '/^[A-Z0-9_]+$/'; private const string PATTERN_DB_PASSWORD_VAR = '/^_APP_DB_.*_PASS$/'; @@ -601,18 +602,25 @@ class Install extends Action $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_IN_PROGRESS, $messages); $this->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI, $progress, $isUpgrade); + if (!$isUpgrade) { + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages); + } + if (!$isLocalInstall) { $this->connectInstallerToAppwriteNetwork(); } $domain = $input['_APP_DOMAIN'] ?? 'localhost'; - // Wait for Appwrite API to be healthy before marking containers as ready - $apiUrl = $this->waitForApiReady($domain, $httpPort, $isLocalInstall, $progress, InstallerServer::STEP_DOCKER_CONTAINERS); + $healthStep = $isUpgrade ? InstallerServer::STEP_DOCKER_CONTAINERS : InstallerServer::STEP_ACCOUNT_SETUP; + $apiUrl = $this->waitForApiReady($domain, $httpPort, $isLocalInstall, $progress, $healthStep); - $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages); + if ($isUpgrade) { + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages); + } if (!$isUpgrade) { + $currentStep = InstallerServer::STEP_ACCOUNT_SETUP; $this->createInitialAdminAccount($account, $progress, $apiUrl, $domain); } @@ -777,7 +785,7 @@ class Install extends Action * - host.docker.internal:{port} — reaches host-published ports from inside a container * - localhost:{port} — works when running directly on the host (local dev) */ - private function waitForApiReady(string $domain, string $httpPort, bool $isLocalInstall, ?callable $progress, string $step = InstallerServer::STEP_DOCKER_CONTAINERS): string + private function waitForApiReady(string $domain, string $httpPort, bool $isLocalInstall, ?callable $progress, string $step = InstallerServer::STEP_ACCOUNT_SETUP): string { $client = new Client(); $client @@ -818,7 +826,7 @@ class Install extends Action $progress( $step, InstallerServer::STATUS_IN_PROGRESS, - 'Waiting for Appwrite to be ready (' . ($i + 1) . '/' . self::HEALTH_CHECK_ATTEMPTS . ')', + 'Waiting for Appwrite to be ready...', [] ); } catch (\Throwable) { @@ -1023,6 +1031,18 @@ class Install extends Action if ($progress) { $totalServices = $this->countComposeServices($composeFile); + if ($totalServices > 0) { + $verb = $isUpgrade ? 'Restarting' : 'Starting'; + try { + $progress( + InstallerServer::STEP_DOCKER_CONTAINERS, + InstallerServer::STATUS_IN_PROGRESS, + "$verb Docker containers...", + ['containerStarted' => 0, 'containerTotal' => $totalServices] + ); + } catch (\Throwable) { + } + } $result = $this->execWithContainerProgress($commandLine, $totalServices, $progress, $isUpgrade); $output = $result['output']; $exit = $result['exit']; @@ -1072,7 +1092,7 @@ class Install extends Action $output[] = $trimmed; if (str_contains($trimmed, 'Container') && (str_contains($trimmed, 'Started') || str_contains($trimmed, 'Running'))) { - $started++; + $started = min($started + 1, $totalServices); if ($totalServices > 0) { try { $progress( @@ -1088,11 +1108,46 @@ class Install extends Action } fclose($pipes[1]); - $exit = proc_close($process); + + $exit = $this->procCloseWithTimeout($process, self::PROC_CLOSE_TIMEOUT_SECONDS); return ['output' => $output, 'exit' => $exit]; } + /** + * Wait up to $timeoutSeconds for a process to exit, then kill it. + * + * proc_close() blocks indefinitely which can hang the installer if + * docker compose refuses to exit after all containers are running. + * + * @param resource $process A process resource from proc_open() + */ + private function procCloseWithTimeout($process, int $timeoutSeconds): int + { + $deadline = time() + $timeoutSeconds; + + while (time() < $deadline) { + $status = proc_get_status($process); + if (!$status['running']) { + $exitCode = $status['exitcode']; + $closeCode = proc_close($process); + return $exitCode !== -1 ? $exitCode : $closeCode; + } + usleep(250_000); + } + + proc_terminate($process, SIGTERM); + usleep(500_000); + + if (proc_get_status($process)['running']) { + proc_terminate($process, SIGKILL); + } + + proc_close($process); + + return 124; + } + protected function isLocalInstall(): bool { if ($this->isLocalInstall === null) { From 2a7925b362767dcdf7867384cdcdda18f53adbbe Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 25 Mar 2026 00:39:14 +1300 Subject: [PATCH 09/14] (fix): installer resume detects terminal snapshots and redirects cleanly --- .../install/installer/js/modules/progress.js | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/app/views/install/installer/js/modules/progress.js b/app/views/install/installer/js/modules/progress.js index ac44093593..00eaa65c17 100644 --- a/app/views/install/installer/js/modules/progress.js +++ b/app/views/install/installer/js/modules/progress.js @@ -693,6 +693,7 @@ } stopSyncedSpinnerRotation(); setUnloadGuard(false); + clearInstallLock?.(); }; const SSL_STEP = { @@ -909,9 +910,22 @@ } }; + const isSnapshotTerminal = (snapshot) => { + if (!snapshot?.steps) return true; + const stepEntries = Object.values(snapshot.steps); + if (stepEntries.length === 0) return true; + const hasError = stepEntries.some((s) => s.status === STATUS.ERROR); + if (hasError) return true; + const allCompleted = INSTALLATION_STEPS.every((step) => { + const detail = snapshot.steps[step.id]; + return detail && detail.status === STATUS.COMPLETED; + }); + return allCompleted; + }; + const resumeInstall = async (installId) => { const snapshot = await fetchInstallStatus(installId); - if (!snapshot) return false; + if (!snapshot || isSnapshotTerminal(snapshot)) return false; activeInstall = { installId, controller: new AbortController(), @@ -1052,9 +1066,7 @@ if (!resumed) { clearInstallId?.(); clearInstallLock?.(); - const newInstallId = generateInstallId(); - storeInstallId?.(newInstallId); - startInstallStream(newInstallId); + window.location.href = '/?step=1'; } }); } else { From f016d4b7cd21287f116b3ed8a44b1f30bae30957 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 25 Mar 2026 00:39:17 +1300 Subject: [PATCH 10/14] (fix): auto-detect existing database type instead of blocking upgrades --- src/Appwrite/Platform/Tasks/Install.php | 50 +++++++++++++++++++++---- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index 0fc2801e91..161abfb661 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -170,9 +170,9 @@ class Install extends Action } } - // Block database type changes on existing installations. - // Only enforce if the existing config explicitly set _APP_DB_ADAPTER - // (pre-1.9.0 installs never had this variable). + // Detect database type from existing installation. + // 1.9.0+ installs have _APP_DB_ADAPTER; pre-1.9.0 installs + // can be detected by the DB service name or _APP_DB_HOST. $existingDatabase = null; foreach ($compose->getServices() as $service) { if (!$service) { @@ -191,10 +191,15 @@ class Install extends Action $existingDatabase = (new Env($rawEnv))->list()['_APP_DB_ADAPTER'] ?? null; } } - if ($existingDatabase !== null && $existingDatabase !== $database) { - Console::error("Cannot change database type from '{$existingDatabase}' to '{$database}'."); - Console::error('Changing database types on an existing installation is not supported.'); - Console::exit(1); + if ($existingDatabase === null) { + $existingDatabase = $this->detectDatabaseFromCompose($compose); + } + if ($existingDatabase !== null) { + if ($existingDatabase !== $database) { + $database = $existingDatabase; + Console::info("Detected existing database: {$database}"); + } + $vars['_APP_DB_ADAPTER']['default'] = $database; } } @@ -211,7 +216,8 @@ class Install extends Action Console::info('Open your browser at: http://localhost:' . InstallerServer::INSTALLER_WEB_PORT); Console::info('Press Ctrl+C to cancel installation'); - $this->startWebServer($defaultHttpPort, $defaultHttpsPort, $organization, $image, $noStart, $vars); + $detectedDb = ($existingInstallation && isset($existingDatabase)) ? $existingDatabase : null; + $this->startWebServer($defaultHttpPort, $defaultHttpsPort, $organization, $image, $noStart, $vars, $isUpgrade, $detectedDb); return; } @@ -1220,6 +1226,34 @@ class Install extends Action $this->hostPath = $this->getInstallerHostPath(); } + /** + * Detect the database adapter from a pre-1.9.0 compose file by + * checking which DB service exists or reading _APP_DB_HOST. + */ + private function detectDatabaseFromCompose(Compose $compose): ?string + { + $serviceNames = array_keys($compose->getServices()); + $dbServices = ['mariadb', 'mongodb', 'postgresql']; + foreach ($dbServices as $db) { + if (in_array($db, $serviceNames, true)) { + return $db; + } + } + + foreach ($compose->getServices() as $service) { + if (!$service) { + continue; + } + $env = $service->getEnvironment()->list(); + $host = $env['_APP_DB_HOST'] ?? null; + if ($host !== null && in_array($host, $dbServices, true)) { + return $host; + } + } + + return null; + } + protected function readExistingCompose(): string { $composeFile = $this->path . '/' . $this->getComposeFileName(); From 4da726029c59bd20f3ab7e61f8bf9c534725747d Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 25 Mar 2026 00:56:42 +1300 Subject: [PATCH 11/14] (fix): installer stale resume redirect and account-setup phase delay --- .../install/installer/js/modules/progress.js | 16 ++++++++++------ src/Appwrite/Platform/Tasks/Install.php | 1 + 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/views/install/installer/js/modules/progress.js b/app/views/install/installer/js/modules/progress.js index 00eaa65c17..7780fbfea6 100644 --- a/app/views/install/installer/js/modules/progress.js +++ b/app/views/install/installer/js/modules/progress.js @@ -1059,20 +1059,24 @@ } }); + const startFreshInstall = () => { + clearInstallId?.(); + clearInstallLock?.(); + const newInstallId = generateInstallId(); + storeInstallId?.(newInstallId); + startInstallStream(newInstallId); + }; + const lock = getInstallLock?.(); const existingInstallId = lock?.installId || getStoredInstallId?.(); if (existingInstallId) { resumeInstall(existingInstallId).then((resumed) => { if (!resumed) { - clearInstallId?.(); - clearInstallLock?.(); - window.location.href = '/?step=1'; + startFreshInstall(); } }); } else { - const newInstallId = generateInstallId(); - storeInstallId?.(newInstallId); - startInstallStream(newInstallId); + startFreshInstall(); } }; diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index 161abfb661..f085a9bdd8 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -610,6 +610,7 @@ class Install extends Action if (!$isUpgrade) { $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages); + $this->updateProgress($progress, InstallerServer::STEP_ACCOUNT_SETUP, InstallerServer::STATUS_IN_PROGRESS, messageOverride: 'Creating Appwrite account...'); } if (!$isLocalInstall) { From 9564c9b065b4aad752527399afe4f3f940aff9fa Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 25 Mar 2026 01:04:38 +1300 Subject: [PATCH 12/14] (chore): update lock --- composer.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/composer.lock b/composer.lock index de577e2d78..7a30e2f265 100644 --- a/composer.lock +++ b/composer.lock @@ -5216,16 +5216,16 @@ }, { "name": "utopia-php/vcs", - "version": "3.0.1", + "version": "3.1.0", "source": { "type": "git", "url": "https://github.com/utopia-php/vcs.git", - "reference": "0efe842d695acb4b184f5306a836169c771fbcea" + "reference": "03b76ad5fd01bc50f809915bca6ff0745ea913af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/vcs/zipball/0efe842d695acb4b184f5306a836169c771fbcea", - "reference": "0efe842d695acb4b184f5306a836169c771fbcea", + "url": "https://api.github.com/repos/utopia-php/vcs/zipball/03b76ad5fd01bc50f809915bca6ff0745ea913af", + "reference": "03b76ad5fd01bc50f809915bca6ff0745ea913af", "shasum": "" }, "require": { @@ -5259,9 +5259,9 @@ ], "support": { "issues": "https://github.com/utopia-php/vcs/issues", - "source": "https://github.com/utopia-php/vcs/tree/3.0.1" + "source": "https://github.com/utopia-php/vcs/tree/3.1.0" }, - "time": "2026-03-23T15:58:31+00:00" + "time": "2026-03-24T08:49:14+00:00" }, { "name": "utopia-php/websocket", @@ -5439,16 +5439,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.11.14", + "version": "1.12.1", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "ed4faf10fafa1930ed0be3dfe43e41561f2de75b" + "reference": "a724aa8db52f83ea35854a004837fa5ce990b736" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/ed4faf10fafa1930ed0be3dfe43e41561f2de75b", - "reference": "ed4faf10fafa1930ed0be3dfe43e41561f2de75b", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/a724aa8db52f83ea35854a004837fa5ce990b736", + "reference": "a724aa8db52f83ea35854a004837fa5ce990b736", "shasum": "" }, "require": { @@ -5484,9 +5484,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.11.14" + "source": "https://github.com/appwrite/sdk-generator/tree/1.12.1" }, - "time": "2026-03-20T10:55:13+00:00" + "time": "2026-03-24T05:18:43+00:00" }, { "name": "brianium/paratest", From a659038ad27cb27c399d9e1d94a2421f4a525b19 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 25 Mar 2026 01:05:47 +1300 Subject: [PATCH 13/14] fix: address review comments on installer state PR - Restore postgresql in compose.phtml allowedDbServices for consistency with WhiteList validators, JS defaults, and compose template sections - Log errors in performReset catch block instead of swallowing silently - Move $currentStep assignment before waitForApiReady so timeout errors are attributed to the correct step - Replace blocking fgets loop in execWithContainerProgress with non-blocking stream_select polling to prevent unbounded hangs Co-Authored-By: Claude Opus 4.6 (1M context) --- app/views/install/compose.phtml | 2 +- .../install/installer/js/modules/progress.js | 4 +- src/Appwrite/Platform/Tasks/Install.php | 64 ++++++++++++++----- 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index 741d085445..9bc82ecef4 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -13,7 +13,7 @@ $organization = $this->getParam('organization', ''); $image = $this->getParam('image', ''); $enableAssistant = $this->getParam('enableAssistant', false); $dbService = $this->getParam('database', 'mongodb'); -$allowedDbServices = ['mariadb', 'mongodb']; +$allowedDbServices = ['mariadb', 'mongodb', 'postgresql']; if (!\in_array($dbService, $allowedDbServices, true)) { $dbService = 'mongodb'; } diff --git a/app/views/install/installer/js/modules/progress.js b/app/views/install/installer/js/modules/progress.js index 7780fbfea6..f7eb857af2 100644 --- a/app/views/install/installer/js/modules/progress.js +++ b/app/views/install/installer/js/modules/progress.js @@ -1026,7 +1026,9 @@ }); return; } - } catch (e) {} + } catch (e) { + console.error('Reset request failed:', e); + } clearInstallLock?.(); clearInstallId?.(); diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index f085a9bdd8..eab6babc66 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -620,6 +620,9 @@ class Install extends Action $domain = $input['_APP_DOMAIN'] ?? 'localhost'; $healthStep = $isUpgrade ? InstallerServer::STEP_DOCKER_CONTAINERS : InstallerServer::STEP_ACCOUNT_SETUP; + if (!$isUpgrade) { + $currentStep = InstallerServer::STEP_ACCOUNT_SETUP; + } $apiUrl = $this->waitForApiReady($domain, $httpPort, $isLocalInstall, $progress, $healthStep); if ($isUpgrade) { @@ -627,7 +630,6 @@ class Install extends Action } if (!$isUpgrade) { - $currentStep = InstallerServer::STEP_ACCOUNT_SETUP; $this->createInitialAdminAccount($account, $progress, $apiUrl, $domain); } @@ -1094,24 +1096,56 @@ class Install extends Action return ['output' => [], 'exit' => 1]; } - while (($line = fgets($pipes[1])) !== false) { - $trimmed = rtrim($line, "\n\r"); - $output[] = $trimmed; + stream_set_blocking($pipes[1], false); + $deadline = time() + self::PROC_CLOSE_TIMEOUT_SECONDS; + $buffer = ''; - if (str_contains($trimmed, 'Container') && (str_contains($trimmed, 'Started') || str_contains($trimmed, 'Running'))) { - $started = min($started + 1, $totalServices); - if ($totalServices > 0) { - try { - $progress( - InstallerServer::STEP_DOCKER_CONTAINERS, - InstallerServer::STATUS_IN_PROGRESS, - $message, - ['containerStarted' => $started, 'containerTotal' => $totalServices] - ); - } catch (\Throwable) { + while (time() < $deadline) { + $status = proc_get_status($process); + + $read = [$pipes[1]]; + $write = null; + $except = null; + $changed = @stream_select($read, $write, $except, 1); + + if ($changed > 0) { + $chunk = fread($pipes[1], 8192); + if ($chunk === false || $chunk === '') { + if (!$status['running']) { + break; + } + continue; + } + $buffer .= $chunk; + while (($pos = strpos($buffer, "\n")) !== false) { + $trimmed = rtrim(substr($buffer, 0, $pos), "\r"); + $buffer = substr($buffer, $pos + 1); + $output[] = $trimmed; + + if (str_contains($trimmed, 'Container') && (str_contains($trimmed, 'Started') || str_contains($trimmed, 'Running'))) { + $started = min($started + 1, $totalServices); + if ($totalServices > 0) { + try { + $progress( + InstallerServer::STEP_DOCKER_CONTAINERS, + InstallerServer::STATUS_IN_PROGRESS, + $message, + ['containerStarted' => $started, 'containerTotal' => $totalServices] + ); + } catch (\Throwable) { + } + } } } } + + if (!$status['running'] && ($changed === 0 || feof($pipes[1]))) { + break; + } + } + + if ($buffer !== '') { + $output[] = rtrim($buffer, "\r\n"); } fclose($pipes[1]); From 4de9ec7fba16d215137dd6cbc6a08bfec69ce0d0 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 25 Mar 2026 01:08:14 +1300 Subject: [PATCH 14/14] Revert "fix: address review comments on installer state PR" This reverts commit a659038ad27cb27c399d9e1d94a2421f4a525b19. --- app/views/install/compose.phtml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index 9bc82ecef4..741d085445 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -13,7 +13,7 @@ $organization = $this->getParam('organization', ''); $image = $this->getParam('image', ''); $enableAssistant = $this->getParam('enableAssistant', false); $dbService = $this->getParam('database', 'mongodb'); -$allowedDbServices = ['mariadb', 'mongodb', 'postgresql']; +$allowedDbServices = ['mariadb', 'mongodb']; if (!\in_array($dbService, $allowedDbServices, true)) { $dbService = 'mongodb'; }