diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index 0f4df352bd..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'; } @@ -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.26 restart: unless-stopped networks: - appwrite diff --git a/app/views/install/installer/css/styles.css b/app/views/install/installer/css/styles.css index b1d8fe5089..7f253eed46 100644 --- a/app/views/install/installer/css/styles.css +++ b/app/views/install/installer/css/styles.css @@ -691,6 +691,19 @@ body { transform: translateY(10px); } +.install-counter { + margin-left: auto; + opacity: 0; + transition: opacity 0.2s ease; + white-space: nowrap; + user-select: none; + color: var(--fgcolor-neutral-secondary); +} + +.install-row[data-status='in-progress'] .install-counter:not(:empty) { + opacity: 1; +} + .install-row-toggle { margin-left: auto; width: 32px; @@ -897,6 +910,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..f7eb857af2 100644 --- a/app/views/install/installer/js/modules/progress.js +++ b/app/views/install/installer/js/modules/progress.js @@ -111,10 +111,10 @@ 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) => { + 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 ?? 0; + const total = details?.containerTotal; + counter.textContent = (status === STATUS.IN_PROGRESS && total > 0 && started < total) + ? `${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 = () => { @@ -674,6 +693,7 @@ } stopSyncedSpinnerRotation(); setUnloadGuard(false); + clearInstallLock?.(); }; const SSL_STEP = { @@ -890,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(), @@ -966,6 +999,60 @@ } }); + 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) { + console.error('Reset request failed:', 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', () => { @@ -974,22 +1061,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?.(); - const newInstallId = generateInstallId(); - storeInstallId?.(newInstallId); - startInstallStream(newInstallId); + startFreshInstall(); } }); } else { - const newInstallId = generateInstallId(); - storeInstallId?.(newInstallId); - startInstallStream(newInstallId); + startFreshInstall(); } }; 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..cd5de5f4ab 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/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", diff --git a/docker-compose.yml b/docker-compose.yml index f3cb982526..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.5.7 + image: appwrite/console:7.8.26 restart: unless-stopped networks: - appwrite 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); + } } 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..eab6babc66 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$/'; @@ -169,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) { @@ -190,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; } } @@ -210,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; } @@ -599,7 +606,12 @@ 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 (!$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) { $this->connectInstallerToAppwriteNetwork(); @@ -607,10 +619,15 @@ class Install extends Action $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; + if (!$isUpgrade) { + $currentStep = 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) { $this->createInitialAdminAccount($account, $progress, $apiUrl, $domain); @@ -658,8 +675,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 +687,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; } } @@ -732,6 +753,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 +767,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), ]), ]; @@ -766,7 +794,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 @@ -776,12 +804,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 = []; @@ -803,7 +835,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) { @@ -964,7 +996,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 +1036,28 @@ 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); + 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']; + } else { + \exec($commandLine . ' 2>&1', $output, $exit); + } if ($exit !== 0) { $message = trim(implode("\n", $output)); @@ -1017,6 +1069,126 @@ 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]; + } + + stream_set_blocking($pipes[1], false); + $deadline = time() + self::PROC_CLOSE_TIMEOUT_SECONDS; + $buffer = ''; + + 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]); + + $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) { @@ -1089,6 +1261,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(); 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(