2022-07-13 07:02:55 +00:00
|
|
|
<?php
|
2022-07-14 02:04:31 +00:00
|
|
|
|
2022-11-14 10:01:41 +00:00
|
|
|
namespace Appwrite\Platform\Tasks;
|
2022-07-13 07:02:55 +00:00
|
|
|
|
|
|
|
|
use Appwrite\Docker\Compose;
|
|
|
|
|
use Appwrite\Docker\Env;
|
2026-03-19 10:15:20 +00:00
|
|
|
use Appwrite\Platform\Installer\Runtime\State;
|
2026-01-22 14:22:46 +00:00
|
|
|
use Appwrite\Platform\Installer\Server as InstallerServer;
|
2022-07-13 07:02:55 +00:00
|
|
|
use Appwrite\Utopia\View;
|
2026-03-31 01:49:04 +00:00
|
|
|
use Swoole\Coroutine;
|
2025-11-04 07:51:03 +00:00
|
|
|
use Utopia\Auth\Proofs\Password;
|
|
|
|
|
use Utopia\Auth\Proofs\Token;
|
2022-07-13 07:02:55 +00:00
|
|
|
use Utopia\Config\Config;
|
2026-02-10 05:04:24 +00:00
|
|
|
use Utopia\Console;
|
2026-01-24 14:39:56 +00:00
|
|
|
use Utopia\Fetch\Client;
|
2024-03-08 12:57:20 +00:00
|
|
|
use Utopia\Platform\Action;
|
2024-10-08 07:54:40 +00:00
|
|
|
use Utopia\Validator\Boolean;
|
|
|
|
|
use Utopia\Validator\Text;
|
2026-03-02 08:50:25 +00:00
|
|
|
use Utopia\Validator\WhiteList;
|
2022-07-13 07:02:55 +00:00
|
|
|
|
2022-07-14 02:04:31 +00:00
|
|
|
class Install extends Action
|
|
|
|
|
{
|
2026-01-22 14:22:46 +00:00
|
|
|
private const int INSTALL_STEP_DELAY_SECONDS = 2;
|
2026-01-24 11:00:51 +00:00
|
|
|
private const int WEB_SERVER_CHECK_ATTEMPTS = 10;
|
|
|
|
|
private const int WEB_SERVER_CHECK_DELAY_SECONDS = 1;
|
2026-01-30 06:14:00 +00:00
|
|
|
|
2026-03-03 06:47:29 +00:00
|
|
|
private const int HEALTH_CHECK_ATTEMPTS = 30;
|
2026-03-18 05:44:13 +00:00
|
|
|
private const int HEALTH_CHECK_DELAY_SECONDS = 1;
|
2026-03-24 10:53:56 +00:00
|
|
|
private const int PROC_CLOSE_TIMEOUT_SECONDS = 60;
|
2026-01-22 14:22:46 +00:00
|
|
|
|
2026-01-26 12:18:08 +00:00
|
|
|
private const string PATTERN_ENV_VAR_NAME = '/^[A-Z0-9_]+$/';
|
|
|
|
|
private const string PATTERN_DB_PASSWORD_VAR = '/^_APP_DB_.*_PASS$/';
|
|
|
|
|
private const string PATTERN_SESSION_COOKIE = '/a_session_console=([^;]+)/';
|
|
|
|
|
|
2026-02-04 09:18:42 +00:00
|
|
|
private const string APPWRITE_API_URL = 'http://appwrite';
|
2026-01-30 06:14:00 +00:00
|
|
|
private const string GROWTH_API_URL = 'https://growth.appwrite.io/v1';
|
|
|
|
|
|
2026-03-20 08:56:06 +00:00
|
|
|
protected bool $isUpgrade = false;
|
2026-03-31 08:08:29 +00:00
|
|
|
protected bool $migrate = false;
|
2026-01-24 11:00:51 +00:00
|
|
|
protected string $hostPath = '';
|
|
|
|
|
protected ?bool $isLocalInstall = null;
|
|
|
|
|
protected ?array $installerConfig = null;
|
2023-07-24 22:21:34 +00:00
|
|
|
protected string $path = '/usr/src/code/appwrite';
|
|
|
|
|
|
2022-08-02 01:58:36 +00:00
|
|
|
public static function getName(): string
|
|
|
|
|
{
|
|
|
|
|
return 'install';
|
|
|
|
|
}
|
2022-07-14 02:04:31 +00:00
|
|
|
|
2022-07-13 07:02:55 +00:00
|
|
|
public function __construct()
|
|
|
|
|
{
|
|
|
|
|
$this
|
|
|
|
|
->desc('Install Appwrite')
|
2024-01-08 20:56:51 +00:00
|
|
|
->param('http-port', '', new Text(4), 'Server HTTP port', true)
|
|
|
|
|
->param('https-port', '', new Text(4), 'Server HTTPS port', true)
|
2022-07-13 07:02:55 +00:00
|
|
|
->param('organization', 'appwrite', new Text(0), 'Docker Registry organization', true)
|
|
|
|
|
->param('image', 'appwrite', new Text(0), 'Main appwrite docker image', true)
|
|
|
|
|
->param('interactive', 'Y', new Text(1), 'Run an interactive session', true)
|
2024-01-08 20:56:51 +00:00
|
|
|
->param('no-start', false, new Boolean(true), 'Run an interactive session', true)
|
2026-03-02 08:50:25 +00:00
|
|
|
->param('database', 'mongodb', new WhiteList(['mongodb', 'mariadb', 'postgresql']), 'Database to use (mongodb|mariadb|postgresql)', true)
|
2025-06-04 08:37:43 +00:00
|
|
|
->callback($this->action(...));
|
2022-07-13 07:02:55 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-13 04:09:42 +00:00
|
|
|
public function action(
|
|
|
|
|
string $httpPort,
|
2026-02-26 09:02:49 +00:00
|
|
|
string $httpsPort,
|
|
|
|
|
string $organization,
|
|
|
|
|
string $image,
|
|
|
|
|
string $interactive,
|
|
|
|
|
bool $noStart,
|
|
|
|
|
string $database
|
|
|
|
|
): void {
|
2026-03-20 08:56:06 +00:00
|
|
|
$isUpgrade = $this->isUpgrade;
|
2026-01-26 12:10:19 +00:00
|
|
|
$defaultHttpPort = '80';
|
|
|
|
|
$defaultHttpsPort = '443';
|
2022-07-13 07:02:55 +00:00
|
|
|
$config = Config::getParam('variables');
|
2026-02-13 04:09:42 +00:00
|
|
|
|
2025-12-02 10:33:21 +00:00
|
|
|
/** @var array<string, array<string, string>> $vars array where key is variable name and value is variable */
|
2022-07-13 07:02:55 +00:00
|
|
|
$vars = [];
|
|
|
|
|
|
|
|
|
|
foreach ($config as $category) {
|
|
|
|
|
foreach ($category['variables'] ?? [] as $var) {
|
2023-09-04 17:08:50 +00:00
|
|
|
$vars[$var['name']] = $var;
|
2022-07-13 07:02:55 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Console::success('Starting Appwrite installation...');
|
|
|
|
|
|
2026-01-24 11:00:51 +00:00
|
|
|
$isLocalInstall = $this->isLocalInstall();
|
|
|
|
|
$this->applyLocalPaths($isLocalInstall, true);
|
2026-01-22 12:42:41 +00:00
|
|
|
|
|
|
|
|
// Create directory with write permissions
|
2023-07-24 22:21:34 +00:00
|
|
|
if (!\file_exists(\dirname($this->path))) {
|
|
|
|
|
if (!@\mkdir(\dirname($this->path), 0755, true)) {
|
|
|
|
|
Console::error('Can\'t create directory ' . \dirname($this->path));
|
2022-07-13 07:02:55 +00:00
|
|
|
Console::exit(1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-22 12:42:41 +00:00
|
|
|
// Check for existing installation
|
2026-01-24 11:00:51 +00:00
|
|
|
$data = $this->readExistingCompose();
|
2026-01-24 14:39:56 +00:00
|
|
|
$envFileExists = file_exists($this->path . '/' . $this->getEnvFileName());
|
|
|
|
|
$existingInstallation = $data !== '' || $envFileExists;
|
2022-07-13 07:02:55 +00:00
|
|
|
|
2025-12-02 10:33:21 +00:00
|
|
|
if ($existingInstallation) {
|
2022-07-13 07:02:55 +00:00
|
|
|
$time = \time();
|
2026-01-24 11:00:51 +00:00
|
|
|
$composeFileName = $this->getComposeFileName();
|
|
|
|
|
Console::info('Compose file found, creating backup: ' . $composeFileName . '.' . $time . '.backup');
|
|
|
|
|
file_put_contents($this->path . '/' . $composeFileName . '.' . $time . '.backup', $data);
|
2022-07-13 07:02:55 +00:00
|
|
|
$compose = new Compose($data);
|
|
|
|
|
$appwrite = $compose->getService('appwrite');
|
2023-10-26 20:25:35 +00:00
|
|
|
$oldVersion = $appwrite?->getImageVersion();
|
2022-07-13 07:02:55 +00:00
|
|
|
try {
|
|
|
|
|
$ports = $compose->getService('traefik')->getPorts();
|
|
|
|
|
} catch (\Throwable $th) {
|
|
|
|
|
$ports = [
|
2026-01-26 12:10:19 +00:00
|
|
|
$defaultHttpPort => $defaultHttpPort,
|
|
|
|
|
$defaultHttpsPort => $defaultHttpsPort
|
2022-07-13 07:02:55 +00:00
|
|
|
];
|
|
|
|
|
Console::warning('Traefik not found. Falling back to default ports.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($oldVersion) {
|
2025-12-02 10:33:21 +00:00
|
|
|
foreach ($compose->getServices() as $service) {
|
2022-07-13 07:02:55 +00:00
|
|
|
if (!$service) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$env = $service->getEnvironment()->list();
|
|
|
|
|
|
|
|
|
|
foreach ($env as $key => $value) {
|
|
|
|
|
if (is_null($value)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2023-09-04 17:08:50 +00:00
|
|
|
|
|
|
|
|
$configVar = $vars[$key] ?? [];
|
|
|
|
|
if (!empty($configVar) && !($configVar['overwrite'] ?? false)) {
|
|
|
|
|
$vars[$key]['default'] = $value;
|
2022-07-13 07:02:55 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 11:00:51 +00:00
|
|
|
$envData = @file_get_contents($this->path . '/' . $this->getEnvFileName());
|
2022-07-13 07:02:55 +00:00
|
|
|
|
2025-12-02 10:33:21 +00:00
|
|
|
if ($envData !== false) {
|
2026-01-22 12:42:41 +00:00
|
|
|
if (!$isLocalInstall) {
|
|
|
|
|
Console::info('Env file found, creating backup: .env.' . $time . '.backup');
|
|
|
|
|
file_put_contents($this->path . '/.env.' . $time . '.backup', $envData);
|
|
|
|
|
}
|
2025-12-02 10:33:21 +00:00
|
|
|
$env = new Env($envData);
|
2022-07-13 07:02:55 +00:00
|
|
|
|
|
|
|
|
foreach ($env->list() as $key => $value) {
|
|
|
|
|
if (is_null($value)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2023-09-04 17:08:50 +00:00
|
|
|
|
|
|
|
|
$configVar = $vars[$key] ?? [];
|
|
|
|
|
if (!empty($configVar) && !($configVar['overwrite'] ?? false)) {
|
|
|
|
|
$vars[$key]['default'] = $value;
|
2022-07-13 07:02:55 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($ports as $key => $value) {
|
2026-01-26 12:10:19 +00:00
|
|
|
if ($value === $defaultHttpPort) {
|
|
|
|
|
$defaultHttpPort = $key;
|
2022-07-13 07:02:55 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-26 12:10:19 +00:00
|
|
|
if ($value === $defaultHttpsPort) {
|
|
|
|
|
$defaultHttpsPort = $key;
|
2022-07-13 07:02:55 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-17 05:36:23 +00:00
|
|
|
|
2026-03-24 11:39:17 +00:00
|
|
|
// 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.
|
2026-03-20 00:26:18 +00:00
|
|
|
$existingDatabase = null;
|
|
|
|
|
foreach ($compose->getServices() as $service) {
|
|
|
|
|
if (!$service) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
$svcEnv = $service->getEnvironment()->list();
|
|
|
|
|
if (isset($svcEnv['_APP_DB_ADAPTER'])) {
|
|
|
|
|
$existingDatabase = $svcEnv['_APP_DB_ADAPTER'];
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if ($existingDatabase === null) {
|
|
|
|
|
$envFilePath = $this->path . '/' . $this->getEnvFileName();
|
|
|
|
|
$rawEnv = @file_get_contents($envFilePath);
|
|
|
|
|
if ($rawEnv !== false) {
|
|
|
|
|
$existingDatabase = (new Env($rawEnv))->list()['_APP_DB_ADAPTER'] ?? null;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-24 11:39:17 +00:00
|
|
|
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;
|
2025-12-17 05:36:23 +00:00
|
|
|
}
|
2022-07-13 07:02:55 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-19 09:10:23 +00:00
|
|
|
$installerConfig = $this->readInstallerConfig();
|
|
|
|
|
$enabledDatabases = $installerConfig['enabledDatabases'] ?? ['mongodb', 'mariadb'];
|
|
|
|
|
if (!in_array($database, $enabledDatabases, true)) {
|
|
|
|
|
Console::error("Database '{$database}' is not available. Available options: " . implode(', ', $enabledDatabases));
|
|
|
|
|
Console::exit(1);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-22 12:42:41 +00:00
|
|
|
// If interactive and web mode enabled, start web server
|
2026-03-25 00:52:58 +00:00
|
|
|
// Skip the web installer when explicit CLI params are provided
|
|
|
|
|
if ($interactive === 'Y' && Console::isInteractive() && !$this->hasExplicitCliParams()) {
|
2025-12-02 10:33:21 +00:00
|
|
|
Console::success('Starting web installer...');
|
2026-01-22 14:22:46 +00:00
|
|
|
Console::info('Open your browser at: http://localhost:' . InstallerServer::INSTALLER_WEB_PORT);
|
2025-12-02 10:33:21 +00:00
|
|
|
Console::info('Press Ctrl+C to cancel installation');
|
|
|
|
|
|
2026-03-24 11:39:17 +00:00
|
|
|
$detectedDb = ($existingInstallation && isset($existingDatabase)) ? $existingDatabase : null;
|
2026-03-31 02:58:41 +00:00
|
|
|
$this->startWebServer($defaultHttpPort, $defaultHttpsPort, $organization, $image, $noStart, $vars, $isUpgrade || $existingInstallation, $detectedDb);
|
2025-12-02 10:33:21 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-22 12:42:41 +00:00
|
|
|
// Fall back to CLI mode
|
2026-01-22 14:22:46 +00:00
|
|
|
$enableAssistant = false;
|
|
|
|
|
$assistantExistsInOldCompose = false;
|
|
|
|
|
if ($existingInstallation && isset($compose)) {
|
|
|
|
|
try {
|
|
|
|
|
$assistantService = $compose->getService('appwrite-assistant');
|
|
|
|
|
$assistantExistsInOldCompose = $assistantService !== null;
|
|
|
|
|
} catch (\Throwable) {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($interactive === 'Y' && Console::isInteractive()) {
|
|
|
|
|
$prompt = 'Add Appwrite Assistant? (Y/n)' . ($assistantExistsInOldCompose ? ' [Currently enabled]' : '');
|
|
|
|
|
$answer = Console::confirm($prompt);
|
|
|
|
|
|
|
|
|
|
if (empty($answer)) {
|
|
|
|
|
$enableAssistant = $assistantExistsInOldCompose;
|
|
|
|
|
} else {
|
|
|
|
|
$enableAssistant = \strtolower($answer) === 'y';
|
|
|
|
|
}
|
|
|
|
|
} elseif ($assistantExistsInOldCompose) {
|
|
|
|
|
$enableAssistant = true;
|
|
|
|
|
}
|
|
|
|
|
|
2022-07-13 07:02:55 +00:00
|
|
|
if (empty($httpPort)) {
|
2026-01-26 12:10:19 +00:00
|
|
|
$httpPort = Console::confirm('Choose your server HTTP port: (default: ' . $defaultHttpPort . ')');
|
|
|
|
|
$httpPort = ($httpPort) ?: $defaultHttpPort;
|
2022-07-13 07:02:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (empty($httpsPort)) {
|
2026-01-26 12:10:19 +00:00
|
|
|
$httpsPort = Console::confirm('Choose your server HTTPS port: (default: ' . $defaultHttpsPort . ')');
|
|
|
|
|
$httpsPort = ($httpsPort) ?: $defaultHttpsPort;
|
2022-07-13 07:02:55 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-22 14:22:46 +00:00
|
|
|
$userInput = [];
|
2023-04-14 23:18:31 +00:00
|
|
|
foreach ($vars as $var) {
|
2026-01-22 14:22:46 +00:00
|
|
|
if ($var['name'] === '_APP_ASSISTANT_OPENAI_API_KEY') {
|
|
|
|
|
if (!$enableAssistant) {
|
|
|
|
|
$userInput[$var['name']] = '';
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!empty($var['default'])) {
|
|
|
|
|
$userInput[$var['name']] = $var['default'];
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Console::isInteractive() && $interactive === 'Y') {
|
|
|
|
|
$userInput[$var['name']] = Console::confirm('Enter your OpenAI API key for Appwrite Assistant:');
|
|
|
|
|
if (empty($userInput[$var['name']])) {
|
|
|
|
|
Console::warning('No API key provided. Assistant will be disabled.');
|
|
|
|
|
$enableAssistant = false;
|
|
|
|
|
$userInput[$var['name']] = '';
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
$userInput[$var['name']] = '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-02 10:33:21 +00:00
|
|
|
if (!$var['required'] || !Console::isInteractive() || $interactive !== 'Y') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2022-07-13 07:02:55 +00:00
|
|
|
|
2025-07-31 14:49:04 +00:00
|
|
|
if ($var['name'] === '_APP_DB_ADAPTER' && $data !== false) {
|
2026-03-11 01:58:57 +00:00
|
|
|
$userInput[$var['name']] = $database;
|
2025-07-31 14:49:04 +00:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-02 10:33:21 +00:00
|
|
|
$value = Console::confirm($var['question'] . ' (default: \'' . $var['default'] . '\')');
|
2022-07-13 07:02:55 +00:00
|
|
|
|
2025-12-02 10:33:21 +00:00
|
|
|
if (!empty($value)) {
|
|
|
|
|
$userInput[$var['name']] = $value;
|
2022-07-13 07:02:55 +00:00
|
|
|
}
|
2025-12-02 10:33:21 +00:00
|
|
|
|
|
|
|
|
if ($var['filter'] === 'domainTarget' && !empty($value) && $value !== 'localhost') {
|
|
|
|
|
Console::warning("\nIf you haven't already done so, set the following record for {$value} on your DNS provider:\n");
|
|
|
|
|
$mask = "%-15.15s %-10.10s %-30.30s\n";
|
|
|
|
|
printf($mask, "Type", "Name", "Value");
|
|
|
|
|
printf($mask, "A or AAAA", "@", "<YOUR PUBLIC IP>");
|
|
|
|
|
Console::warning("\nUse 'AAAA' if you're using an IPv6 address and 'A' if you're using an IPv4 address.\n");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-20 00:26:18 +00:00
|
|
|
$userInput['_APP_DB_ADAPTER'] = $userInput['_APP_DB_ADAPTER'] ?? $database;
|
|
|
|
|
$database = $userInput['_APP_DB_ADAPTER'];
|
2025-05-16 11:31:18 +00:00
|
|
|
if ($database === 'postgresql') {
|
2026-03-11 01:58:57 +00:00
|
|
|
$userInput['_APP_DB_HOST'] = 'postgresql';
|
|
|
|
|
$userInput['_APP_DB_PORT'] = 5432;
|
2026-02-26 12:44:46 +00:00
|
|
|
} elseif ($database === 'mongodb') {
|
2026-03-11 01:58:57 +00:00
|
|
|
$userInput['_APP_DB_HOST'] = 'mongodb';
|
|
|
|
|
$userInput['_APP_DB_PORT'] = 27017;
|
2025-07-31 14:49:04 +00:00
|
|
|
} elseif ($database === 'mariadb') {
|
2026-03-11 01:58:57 +00:00
|
|
|
$userInput['_APP_DB_HOST'] = 'mariadb';
|
|
|
|
|
$userInput['_APP_DB_PORT'] = 3306;
|
2025-07-31 14:49:04 +00:00
|
|
|
}
|
2025-12-02 10:33:21 +00:00
|
|
|
|
2026-01-24 14:39:56 +00:00
|
|
|
$shouldGenerateSecrets = !$existingInstallation && !$isUpgrade;
|
|
|
|
|
$input = $this->prepareEnvironmentVariables($userInput, $vars, $shouldGenerateSecrets);
|
2026-03-31 08:08:29 +00:00
|
|
|
$this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart, null, null, $isUpgrade, migrate: $this->migrate);
|
2025-12-02 10:33:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-01-26 12:10:19 +00:00
|
|
|
protected function startWebServer(string $defaultHttpPort, string $defaultHttpsPort, string $organization, string $image, bool $noStart, array $vars, bool $isUpgrade = false, ?string $lockedDatabase = null): void
|
2025-12-02 10:33:21 +00:00
|
|
|
{
|
2026-01-25 06:03:05 +00:00
|
|
|
$port = InstallerServer::INSTALLER_WEB_PORT;
|
|
|
|
|
|
|
|
|
|
@unlink(InstallerServer::INSTALLER_COMPLETE_FILE);
|
2025-12-02 10:33:21 +00:00
|
|
|
|
2026-03-19 10:15:20 +00:00
|
|
|
$state = new State([]);
|
|
|
|
|
$state->clearStaleLock();
|
|
|
|
|
|
2026-03-19 09:10:23 +00:00
|
|
|
$installerConfig = $this->readInstallerConfig();
|
|
|
|
|
$enabledDatabases = $installerConfig['enabledDatabases'] ?? ['mongodb', 'mariadb'];
|
|
|
|
|
|
2026-01-24 11:00:51 +00:00
|
|
|
$this->setInstallerConfig([
|
2026-01-26 12:10:19 +00:00
|
|
|
'defaultHttpPort' => $defaultHttpPort,
|
|
|
|
|
'defaultHttpsPort' => $defaultHttpsPort,
|
2026-01-24 11:00:51 +00:00
|
|
|
'organization' => $organization,
|
|
|
|
|
'image' => $image,
|
|
|
|
|
'noStart' => $noStart,
|
|
|
|
|
'vars' => $vars,
|
|
|
|
|
'isUpgrade' => $isUpgrade,
|
|
|
|
|
'lockedDatabase' => $lockedDatabase,
|
2026-03-19 09:10:23 +00:00
|
|
|
'enabledDatabases' => $enabledDatabases,
|
2026-01-24 11:00:51 +00:00
|
|
|
'isLocal' => $this->isLocalInstall(),
|
|
|
|
|
'hostPath' => $this->hostPath ?: null,
|
|
|
|
|
]);
|
2026-02-26 08:42:37 +00:00
|
|
|
|
|
|
|
|
// Start Swoole-based installer server in background
|
2026-03-02 08:50:25 +00:00
|
|
|
// Redirect stdout/stderr to a log file so exec() returns immediately
|
|
|
|
|
// (otherwise the backgrounded process holds the pipe open and exec() hangs)
|
2026-02-26 08:42:37 +00:00
|
|
|
$serverScript = \escapeshellarg(dirname(__DIR__) . '/Installer/Server.php');
|
2026-03-02 08:50:25 +00:00
|
|
|
$logFile = \sys_get_temp_dir() . '/appwrite-installer-server.log';
|
2026-01-22 11:58:23 +00:00
|
|
|
$output = [];
|
2026-03-02 08:50:25 +00:00
|
|
|
\exec("php {$serverScript} > " . \escapeshellarg($logFile) . " 2>&1 & echo \$!", $output);
|
2026-01-22 11:58:23 +00:00
|
|
|
$pid = isset($output[0]) ? (int) $output[0] : 0;
|
|
|
|
|
|
2026-01-24 11:00:51 +00:00
|
|
|
\register_shutdown_function(function () use ($pid) {
|
2026-01-22 11:58:23 +00:00
|
|
|
if ($pid > 0 && \function_exists('posix_kill')) {
|
|
|
|
|
@\posix_kill($pid, SIGTERM);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-03-02 08:50:25 +00:00
|
|
|
\sleep(1);
|
2025-12-02 10:33:21 +00:00
|
|
|
|
2026-01-24 11:00:51 +00:00
|
|
|
if (!$this->waitForWebServer($port)) {
|
2026-03-02 08:50:25 +00:00
|
|
|
$log = @\file_get_contents($logFile);
|
|
|
|
|
if ($log !== false && $log !== '') {
|
|
|
|
|
Console::error('Installer server log:');
|
|
|
|
|
Console::error($log);
|
|
|
|
|
}
|
2026-01-24 11:00:51 +00:00
|
|
|
Console::warning('Web installer did not respond in time. Please refresh the browser.');
|
|
|
|
|
return;
|
2025-12-02 10:33:21 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-25 06:03:05 +00:00
|
|
|
if ($this->isInstallationComplete($port)) {
|
|
|
|
|
Console::success('Installation completed.');
|
2025-12-02 10:33:21 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 14:39:56 +00:00
|
|
|
public function prepareEnvironmentVariables(array $userInput, array $vars, bool $shouldGenerateSecrets = true): array
|
2025-12-02 10:33:21 +00:00
|
|
|
{
|
|
|
|
|
$input = [];
|
|
|
|
|
$password = new Password();
|
|
|
|
|
$token = new Token();
|
|
|
|
|
|
2026-01-22 12:42:41 +00:00
|
|
|
// Start with all defaults
|
2025-12-02 10:33:21 +00:00
|
|
|
foreach ($vars as $var) {
|
2026-01-26 11:56:18 +00:00
|
|
|
$filter = $var['filter'] ?? null;
|
2026-01-24 11:00:51 +00:00
|
|
|
$default = $var['default'] ?? null;
|
|
|
|
|
$hasDefault = $default !== null && $default !== '';
|
2026-01-26 11:56:18 +00:00
|
|
|
|
|
|
|
|
if ($filter === 'token') {
|
2026-01-24 14:39:56 +00:00
|
|
|
if ($hasDefault) {
|
|
|
|
|
$input[$var['name']] = $default;
|
|
|
|
|
} elseif ($shouldGenerateSecrets) {
|
|
|
|
|
$input[$var['name']] = $token->generate();
|
|
|
|
|
} else {
|
|
|
|
|
$input[$var['name']] = '';
|
|
|
|
|
}
|
2026-01-26 11:56:18 +00:00
|
|
|
} elseif ($filter === 'password') {
|
2026-01-24 14:39:56 +00:00
|
|
|
if ($hasDefault) {
|
|
|
|
|
$input[$var['name']] = $default;
|
|
|
|
|
} elseif ($shouldGenerateSecrets) {
|
2026-01-26 11:56:18 +00:00
|
|
|
/*;#+@:/?& broke DSNs locally */
|
2026-01-24 14:39:56 +00:00
|
|
|
$input[$var['name']] = $this->generatePasswordValue($var['name'], $password);
|
|
|
|
|
} else {
|
|
|
|
|
$input[$var['name']] = '';
|
|
|
|
|
}
|
2025-12-02 10:33:21 +00:00
|
|
|
} else {
|
2026-01-24 11:00:51 +00:00
|
|
|
$input[$var['name']] = $default;
|
2022-07-13 07:02:55 +00:00
|
|
|
}
|
2025-12-02 10:33:21 +00:00
|
|
|
}
|
2022-10-14 09:32:17 +00:00
|
|
|
|
2026-01-22 12:42:41 +00:00
|
|
|
// Override with user inputs
|
2025-12-02 10:33:21 +00:00
|
|
|
foreach ($userInput as $key => $value) {
|
2026-01-22 14:22:46 +00:00
|
|
|
if ($value !== null && ($value !== '' || $key === '_APP_ASSISTANT_OPENAI_API_KEY')) {
|
2025-12-02 10:33:21 +00:00
|
|
|
$input[$key] = $value;
|
2022-10-14 09:32:17 +00:00
|
|
|
}
|
2022-07-13 07:02:55 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-24 11:00:51 +00:00
|
|
|
foreach ($input as $key => $value) {
|
|
|
|
|
if (!is_string($value)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (str_contains($value, "\n") || str_contains($value, "\r")) {
|
|
|
|
|
throw new \InvalidArgumentException('Invalid value for ' . $key);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-22 12:42:41 +00:00
|
|
|
// Set database-specific connection details
|
2025-12-02 10:33:21 +00:00
|
|
|
$database = $input['_APP_DB_ADAPTER'] ?? 'mongodb';
|
|
|
|
|
if ($database === 'mongodb') {
|
|
|
|
|
$input['_APP_DB_HOST'] = 'mongodb';
|
|
|
|
|
$input['_APP_DB_PORT'] = 27017;
|
|
|
|
|
} elseif ($database === 'mariadb') {
|
|
|
|
|
$input['_APP_DB_HOST'] = 'mariadb';
|
|
|
|
|
$input['_APP_DB_PORT'] = 3306;
|
2026-02-13 04:09:42 +00:00
|
|
|
} elseif ($database === 'postgresql') {
|
2026-02-26 12:29:38 +00:00
|
|
|
$input['_APP_DB_HOST'] = 'postgresql';
|
2026-02-13 04:09:42 +00:00
|
|
|
$input['_APP_DB_PORT'] = 5432;
|
2025-12-02 10:33:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $input;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 14:39:56 +00:00
|
|
|
public function hasExistingConfig(): bool
|
|
|
|
|
{
|
|
|
|
|
$isLocalInstall = $this->isLocalInstall();
|
|
|
|
|
$this->applyLocalPaths($isLocalInstall, true);
|
|
|
|
|
|
|
|
|
|
if ($this->readExistingCompose() !== '') {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return file_exists($this->path . '/' . $this->getEnvFileName());
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 06:14:00 +00:00
|
|
|
private function updateProgress(?callable $progress, string $step, string $status, array $messages = [], array $details = [], ?string $messageOverride = null): void
|
2026-01-22 11:58:23 +00:00
|
|
|
{
|
|
|
|
|
if (!$progress) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 11:00:51 +00:00
|
|
|
if ($messageOverride !== null) {
|
|
|
|
|
$message = $messageOverride;
|
|
|
|
|
} else {
|
|
|
|
|
$key = $status === InstallerServer::STATUS_COMPLETED ? 'done' : 'start';
|
|
|
|
|
$message = $messages[$step][$key] ?? null;
|
|
|
|
|
if ($message === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-22 11:58:23 +00:00
|
|
|
try {
|
|
|
|
|
$progress($step, $status, $message, $details);
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
}
|
2026-01-24 11:00:51 +00:00
|
|
|
if ($status === InstallerServer::STATUS_IN_PROGRESS) {
|
|
|
|
|
sleep(self::INSTALL_STEP_DELAY_SECONDS);
|
|
|
|
|
}
|
2026-01-22 11:58:23 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-24 11:00:51 +00:00
|
|
|
private function setInstallerConfig(array $config): void
|
2026-01-22 11:58:23 +00:00
|
|
|
{
|
2026-01-24 11:00:51 +00:00
|
|
|
$json = json_encode($config, JSON_UNESCAPED_SLASHES);
|
|
|
|
|
if (!is_string($json)) {
|
|
|
|
|
return;
|
2026-01-22 11:58:23 +00:00
|
|
|
}
|
2026-01-24 11:00:51 +00:00
|
|
|
|
|
|
|
|
putenv('APPWRITE_INSTALLER_CONFIG=' . $json);
|
|
|
|
|
$path = InstallerServer::INSTALLER_CONFIG_FILE;
|
|
|
|
|
if (@file_put_contents($path, $json) === false) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
@chmod($path, 0600);
|
2026-01-22 11:58:23 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-02 10:33:21 +00:00
|
|
|
public function performInstallation(
|
|
|
|
|
string $httpPort,
|
|
|
|
|
string $httpsPort,
|
|
|
|
|
string $organization,
|
|
|
|
|
string $image,
|
|
|
|
|
array $input,
|
2026-01-22 11:58:23 +00:00
|
|
|
bool $noStart,
|
|
|
|
|
?callable $progress = null,
|
|
|
|
|
?string $resumeFromStep = null,
|
2026-01-24 14:39:56 +00:00
|
|
|
bool $isUpgrade = false,
|
2026-03-31 01:44:20 +00:00
|
|
|
array $account = [],
|
|
|
|
|
?callable $onComplete = null,
|
2026-03-31 08:08:29 +00:00
|
|
|
bool $migrate = false,
|
2025-12-02 10:33:21 +00:00
|
|
|
): void {
|
2026-01-24 11:00:51 +00:00
|
|
|
$isLocalInstall = $this->isLocalInstall();
|
|
|
|
|
$this->applyLocalPaths($isLocalInstall, false);
|
|
|
|
|
|
2025-12-02 10:33:21 +00:00
|
|
|
$isCLI = php_sapi_name() === 'cli';
|
2026-03-20 06:12:47 +00:00
|
|
|
if ($isLocalInstall || $isUpgrade) {
|
2026-01-24 11:00:51 +00:00
|
|
|
$useExistingConfig = false;
|
|
|
|
|
} else {
|
|
|
|
|
$useExistingConfig = file_exists($this->path . '/' . $this->getComposeFileName())
|
|
|
|
|
&& file_exists($this->path . '/' . $this->getEnvFileName());
|
|
|
|
|
}
|
2025-12-02 10:33:21 +00:00
|
|
|
|
2026-01-24 11:00:51 +00:00
|
|
|
if ($isLocalInstall) {
|
2026-01-22 11:58:23 +00:00
|
|
|
$image = 'appwrite';
|
2026-01-24 14:39:56 +00:00
|
|
|
$organization = 'appwrite';
|
2026-01-22 11:58:23 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-24 11:00:51 +00:00
|
|
|
$templateForEnv = new View($this->buildFromProjectPath('/app/views/install/env.phtml'));
|
|
|
|
|
$templateForCompose = new View($this->buildFromProjectPath('/app/views/install/compose.phtml'));
|
2022-07-13 07:02:55 +00:00
|
|
|
|
2025-12-02 10:33:21 +00:00
|
|
|
$database = $input['_APP_DB_ADAPTER'] ?? 'mongodb';
|
|
|
|
|
|
2026-03-12 11:32:40 +00:00
|
|
|
$version = \getenv('_APP_VERSION') ?: (\defined('APP_VERSION_STABLE') ? APP_VERSION_STABLE : 'latest');
|
2026-01-24 11:00:51 +00:00
|
|
|
if ($isLocalInstall) {
|
2026-01-22 11:58:23 +00:00
|
|
|
$version = 'local';
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 11:00:51 +00:00
|
|
|
$assistantKey = (string) ($input['_APP_ASSISTANT_OPENAI_API_KEY'] ?? '');
|
|
|
|
|
$enableAssistant = trim($assistantKey) !== '';
|
|
|
|
|
|
2022-07-13 07:02:55 +00:00
|
|
|
$templateForCompose
|
|
|
|
|
->setParam('httpPort', $httpPort)
|
|
|
|
|
->setParam('httpsPort', $httpsPort)
|
2026-01-22 11:58:23 +00:00
|
|
|
->setParam('version', $version)
|
2022-07-13 07:02:55 +00:00
|
|
|
->setParam('organization', $organization)
|
2025-12-02 10:33:21 +00:00
|
|
|
->setParam('image', $image)
|
2026-01-24 11:00:51 +00:00
|
|
|
->setParam('database', $database)
|
|
|
|
|
->setParam('hostPath', $this->hostPath)
|
|
|
|
|
->setParam('enableAssistant', $enableAssistant);
|
2022-07-13 07:02:55 +00:00
|
|
|
|
2023-08-28 22:09:37 +00:00
|
|
|
$templateForEnv->setParam('vars', $input);
|
2022-07-13 07:02:55 +00:00
|
|
|
|
2026-01-24 11:00:51 +00:00
|
|
|
$steps = [
|
|
|
|
|
InstallerServer::STEP_DOCKER_COMPOSE,
|
|
|
|
|
InstallerServer::STEP_ENV_VARS,
|
|
|
|
|
InstallerServer::STEP_DOCKER_CONTAINERS
|
|
|
|
|
];
|
|
|
|
|
|
2026-01-22 11:58:23 +00:00
|
|
|
$startIndex = 0;
|
|
|
|
|
if ($resumeFromStep !== null) {
|
|
|
|
|
$resumeIndex = array_search($resumeFromStep, $steps, true);
|
|
|
|
|
if ($resumeIndex !== false) {
|
|
|
|
|
$startIndex = $resumeIndex;
|
|
|
|
|
}
|
2022-07-13 07:02:55 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-22 11:58:23 +00:00
|
|
|
$currentStep = null;
|
|
|
|
|
|
2026-01-24 11:00:51 +00:00
|
|
|
$messages = $this->buildStepMessages($isUpgrade);
|
2022-07-13 07:02:55 +00:00
|
|
|
|
2026-01-22 11:58:23 +00:00
|
|
|
try {
|
|
|
|
|
if ($startIndex <= 1) {
|
2026-01-24 11:00:51 +00:00
|
|
|
$this->updateProgress($progress, InstallerServer::STEP_CONFIG_FILES, InstallerServer::STATUS_IN_PROGRESS, $messages);
|
2022-07-13 07:02:55 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-22 11:58:23 +00:00
|
|
|
if ($startIndex <= 0) {
|
2026-01-24 11:00:51 +00:00
|
|
|
$currentStep = InstallerServer::STEP_DOCKER_COMPOSE;
|
|
|
|
|
$this->updateProgress($progress, InstallerServer::STEP_DOCKER_COMPOSE, InstallerServer::STATUS_IN_PROGRESS, $messages);
|
2026-01-22 11:58:23 +00:00
|
|
|
|
2026-01-22 12:42:41 +00:00
|
|
|
if (!$useExistingConfig) {
|
2026-01-24 14:39:56 +00:00
|
|
|
$this->writeComposeFile($templateForCompose);
|
2025-12-02 10:33:21 +00:00
|
|
|
}
|
2026-01-22 11:58:23 +00:00
|
|
|
|
2026-01-24 11:00:51 +00:00
|
|
|
$this->updateProgress($progress, InstallerServer::STEP_DOCKER_COMPOSE, InstallerServer::STATUS_COMPLETED, $messages);
|
2025-12-02 10:33:21 +00:00
|
|
|
}
|
2022-07-13 07:02:55 +00:00
|
|
|
|
2026-01-22 11:58:23 +00:00
|
|
|
if ($startIndex <= 1) {
|
2026-01-24 11:00:51 +00:00
|
|
|
$currentStep = InstallerServer::STEP_ENV_VARS;
|
|
|
|
|
$this->updateProgress($progress, InstallerServer::STEP_ENV_VARS, InstallerServer::STATUS_IN_PROGRESS, $messages);
|
2026-01-22 11:58:23 +00:00
|
|
|
|
2026-01-22 12:42:41 +00:00
|
|
|
if (!$useExistingConfig) {
|
2026-01-24 14:39:56 +00:00
|
|
|
$this->writeEnvFile($templateForEnv);
|
2026-01-22 11:58:23 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-24 11:00:51 +00:00
|
|
|
$this->updateProgress($progress, InstallerServer::STEP_ENV_VARS, InstallerServer::STATUS_COMPLETED, $messages);
|
|
|
|
|
$this->updateProgress($progress, InstallerServer::STEP_CONFIG_FILES, InstallerServer::STATUS_COMPLETED, $messages);
|
2025-12-02 10:33:21 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-22 12:42:41 +00:00
|
|
|
if ($database === 'mongodb' && !$useExistingConfig) {
|
2026-01-24 11:00:51 +00:00
|
|
|
$this->copyMongoEntrypointIfNeeded();
|
2025-12-02 10:33:21 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-22 11:58:23 +00:00
|
|
|
if (!$noStart && $startIndex <= 2) {
|
2026-01-24 11:00:51 +00:00
|
|
|
$currentStep = InstallerServer::STEP_DOCKER_CONTAINERS;
|
|
|
|
|
$this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_IN_PROGRESS, $messages);
|
2026-03-24 08:25:57 +00:00
|
|
|
$this->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI, $progress, $isUpgrade);
|
2026-03-02 11:46:51 +00:00
|
|
|
|
2026-03-24 10:53:56 +00:00
|
|
|
if (!$isUpgrade) {
|
|
|
|
|
$this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages);
|
2026-03-24 11:56:42 +00:00
|
|
|
$this->updateProgress($progress, InstallerServer::STEP_ACCOUNT_SETUP, InstallerServer::STATUS_IN_PROGRESS, messageOverride: 'Creating Appwrite account...');
|
2026-03-24 10:53:56 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-03 06:47:29 +00:00
|
|
|
if (!$isLocalInstall) {
|
|
|
|
|
$this->connectInstallerToAppwriteNetwork();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$domain = $input['_APP_DOMAIN'] ?? 'localhost';
|
|
|
|
|
|
2026-03-24 10:53:56 +00:00
|
|
|
$healthStep = $isUpgrade ? InstallerServer::STEP_DOCKER_CONTAINERS : InstallerServer::STEP_ACCOUNT_SETUP;
|
2026-03-24 12:05:47 +00:00
|
|
|
if (!$isUpgrade) {
|
|
|
|
|
$currentStep = InstallerServer::STEP_ACCOUNT_SETUP;
|
|
|
|
|
}
|
2026-03-24 10:53:56 +00:00
|
|
|
$apiUrl = $this->waitForApiReady($domain, $httpPort, $isLocalInstall, $progress, $healthStep);
|
2026-01-22 11:58:23 +00:00
|
|
|
|
2026-03-24 10:53:56 +00:00
|
|
|
if ($isUpgrade) {
|
|
|
|
|
$this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages);
|
|
|
|
|
}
|
2026-01-22 11:58:23 +00:00
|
|
|
|
2026-01-24 14:39:56 +00:00
|
|
|
if (!$isUpgrade) {
|
2026-03-03 06:47:29 +00:00
|
|
|
$this->createInitialAdminAccount($account, $progress, $apiUrl, $domain);
|
2026-01-24 14:39:56 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 08:08:29 +00:00
|
|
|
if ($isUpgrade && $migrate) {
|
2026-03-31 07:58:33 +00:00
|
|
|
// Allow the containers-completed SSE event to flush
|
|
|
|
|
// before blocking on migration exec
|
|
|
|
|
usleep(200_000);
|
|
|
|
|
$currentStep = InstallerServer::STEP_MIGRATION;
|
|
|
|
|
$this->runDatabaseMigration($progress, $isLocalInstall);
|
|
|
|
|
} elseif ($isUpgrade) {
|
|
|
|
|
$this->updateProgress($progress, InstallerServer::STEP_MIGRATION, InstallerServer::STATUS_COMPLETED, messageOverride: 'Migration skipped');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 01:44:20 +00:00
|
|
|
// Signal completion before tracking so the SSE stream
|
|
|
|
|
// finishes and the frontend can redirect immediately.
|
|
|
|
|
if ($onComplete) {
|
|
|
|
|
try {
|
|
|
|
|
$onComplete();
|
|
|
|
|
} catch (\Throwable) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:15:51 +00:00
|
|
|
// Run tracking in a coroutine when inside a Swoole
|
|
|
|
|
// request so it doesn't block the worker.
|
|
|
|
|
if (Coroutine::getCid() !== -1) {
|
2026-03-31 01:49:04 +00:00
|
|
|
go(function () use ($input, $isUpgrade, $version, $account) {
|
|
|
|
|
$this->trackSelfHostedInstall($input, $isUpgrade, $version, $account);
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
$this->trackSelfHostedInstall($input, $isUpgrade, $version, $account);
|
|
|
|
|
}
|
2026-01-30 06:14:00 +00:00
|
|
|
|
2026-01-22 11:58:23 +00:00
|
|
|
if ($isCLI) {
|
|
|
|
|
Console::success('Appwrite installed successfully');
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if ($isCLI) {
|
|
|
|
|
Console::success('Installation files created. Run "docker compose up -d" to start Appwrite');
|
|
|
|
|
}
|
2025-12-02 10:33:21 +00:00
|
|
|
}
|
2026-01-22 11:58:23 +00:00
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
if ($currentStep) {
|
2026-03-11 01:58:57 +00:00
|
|
|
$details = [];
|
2026-01-22 11:58:23 +00:00
|
|
|
$previous = $e->getPrevious();
|
|
|
|
|
if ($previous instanceof \Throwable && $previous->getMessage() !== '') {
|
|
|
|
|
$details['output'] = $previous->getMessage();
|
|
|
|
|
}
|
2026-01-24 11:00:51 +00:00
|
|
|
$this->updateProgress($progress, $currentStep, InstallerServer::STATUS_ERROR, $messages, $details, $e->getMessage());
|
2025-12-02 10:33:21 +00:00
|
|
|
}
|
2026-01-22 11:58:23 +00:00
|
|
|
throw $e;
|
2022-07-13 07:02:55 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-24 11:00:51 +00:00
|
|
|
|
2026-03-03 06:47:29 +00:00
|
|
|
private function createInitialAdminAccount(array $account, ?callable $progress, string $apiUrl, string $domain): void
|
2026-01-24 14:39:56 +00:00
|
|
|
{
|
|
|
|
|
$name = $account['name'] ?? 'Admin';
|
|
|
|
|
$email = $account['email'] ?? null;
|
|
|
|
|
$password = $account['password'] ?? null;
|
|
|
|
|
|
|
|
|
|
if (!$email || !$password) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$this->updateProgress(
|
|
|
|
|
$progress,
|
|
|
|
|
InstallerServer::STEP_ACCOUNT_SETUP,
|
|
|
|
|
InstallerServer::STATUS_IN_PROGRESS,
|
|
|
|
|
messageOverride: 'Creating Appwrite account'
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-24 08:36:32 +00:00
|
|
|
// 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).
|
2026-03-12 11:32:40 +00:00
|
|
|
$userId = null;
|
|
|
|
|
try {
|
|
|
|
|
$userId = $this->makeApiCall('/v1/account', [
|
|
|
|
|
'userId' => 'unique()',
|
|
|
|
|
'email' => $email,
|
|
|
|
|
'password' => $password,
|
|
|
|
|
'name' => $name
|
|
|
|
|
], false, $apiUrl, $domain);
|
|
|
|
|
} catch (\Throwable $e) {
|
2026-03-24 08:36:32 +00:00
|
|
|
$message = $e->getMessage();
|
|
|
|
|
$accountExists = \stripos($message, 'already exists') !== false
|
|
|
|
|
|| \stripos($message, 'console is restricted') !== false;
|
|
|
|
|
if (!$accountExists) {
|
2026-03-12 11:32:40 +00:00
|
|
|
throw $e;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-24 14:39:56 +00:00
|
|
|
|
|
|
|
|
$session = $this->makeApiCall('/v1/account/sessions/email', [
|
|
|
|
|
'email' => $email,
|
|
|
|
|
'password' => $password
|
2026-03-03 06:47:29 +00:00
|
|
|
], true, $apiUrl, $domain);
|
2026-01-24 14:39:56 +00:00
|
|
|
|
|
|
|
|
$this->updateProgress(
|
|
|
|
|
$progress,
|
|
|
|
|
InstallerServer::STEP_ACCOUNT_SETUP,
|
|
|
|
|
InstallerServer::STATUS_COMPLETED,
|
|
|
|
|
details: [
|
2026-03-12 11:32:40 +00:00
|
|
|
'userId' => $userId ?? $session['id'],
|
2026-01-24 14:39:56 +00:00
|
|
|
'sessionId' => $session['id'],
|
2026-03-02 11:46:51 +00:00
|
|
|
'sessionSecret' => $session['secret'],
|
2026-01-24 14:39:56 +00:00
|
|
|
'sessionExpire' => $session['expire'] ?? null
|
|
|
|
|
],
|
|
|
|
|
messageOverride: 'Account created successfully'
|
|
|
|
|
);
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
$this->updateProgress(
|
|
|
|
|
$progress,
|
|
|
|
|
InstallerServer::STEP_ACCOUNT_SETUP,
|
|
|
|
|
InstallerServer::STATUS_ERROR,
|
2026-03-03 06:47:29 +00:00
|
|
|
details: [
|
|
|
|
|
'output' => "apiUrl={$apiUrl}, domain={$domain}",
|
|
|
|
|
'trace' => $e->getTraceAsString(),
|
|
|
|
|
],
|
2026-01-24 14:39:56 +00:00
|
|
|
messageOverride: 'Account creation failed: ' . $e->getMessage()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 07:58:33 +00:00
|
|
|
private function runDatabaseMigration(?callable $progress, bool $isLocalInstall): void
|
|
|
|
|
{
|
|
|
|
|
$this->updateProgress(
|
|
|
|
|
$progress,
|
|
|
|
|
InstallerServer::STEP_MIGRATION,
|
|
|
|
|
InstallerServer::STATUS_IN_PROGRESS,
|
|
|
|
|
messageOverride: 'Running database migration...'
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Allow the SSE chunk to flush before the blocking exec
|
|
|
|
|
usleep(100_000);
|
|
|
|
|
|
|
|
|
|
// Static command — no user input involved
|
|
|
|
|
$command = $isLocalInstall
|
|
|
|
|
? 'docker compose exec appwrite migrate 2>&1'
|
|
|
|
|
: 'docker exec appwrite migrate 2>&1';
|
|
|
|
|
|
|
|
|
|
$output = [];
|
|
|
|
|
\exec($command, $output, $exit);
|
|
|
|
|
|
|
|
|
|
if ($exit !== 0) {
|
|
|
|
|
$message = trim(implode("\n", $output));
|
|
|
|
|
$this->updateProgress(
|
|
|
|
|
$progress,
|
|
|
|
|
InstallerServer::STEP_MIGRATION,
|
|
|
|
|
InstallerServer::STATUS_ERROR,
|
|
|
|
|
details: ['output' => $message],
|
|
|
|
|
messageOverride: 'Migration failed: ' . ($message ?: 'exit code ' . $exit)
|
|
|
|
|
);
|
|
|
|
|
throw new \RuntimeException('Database migration failed', 0, $message !== '' ? new \RuntimeException($message) : null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->updateProgress(
|
|
|
|
|
$progress,
|
|
|
|
|
InstallerServer::STEP_MIGRATION,
|
|
|
|
|
InstallerServer::STATUS_COMPLETED,
|
|
|
|
|
messageOverride: 'Database migration completed'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 06:14:00 +00:00
|
|
|
private function trackSelfHostedInstall(array $input, bool $isUpgrade, string $version, array $account): void
|
|
|
|
|
{
|
|
|
|
|
if ($this->isLocalInstall()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$appEnv = $input['_APP_ENV'] ?? 'development';
|
|
|
|
|
$domain = $input['_APP_DOMAIN'] ?? 'localhost';
|
|
|
|
|
|
|
|
|
|
/* local or test instance */
|
|
|
|
|
if ($appEnv !== 'production') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* prod but local or test instance */
|
|
|
|
|
if ($domain === 'localhost'
|
|
|
|
|
|| str_starts_with($domain, '127.')
|
|
|
|
|
|| str_starts_with($domain, '0.0.0.0')
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$type = $isUpgrade ? 'upgrade' : 'install';
|
|
|
|
|
$database = $input['_APP_DB_ADAPTER'] ?? 'mongodb';
|
2026-02-03 08:24:20 +00:00
|
|
|
$name = $account['name'] ?? 'Admin';
|
|
|
|
|
$email = $account['email'] ?? 'admin@selfhosted.local';
|
2026-01-30 06:14:00 +00:00
|
|
|
|
2026-03-31 01:44:20 +00:00
|
|
|
$hostIp = @gethostbyname($domain);
|
2026-03-24 08:25:57 +00:00
|
|
|
|
2026-01-30 06:14:00 +00:00
|
|
|
$payload = [
|
2026-02-03 08:24:20 +00:00
|
|
|
'action' => $type,
|
|
|
|
|
'account' => 'self-hosted',
|
|
|
|
|
'url' => 'https://' . $domain,
|
|
|
|
|
'category' => 'self_hosted',
|
|
|
|
|
'label' => 'self_hosted_' . $type,
|
2026-01-30 06:41:39 +00:00
|
|
|
'version' => $version,
|
2026-02-03 08:24:20 +00:00
|
|
|
'data' => json_encode([
|
2026-02-26 13:54:06 +00:00
|
|
|
'name' => $name,
|
|
|
|
|
'email' => $email,
|
2026-02-03 08:24:20 +00:00
|
|
|
'domain' => $domain,
|
|
|
|
|
'database' => $database,
|
2026-03-31 03:02:22 +00:00
|
|
|
'ip' => ($hostIp !== false && $hostIp !== $domain) ? $hostIp : null,
|
2026-03-24 08:25:57 +00:00
|
|
|
'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),
|
2026-02-03 08:24:20 +00:00
|
|
|
]),
|
2026-01-30 06:14:00 +00:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$client = new Client();
|
|
|
|
|
$client
|
2026-03-31 01:44:20 +00:00
|
|
|
->setConnectTimeout(5000)
|
|
|
|
|
->setTimeout(5000)
|
2026-01-30 06:14:00 +00:00
|
|
|
->addHeader('Content-Type', 'application/json')
|
2026-02-03 08:24:20 +00:00
|
|
|
->fetch(self::GROWTH_API_URL . '/analytics', Client::METHOD_POST, $payload);
|
2026-01-30 06:14:00 +00:00
|
|
|
} catch (\Throwable) {
|
|
|
|
|
// tracking shouldn't block installation
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 06:47:29 +00:00
|
|
|
/**
|
|
|
|
|
* Wait for the Appwrite API to respond. Builds candidate URLs based on
|
|
|
|
|
* the runtime context and returns whichever responds first.
|
|
|
|
|
*
|
|
|
|
|
* Candidates (in order of preference):
|
|
|
|
|
* - Docker internal DNS (http://appwrite) — only if on the appwrite network
|
|
|
|
|
* - host.docker.internal:{port} — reaches host-published ports from inside a container
|
|
|
|
|
* - localhost:{port} — works when running directly on the host (local dev)
|
|
|
|
|
*/
|
2026-03-24 10:53:56 +00:00
|
|
|
private function waitForApiReady(string $domain, string $httpPort, bool $isLocalInstall, ?callable $progress, string $step = InstallerServer::STEP_ACCOUNT_SETUP): string
|
2026-01-24 14:39:56 +00:00
|
|
|
{
|
|
|
|
|
$client = new Client();
|
2026-03-03 06:47:29 +00:00
|
|
|
$client
|
2026-03-18 05:44:13 +00:00
|
|
|
->setTimeout(2000)
|
|
|
|
|
->setConnectTimeout(2000)
|
2026-03-03 06:47:29 +00:00
|
|
|
->addHeader('Host', $domain);
|
|
|
|
|
|
|
|
|
|
$healthPath = '/v1/health/version';
|
|
|
|
|
|
2026-03-24 08:25:57 +00:00
|
|
|
if ($isLocalInstall) {
|
|
|
|
|
$candidates = [
|
|
|
|
|
'http://localhost:' . $httpPort . $healthPath,
|
|
|
|
|
];
|
|
|
|
|
} else {
|
|
|
|
|
$candidates = [
|
|
|
|
|
self::APPWRITE_API_URL . $healthPath,
|
|
|
|
|
'http://host.docker.internal:' . $httpPort . $healthPath,
|
|
|
|
|
];
|
|
|
|
|
}
|
2026-03-03 06:47:29 +00:00
|
|
|
|
|
|
|
|
$lastErrors = [];
|
2026-01-24 14:39:56 +00:00
|
|
|
|
|
|
|
|
for ($i = 0; $i < self::HEALTH_CHECK_ATTEMPTS; $i++) {
|
2026-03-03 06:47:29 +00:00
|
|
|
foreach ($candidates as $url) {
|
|
|
|
|
try {
|
|
|
|
|
$response = $client->fetch($url);
|
|
|
|
|
if ($response->getStatusCode() === 200) {
|
|
|
|
|
return \rtrim(\substr($url, 0, -\strlen($healthPath)), '/');
|
|
|
|
|
}
|
|
|
|
|
$lastErrors[$url] = "HTTP {$response->getStatusCode()}";
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
$lastErrors[$url] = $e->getMessage();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($progress) {
|
|
|
|
|
try {
|
|
|
|
|
$progress(
|
|
|
|
|
$step,
|
|
|
|
|
InstallerServer::STATUS_IN_PROGRESS,
|
2026-03-24 10:53:56 +00:00
|
|
|
'Waiting for Appwrite to be ready...',
|
2026-03-03 06:47:29 +00:00
|
|
|
[]
|
|
|
|
|
);
|
|
|
|
|
} catch (\Throwable) {
|
|
|
|
|
}
|
2026-01-24 14:39:56 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($i < self::HEALTH_CHECK_ATTEMPTS - 1) {
|
|
|
|
|
sleep(self::HEALTH_CHECK_DELAY_SECONDS);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 06:47:29 +00:00
|
|
|
$errorDetail = implode('; ', array_map(
|
|
|
|
|
fn ($url, $err) => "{$url} => {$err}",
|
|
|
|
|
array_keys($lastErrors),
|
|
|
|
|
array_values($lastErrors)
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
throw new \Exception("Failed to connect with Appwrite in time. Tried: {$errorDetail}");
|
2026-01-24 14:39:56 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-03 06:47:29 +00:00
|
|
|
/**
|
|
|
|
|
* Connect the installer container to the appwrite network, retrying
|
|
|
|
|
* until it succeeds or we run out of attempts.
|
|
|
|
|
*/
|
|
|
|
|
private function connectInstallerToAppwriteNetwork(): void
|
2026-01-24 14:39:56 +00:00
|
|
|
{
|
|
|
|
|
$network = escapeshellarg('appwrite');
|
2026-03-03 06:47:29 +00:00
|
|
|
|
|
|
|
|
// Resolve our own container ID — works regardless of how the
|
|
|
|
|
// container was started (--name, random name, etc.).
|
|
|
|
|
$containerId = trim((string) @file_get_contents('/etc/hostname'));
|
|
|
|
|
if ($containerId === '') {
|
|
|
|
|
$containerId = InstallerServer::DEFAULT_CONTAINER;
|
|
|
|
|
}
|
|
|
|
|
$container = escapeshellarg($containerId);
|
|
|
|
|
|
|
|
|
|
$outputStr = '';
|
|
|
|
|
for ($i = 0; $i < 10; $i++) {
|
|
|
|
|
$output = [];
|
|
|
|
|
@exec("docker network connect {$network} {$container} 2>&1", $output, $exitCode);
|
|
|
|
|
|
|
|
|
|
if ($exitCode === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$outputStr = implode(' ', $output);
|
|
|
|
|
if (str_contains($outputStr, 'already exists')) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sleep(1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new \Exception('Failed to connect installer to appwrite network: ' . $outputStr);
|
2026-01-24 14:39:56 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-03 06:47:29 +00:00
|
|
|
private function makeApiCall(string $endpoint, array $body, bool $extractSession = false, string $apiUrl = self::APPWRITE_API_URL, string $domain = 'localhost')
|
2026-01-24 14:39:56 +00:00
|
|
|
{
|
|
|
|
|
$client = new Client();
|
|
|
|
|
$client
|
2026-03-03 06:47:29 +00:00
|
|
|
->setTimeout(30000)
|
|
|
|
|
->setConnectTimeout(10000)
|
2026-01-24 14:39:56 +00:00
|
|
|
->addHeader('Content-Type', 'application/json')
|
2026-03-03 06:47:29 +00:00
|
|
|
->addHeader('X-Appwrite-Project', 'console')
|
|
|
|
|
->addHeader('Host', $domain);
|
2026-01-24 14:39:56 +00:00
|
|
|
|
2026-03-02 11:46:51 +00:00
|
|
|
$url = $apiUrl . $endpoint;
|
2026-01-24 14:39:56 +00:00
|
|
|
$response = $client->fetch($url, Client::METHOD_POST, $body);
|
|
|
|
|
|
|
|
|
|
if ($response->getStatusCode() !== 201) {
|
|
|
|
|
$error = $response->json();
|
2026-03-03 06:47:29 +00:00
|
|
|
$message = $error['message'] ?? ('HTTP ' . $response->getStatusCode() . ': ' . $response->getBody());
|
|
|
|
|
throw new \Exception("API call failed ({$endpoint}): {$message}");
|
2026-01-24 14:39:56 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$data = $response->json();
|
|
|
|
|
if (!isset($data['$id'])) {
|
|
|
|
|
throw new \Exception('API response missing ID field');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($extractSession) {
|
|
|
|
|
$headers = $response->getHeaders();
|
|
|
|
|
$setCookie = $headers['set-cookie'] ?? $headers['Set-Cookie'] ?? null;
|
|
|
|
|
|
2026-01-26 12:18:08 +00:00
|
|
|
if (!$setCookie || !preg_match(self::PATTERN_SESSION_COOKIE, $setCookie, $matches)) {
|
2026-01-24 14:39:56 +00:00
|
|
|
throw new \Exception('Session created but no cookie found');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'id' => $data['$id'],
|
2026-03-12 11:32:40 +00:00
|
|
|
'secret' => urldecode($matches[1]),
|
2026-01-24 14:39:56 +00:00
|
|
|
'expire' => $data['expire'] ?? null
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $data['$id'];
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 11:00:51 +00:00
|
|
|
private function buildStepMessages(bool $isUpgrade): array
|
|
|
|
|
{
|
|
|
|
|
$isUpgradeLabel = $isUpgrade ? 'updated' : 'created';
|
|
|
|
|
$verbs = [
|
|
|
|
|
InstallerServer::STEP_CONFIG_FILES => $isUpgrade ? 'Updating' : 'Creating',
|
|
|
|
|
InstallerServer::STEP_DOCKER_COMPOSE => $isUpgrade ? 'Updating' : 'Generating',
|
|
|
|
|
InstallerServer::STEP_ENV_VARS => $isUpgrade ? 'Updating' : 'Configuring',
|
|
|
|
|
InstallerServer::STEP_DOCKER_CONTAINERS => $isUpgrade ? 'Restarting' : 'Starting',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
InstallerServer::STEP_CONFIG_FILES => [
|
|
|
|
|
'start' => $verbs[InstallerServer::STEP_CONFIG_FILES] . ' configuration files...',
|
|
|
|
|
'done' => 'Configuration files ' . $isUpgradeLabel,
|
|
|
|
|
],
|
|
|
|
|
InstallerServer::STEP_DOCKER_COMPOSE => [
|
|
|
|
|
'start' => $verbs[InstallerServer::STEP_DOCKER_COMPOSE] . ' Docker Compose file...',
|
|
|
|
|
'done' => 'Docker Compose file ' . $isUpgradeLabel,
|
|
|
|
|
],
|
|
|
|
|
InstallerServer::STEP_ENV_VARS => [
|
|
|
|
|
'start' => $verbs[InstallerServer::STEP_ENV_VARS] . ' environment variables...',
|
|
|
|
|
'done' => 'Environment variables ' . $isUpgradeLabel,
|
|
|
|
|
],
|
|
|
|
|
InstallerServer::STEP_DOCKER_CONTAINERS => [
|
|
|
|
|
'start' => $verbs[InstallerServer::STEP_DOCKER_CONTAINERS] . ' Docker containers...',
|
|
|
|
|
'done' => $isUpgrade ? 'Docker containers restarted' : 'Docker containers started',
|
|
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 14:39:56 +00:00
|
|
|
private function writeComposeFile(View $template): void
|
2026-01-24 11:00:51 +00:00
|
|
|
{
|
|
|
|
|
$composeFileName = $this->getComposeFileName();
|
|
|
|
|
$targetPath = $this->path . '/' . $composeFileName;
|
|
|
|
|
$renderedContent = $template->render(false);
|
|
|
|
|
|
|
|
|
|
$result = @file_put_contents($targetPath, $renderedContent);
|
|
|
|
|
if ($result === false) {
|
|
|
|
|
$lastError = error_get_last();
|
|
|
|
|
$errorMsg = $lastError ? $lastError['message'] : 'Unknown error';
|
|
|
|
|
throw new \Exception('Failed to save Docker Compose file: ' . $errorMsg . ' (path: ' . $targetPath . ')');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 14:39:56 +00:00
|
|
|
private function writeEnvFile(View $template): void
|
2026-01-24 11:00:51 +00:00
|
|
|
{
|
|
|
|
|
$envFileName = $this->getEnvFileName();
|
|
|
|
|
if (!\file_put_contents($this->path . '/' . $envFileName, $template->render(false))) {
|
|
|
|
|
throw new \Exception('Failed to save environment variables file');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function copyMongoEntrypointIfNeeded(): void
|
|
|
|
|
{
|
|
|
|
|
$mongoEntrypoint = $this->buildFromProjectPath('/mongo-entrypoint.sh');
|
|
|
|
|
|
|
|
|
|
if (file_exists($mongoEntrypoint)) {
|
|
|
|
|
// Always use container path for file operations
|
|
|
|
|
copy($mongoEntrypoint, $this->path . '/mongo-entrypoint.sh');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 08:25:57 +00:00
|
|
|
protected function runDockerCompose(array $input, bool $isLocalInstall, bool $useExistingConfig, bool $isCLI, ?callable $progress = null, bool $isUpgrade = false): void
|
2026-01-24 11:00:51 +00:00
|
|
|
{
|
|
|
|
|
$env = '';
|
|
|
|
|
if (!$useExistingConfig) {
|
|
|
|
|
foreach ($input as $key => $value) {
|
|
|
|
|
if ($value === null || $value === '') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-01-26 12:18:08 +00:00
|
|
|
if (!preg_match(self::PATTERN_ENV_VAR_NAME, $key)) {
|
2026-01-24 14:39:56 +00:00
|
|
|
throw new \Exception("Invalid environment variable name: $key");
|
2026-01-24 11:00:51 +00:00
|
|
|
}
|
|
|
|
|
$env .= $key . '=' . \escapeshellarg((string) $value) . ' ';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($isCLI) {
|
|
|
|
|
Console::log("Running \"docker compose up -d --remove-orphans --renew-anon-volumes\"");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$composeFileName = $this->getComposeFileName();
|
|
|
|
|
$composeFile = $this->path . '/' . $composeFileName;
|
|
|
|
|
|
|
|
|
|
$command = [
|
|
|
|
|
'docker',
|
|
|
|
|
'compose',
|
|
|
|
|
'-f',
|
|
|
|
|
$composeFile,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if ($isLocalInstall) {
|
|
|
|
|
$command[] = '--project-name';
|
|
|
|
|
$command[] = 'appwrite';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$command[] = '--project-directory';
|
|
|
|
|
$command[] = $this->path;
|
|
|
|
|
$command[] = 'up';
|
|
|
|
|
$command[] = '-d';
|
|
|
|
|
$command[] = '--remove-orphans';
|
|
|
|
|
$command[] = '--renew-anon-volumes';
|
2026-03-24 08:25:57 +00:00
|
|
|
$commandLine = $env . implode(' ', array_map(escapeshellarg(...), $command));
|
|
|
|
|
|
|
|
|
|
if ($progress) {
|
|
|
|
|
$totalServices = $this->countComposeServices($composeFile);
|
2026-03-24 10:53:56 +00:00
|
|
|
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) {
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-24 08:25:57 +00:00
|
|
|
$result = $this->execWithContainerProgress($commandLine, $totalServices, $progress, $isUpgrade);
|
|
|
|
|
$output = $result['output'];
|
|
|
|
|
$exit = $result['exit'];
|
|
|
|
|
} else {
|
|
|
|
|
\exec($commandLine . ' 2>&1', $output, $exit);
|
|
|
|
|
}
|
2026-01-24 11:00:51 +00:00
|
|
|
|
|
|
|
|
if ($exit !== 0) {
|
|
|
|
|
$message = trim(implode("\n", $output));
|
|
|
|
|
$previous = $message !== '' ? new \RuntimeException($message) : null;
|
|
|
|
|
throw new \RuntimeException('Failed to start containers', 0, $previous);
|
|
|
|
|
}
|
|
|
|
|
if ($isLocalInstall && $isCLI && !empty($output)) {
|
|
|
|
|
Console::log(implode("\n", $output));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 08:25:57 +00:00
|
|
|
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];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 12:05:47 +00:00
|
|
|
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) {
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-24 08:25:57 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-24 12:05:47 +00:00
|
|
|
|
|
|
|
|
if (!$status['running'] && ($changed === 0 || feof($pipes[1]))) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($buffer !== '') {
|
|
|
|
|
$output[] = rtrim($buffer, "\r\n");
|
2026-03-24 08:25:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fclose($pipes[1]);
|
2026-03-24 10:53:56 +00:00
|
|
|
|
|
|
|
|
$exit = $this->procCloseWithTimeout($process, self::PROC_CLOSE_TIMEOUT_SECONDS);
|
2026-03-24 08:25:57 +00:00
|
|
|
|
|
|
|
|
return ['output' => $output, 'exit' => $exit];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 10:53:56 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 11:00:51 +00:00
|
|
|
protected function isLocalInstall(): bool
|
|
|
|
|
{
|
|
|
|
|
if ($this->isLocalInstall === null) {
|
|
|
|
|
$config = $this->readInstallerConfig();
|
|
|
|
|
$this->isLocalInstall = !empty($config['isLocal']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->isLocalInstall;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function readInstallerConfig(): array
|
|
|
|
|
{
|
|
|
|
|
if ($this->installerConfig !== null) {
|
|
|
|
|
return $this->installerConfig;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->installerConfig = [];
|
|
|
|
|
$decodeConfig = static function (string $json): ?array {
|
|
|
|
|
$decoded = json_decode($json, true);
|
|
|
|
|
return is_array($decoded) ? $decoded : null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
$json = getenv('APPWRITE_INSTALLER_CONFIG');
|
|
|
|
|
$path = InstallerServer::INSTALLER_CONFIG_FILE;
|
|
|
|
|
$fileJson = file_exists($path) ? file_get_contents($path) : null;
|
|
|
|
|
|
|
|
|
|
foreach ([$json, $fileJson] as $candidate) {
|
|
|
|
|
if (!is_string($candidate) || $candidate === '') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$decoded = $decodeConfig($candidate);
|
|
|
|
|
if ($decoded !== null) {
|
|
|
|
|
$this->installerConfig = $decoded;
|
|
|
|
|
return $this->installerConfig;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->installerConfig;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function getInstallerHostPath(): string
|
|
|
|
|
{
|
|
|
|
|
$config = $this->readInstallerConfig();
|
|
|
|
|
if (!empty($config['hostPath'])) {
|
|
|
|
|
return (string) $config['hostPath'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$cwd = getcwd();
|
|
|
|
|
return $cwd !== false ? $cwd : '.';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function buildFromProjectPath(string $suffix): string
|
|
|
|
|
{
|
|
|
|
|
if ($suffix !== '' && $suffix[0] !== '/') {
|
|
|
|
|
$suffix = '/' . $suffix;
|
|
|
|
|
}
|
|
|
|
|
return dirname(__DIR__, 4) . $suffix;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function applyLocalPaths(bool $isLocalInstall, bool $force = false): void
|
|
|
|
|
{
|
|
|
|
|
if (!$isLocalInstall) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!$force && $this->hostPath !== '') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
$this->path = '/usr/src/code';
|
|
|
|
|
$this->hostPath = $this->getInstallerHostPath();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 00:52:58 +00:00
|
|
|
/**
|
|
|
|
|
* Check if any installer-specific CLI params were explicitly passed.
|
|
|
|
|
* When params like --database or --http-port are provided, the user
|
|
|
|
|
* intends to run in CLI mode rather than launching the web installer.
|
|
|
|
|
*/
|
|
|
|
|
private function hasExplicitCliParams(): bool
|
|
|
|
|
{
|
|
|
|
|
$argv = $_SERVER['argv'] ?? [];
|
|
|
|
|
foreach ($argv as $arg) {
|
2026-04-01 01:07:59 +00:00
|
|
|
if (\str_starts_with($arg, '--')) {
|
2026-03-25 00:52:58 +00:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 11:39:17 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 11:00:51 +00:00
|
|
|
protected function readExistingCompose(): string
|
|
|
|
|
{
|
|
|
|
|
$composeFile = $this->path . '/' . $this->getComposeFileName();
|
|
|
|
|
$data = @file_get_contents($composeFile);
|
|
|
|
|
return !empty($data) ? $data : '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function generatePasswordValue(string $varName, Password $password): string
|
|
|
|
|
{
|
|
|
|
|
$value = $password->generate();
|
2026-01-26 12:18:08 +00:00
|
|
|
if (!\preg_match(self::PATTERN_DB_PASSWORD_VAR, $varName)) {
|
2026-01-24 11:00:51 +00:00
|
|
|
return $value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return rtrim(strtr(base64_encode(hash('sha256', $value, true)), '+/', '-_'), '=');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function getComposeFileName(): string
|
|
|
|
|
{
|
|
|
|
|
return $this->isLocalInstall() ? 'docker-compose.web-installer.yml' : 'docker-compose.yml';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function getEnvFileName(): string
|
|
|
|
|
{
|
|
|
|
|
return $this->isLocalInstall() ? '.env.web-installer' : '.env';
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 06:03:05 +00:00
|
|
|
private function isInstallationComplete(int $port): bool
|
|
|
|
|
{
|
2026-03-11 01:58:57 +00:00
|
|
|
$maxAttempts = 7200; // 2 hours maximum
|
|
|
|
|
$attempt = 0;
|
|
|
|
|
while ($attempt < $maxAttempts) {
|
2026-01-25 06:03:05 +00:00
|
|
|
if (file_exists(InstallerServer::INSTALLER_COMPLETE_FILE)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
$handle = @fsockopen('localhost', $port, $errno, $errstr, 1);
|
|
|
|
|
if ($handle === false) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
\fclose($handle);
|
|
|
|
|
\sleep(1);
|
2026-03-11 01:58:57 +00:00
|
|
|
$attempt++;
|
2026-01-25 06:03:05 +00:00
|
|
|
}
|
2026-03-11 01:58:57 +00:00
|
|
|
return false;
|
2026-01-25 06:03:05 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-24 11:00:51 +00:00
|
|
|
private function waitForWebServer(int $port): bool
|
|
|
|
|
{
|
|
|
|
|
for ($attempt = 0; $attempt < self::WEB_SERVER_CHECK_ATTEMPTS; $attempt++) {
|
|
|
|
|
$handle = @fsockopen('localhost', $port, $errno, $errstr, 1);
|
|
|
|
|
if ($handle !== false) {
|
|
|
|
|
\fclose($handle);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
\sleep(self::WEB_SERVER_CHECK_DELAY_SECONDS);
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2022-07-14 02:04:31 +00:00
|
|
|
}
|