mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Merge pull request #11624 from appwrite/fix-installer-state
This commit is contained in:
@@ -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: <?php echo $organization; ?>/console:7.6.4
|
||||
image: <?php echo $organization; ?>/console:7.8.26
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- appwrite
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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-xs-400" 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>
|
||||
|
||||
Generated
+12
-12
@@ -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",
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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