From 76684874e95bf38019041b25902f69379a3e877c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 24 Mar 2026 21:25:57 +1300 Subject: [PATCH] =?UTF-8?q?(feat):=20installer=20improvements=20=E2=80=94?= =?UTF-8?q?=20reset,=20state=20resilience,=20container=20progress,=20SSL?= =?UTF-8?q?=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(