mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
(feat): installer improvements — reset, state resilience, container progress, SSL email fallback
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -240,6 +240,9 @@
|
||||
if (key === 'database') {
|
||||
value = toDatabaseLabel(formState?.database);
|
||||
}
|
||||
if (key === 'emailCertificates' && !value) {
|
||||
value = formState?.accountEmail;
|
||||
}
|
||||
if (value) {
|
||||
node.textContent = value;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ $isUpgrade = $isUpgrade ?? false;
|
||||
</span>
|
||||
<span class="install-text typography-text-m-400 text-neutral-primary" data-install-text></span>
|
||||
</div>
|
||||
<span class="install-counter typography-text-s-400 text-neutral-secondary" data-install-counter></span>
|
||||
<button type="button" class="install-row-toggle" aria-expanded="false" data-install-toggle>
|
||||
<?php include __DIR__ . '/../../icons/chevron-down.svg'; ?>
|
||||
</button>
|
||||
@@ -50,4 +51,13 @@ $isUpgrade = $isUpgrade ?? false;
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="install-global-actions is-hidden" data-install-global-actions>
|
||||
<button type="button" class="button secondary" data-install-start-over>
|
||||
<span class="button-text typography-text-m-500">Start Over</span>
|
||||
</button>
|
||||
<button type="button" class="button secondary" data-install-hard-reset>
|
||||
<span class="button-text typography-text-m-500">Reset Everything</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Installer\Http\Installer;
|
||||
|
||||
use Appwrite\Platform\Installer\Runtime\Config;
|
||||
use Appwrite\Platform\Installer\Runtime\State;
|
||||
use Appwrite\Platform\Installer\Server;
|
||||
use Utopia\Http\Adapter\Swoole\Request;
|
||||
use Utopia\Http\Adapter\Swoole\Response;
|
||||
use Utopia\Platform\Action;
|
||||
use Utopia\Validator\Boolean;
|
||||
use Utopia\Validator\Text;
|
||||
|
||||
class Reset extends Action
|
||||
{
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'installerReset';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user