Deploy Ladill Hosting / deploy (push) Failing after 17s
Shared web hosting extracted from the platform monolith, with CI deploy to /var/www/ladill-hosting matching Bird/Domains/Email. Co-authored-by: Cursor <cursoragent@cursor.com>
465 lines
19 KiB
PHP
465 lines
19 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Hosting;
|
|
|
|
use App\Models\HostedSite;
|
|
use App\Models\HostedDatabase;
|
|
use App\Models\AppInstallation;
|
|
use App\Services\Hosting\Contracts\AppInstallerInterface;
|
|
use App\Services\Hosting\Providers\SharedNodeProvider;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Str;
|
|
|
|
class AppInstaller implements AppInstallerInterface
|
|
{
|
|
private SharedNodeProvider $nodeProvider;
|
|
|
|
public function __construct(SharedNodeProvider $nodeProvider)
|
|
{
|
|
$this->nodeProvider = $nodeProvider;
|
|
}
|
|
|
|
public function getSupportedApps(): array
|
|
{
|
|
return [
|
|
'wordpress' => [
|
|
'name' => 'WordPress',
|
|
'description' => 'Popular blogging and CMS platform',
|
|
'icon' => 'wordpress',
|
|
'min_php' => '7.4',
|
|
'recommended_php' => '8.2',
|
|
],
|
|
'joomla' => [
|
|
'name' => 'Joomla',
|
|
'description' => 'Flexible content management system',
|
|
'icon' => 'joomla',
|
|
'min_php' => '7.4',
|
|
'recommended_php' => '8.1',
|
|
],
|
|
'drupal' => [
|
|
'name' => 'Drupal',
|
|
'description' => 'Enterprise-grade CMS',
|
|
'icon' => 'drupal',
|
|
'min_php' => '8.1',
|
|
'recommended_php' => '8.2',
|
|
],
|
|
'opencart' => [
|
|
'name' => 'OpenCart',
|
|
'description' => 'E-commerce platform',
|
|
'icon' => 'opencart',
|
|
'min_php' => '8.0',
|
|
'recommended_php' => '8.1',
|
|
],
|
|
'magento' => [
|
|
'name' => 'Magento',
|
|
'description' => 'Enterprise e-commerce platform',
|
|
'icon' => 'magento',
|
|
'min_php' => '8.1',
|
|
'recommended_php' => '8.2',
|
|
'min_ram_mb' => 2048,
|
|
],
|
|
];
|
|
}
|
|
|
|
public function getAppVersions(string $appType): array
|
|
{
|
|
return match ($appType) {
|
|
'wordpress' => ['6.4', '6.3', '6.2'],
|
|
'joomla' => ['5.0', '4.4', '4.3'],
|
|
'drupal' => ['10.2', '10.1', '10.0'],
|
|
'opencart' => ['4.0', '3.0'],
|
|
'magento' => ['2.4.6', '2.4.5'],
|
|
default => [],
|
|
};
|
|
}
|
|
|
|
public function install(HostedSite $site, string $appType, array $config): AppInstallation
|
|
{
|
|
$account = $site->account;
|
|
$supportedApps = $this->getSupportedApps();
|
|
|
|
if (!isset($supportedApps[$appType])) {
|
|
throw new \InvalidArgumentException("Unsupported app type: {$appType}");
|
|
}
|
|
|
|
// Create database for the app
|
|
$dbName = $account->username . '_' . Str::random(4);
|
|
$dbUser = $account->username . '_' . Str::random(4);
|
|
$dbPassword = Str::password(16);
|
|
|
|
$database = HostedDatabase::create([
|
|
'hosting_account_id' => $account->id,
|
|
'hosted_site_id' => $site->id,
|
|
'name' => $dbName,
|
|
'username' => $dbUser,
|
|
'password_encrypted' => encrypt($dbPassword),
|
|
'type' => 'mysql',
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$this->nodeProvider->createDatabase($database, $dbPassword);
|
|
|
|
// Create installation record
|
|
$installation = AppInstallation::create([
|
|
'hosted_site_id' => $site->id,
|
|
'app_type' => $appType,
|
|
'app_version' => $config['version'] ?? $this->getAppVersions($appType)[0],
|
|
'admin_username' => $config['admin_username'] ?? 'admin',
|
|
'admin_email' => $config['admin_email'] ?? $account->user->email,
|
|
'status' => 'installing',
|
|
'config' => [
|
|
'db_name' => $dbName,
|
|
'db_user' => $dbUser,
|
|
'site_title' => $config['site_title'] ?? $site->domain,
|
|
],
|
|
]);
|
|
|
|
try {
|
|
$result = match ($appType) {
|
|
'wordpress' => $this->installWordPress($site, $installation, $database, $dbPassword, $config),
|
|
'joomla' => $this->installJoomla($site, $installation, $database, $dbPassword, $config),
|
|
'drupal' => $this->installDrupal($site, $installation, $database, $dbPassword, $config),
|
|
'opencart' => $this->installOpenCart($site, $installation, $database, $dbPassword, $config),
|
|
'magento' => $this->installMagento($site, $installation, $database, $dbPassword, $config),
|
|
default => throw new \InvalidArgumentException("No installer for: {$appType}"),
|
|
};
|
|
|
|
$installation->update([
|
|
'status' => 'active',
|
|
'admin_path' => $result['admin_path'] ?? null,
|
|
'installed_at' => now(),
|
|
]);
|
|
|
|
$site->update([
|
|
'installed_app' => $appType,
|
|
'installed_app_version' => $installation->app_version,
|
|
]);
|
|
|
|
} catch (\Exception $e) {
|
|
Log::error("App installation failed", [
|
|
'app' => $appType,
|
|
'site' => $site->domain,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
$installation->update([
|
|
'status' => 'failed',
|
|
'config' => array_merge($installation->config ?? [], ['error' => $e->getMessage()]),
|
|
]);
|
|
|
|
throw $e;
|
|
}
|
|
|
|
return $installation;
|
|
}
|
|
|
|
public function uninstall(AppInstallation $installation): bool
|
|
{
|
|
$site = $installation->site;
|
|
$account = $site->account;
|
|
$appType = $installation->app_type;
|
|
|
|
try {
|
|
// For Node.js/Python apps, stop and remove the systemd service first
|
|
if (in_array($appType, ['nodejs', 'python'], true)) {
|
|
try {
|
|
$this->nodeProvider->removeAppService($site);
|
|
} catch (\Exception $e) {
|
|
Log::warning("Failed to remove app service during uninstall: " . $e->getMessage());
|
|
}
|
|
|
|
// Node.js specific cleanup
|
|
if ($appType === 'nodejs') {
|
|
$this->nodeProvider->executeCommand(
|
|
$account,
|
|
"rm -rf {$site->document_root}/node_modules {$site->document_root}/package.json {$site->document_root}/package-lock.json 2>&1 || true"
|
|
);
|
|
}
|
|
|
|
// Python specific cleanup
|
|
if ($appType === 'python') {
|
|
$this->nodeProvider->executeCommand(
|
|
$account,
|
|
"rm -rf {$site->document_root}/venv {$site->document_root}/__pycache__ {$site->document_root}/.venv 2>&1 || true"
|
|
);
|
|
$this->nodeProvider->executeCommand(
|
|
$account,
|
|
"find {$site->document_root} -name '*.pyc' -delete 2>&1 || true"
|
|
);
|
|
}
|
|
}
|
|
|
|
// For apps with subdirectories (Drupal with web/, Laravel with public/, Magento with pub/)
|
|
// we need to clean the parent directory too
|
|
$docRoot = $site->document_root;
|
|
if (in_array($appType, ['drupal', 'laravel', 'magento'], true)) {
|
|
// Try to clean up parent directory if it's a subdirectory installation
|
|
$parentDir = dirname($docRoot);
|
|
if ($parentDir !== "/home/{$account->username}" && $parentDir !== "/home/{$account->username}/public_html") {
|
|
$this->nodeProvider->executeCommandAsRoot(
|
|
$account,
|
|
"rm -rf " . escapeshellarg($parentDir) . " 2>&1 || true"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Remove all files from document root (use root to handle permission issues)
|
|
$escapedDocRoot = escapeshellarg($docRoot);
|
|
$this->nodeProvider->executeCommandAsRoot(
|
|
$account,
|
|
"rm -rf {$escapedDocRoot}/* {$escapedDocRoot}/.[!.]* {$escapedDocRoot}/..?* 2>&1 || true"
|
|
);
|
|
|
|
// Drop database and database user if exists
|
|
$dbName = $installation->config['db_name'] ?? null;
|
|
if ($dbName) {
|
|
$database = HostedDatabase::where('name', $dbName)->first();
|
|
if ($database) {
|
|
// Drop both database and user
|
|
$dbUser = $database->username;
|
|
$this->nodeProvider->executeCommandAsRoot(
|
|
$account,
|
|
"mysql -e \"DROP DATABASE IF EXISTS \`{$dbName}\`; DROP USER IF EXISTS '{$dbUser}'@'localhost';\" 2>&1 || true"
|
|
);
|
|
$database->delete();
|
|
} else {
|
|
// Database record not found, try cleanup anyway
|
|
$this->nodeProvider->executeCommandAsRoot(
|
|
$account,
|
|
"mysql -e \"DROP DATABASE IF EXISTS \`{$dbName}\`; DROP USER IF EXISTS '{$dbName}'@'localhost';\" 2>&1 || true"
|
|
);
|
|
}
|
|
}
|
|
|
|
$installation->update(['status' => 'removed']);
|
|
$site->update(['installed_app' => null, 'installed_app_version' => null]);
|
|
|
|
Log::info("App uninstalled successfully", [
|
|
'app' => $appType,
|
|
'site' => $site->domain,
|
|
'installation_id' => $installation->id,
|
|
]);
|
|
|
|
return true;
|
|
} catch (\Exception $e) {
|
|
Log::error("Failed to uninstall app: " . $e->getMessage(), [
|
|
'app' => $appType,
|
|
'site' => $site->domain,
|
|
'installation_id' => $installation->id,
|
|
]);
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
public function update(AppInstallation $installation, ?string $version = null): bool
|
|
{
|
|
$site = $installation->site;
|
|
$account = $site->account;
|
|
|
|
$installation->update(['status' => 'updating']);
|
|
|
|
try {
|
|
if ($installation->app_type === 'wordpress') {
|
|
$this->nodeProvider->executeCommand($account,
|
|
"cd {$site->document_root} && wp core update" . ($version ? " --version={$version}" : "")
|
|
);
|
|
$this->nodeProvider->executeCommand($account, "cd {$site->document_root} && wp plugin update --all");
|
|
$this->nodeProvider->executeCommand($account, "cd {$site->document_root} && wp theme update --all");
|
|
}
|
|
|
|
$installation->update([
|
|
'status' => 'active',
|
|
'app_version' => $version ?? $installation->app_version,
|
|
'last_updated_at' => now(),
|
|
]);
|
|
|
|
return true;
|
|
} catch (\Exception $e) {
|
|
$installation->update(['status' => 'active']);
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
public function getStatus(AppInstallation $installation): array
|
|
{
|
|
$site = $installation->site;
|
|
$account = $site->account;
|
|
|
|
$status = [
|
|
'app_type' => $installation->app_type,
|
|
'version' => $installation->app_version,
|
|
'status' => $installation->status,
|
|
'installed_at' => $installation->installed_at,
|
|
];
|
|
|
|
if ($installation->app_type === 'wordpress') {
|
|
$result = $this->nodeProvider->executeCommand($account,
|
|
"cd {$site->document_root} && wp core version 2>/dev/null"
|
|
);
|
|
$status['current_version'] = trim($result['output']);
|
|
}
|
|
|
|
return $status;
|
|
}
|
|
|
|
public function backup(AppInstallation $installation): string
|
|
{
|
|
$site = $installation->site;
|
|
$account = $site->account;
|
|
$backupName = "{$installation->app_type}_{$site->domain}_" . date('Y-m-d_His');
|
|
$backupPath = "/home/{$account->username}/backups/{$backupName}";
|
|
|
|
$this->nodeProvider->executeCommand($account, "mkdir -p /home/{$account->username}/backups");
|
|
|
|
// Backup files
|
|
$this->nodeProvider->executeCommand($account,
|
|
"tar -czf {$backupPath}_files.tar.gz -C {$site->document_root} ."
|
|
);
|
|
|
|
// Backup database
|
|
$dbName = $installation->config['db_name'] ?? null;
|
|
if ($dbName) {
|
|
$this->nodeProvider->executeCommand($account,
|
|
"mysqldump {$dbName} > {$backupPath}_db.sql"
|
|
);
|
|
}
|
|
|
|
return $backupPath;
|
|
}
|
|
|
|
public function restore(AppInstallation $installation, string $backupPath): bool
|
|
{
|
|
$site = $installation->site;
|
|
$account = $site->account;
|
|
|
|
// Restore files
|
|
$this->nodeProvider->executeCommand($account,
|
|
"rm -rf {$site->document_root}/* && tar -xzf {$backupPath}_files.tar.gz -C {$site->document_root}"
|
|
);
|
|
|
|
// Restore database
|
|
$dbName = $installation->config['db_name'] ?? null;
|
|
if ($dbName && file_exists("{$backupPath}_db.sql")) {
|
|
$this->nodeProvider->executeCommand($account,
|
|
"mysql {$dbName} < {$backupPath}_db.sql"
|
|
);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function installWordPress(HostedSite $site, AppInstallation $installation, HostedDatabase $database, string $dbPassword, array $config): array
|
|
{
|
|
$account = $site->account;
|
|
$docRoot = $site->document_root;
|
|
$adminPassword = $config['admin_password'] ?? Str::password(12);
|
|
|
|
$commands = [
|
|
"cd {$docRoot} && wp core download --version={$installation->app_version}",
|
|
"cd {$docRoot} && wp config create --dbname={$database->name} --dbuser={$database->username} --dbpass={$dbPassword} --dbhost=localhost",
|
|
"cd {$docRoot} && wp core install --url=https://{$site->domain} --title='" . addslashes($config['site_title'] ?? $site->domain) . "' --admin_user={$installation->admin_username} --admin_password={$adminPassword} --admin_email={$installation->admin_email}",
|
|
"cd {$docRoot} && wp rewrite structure '/%postname%/'",
|
|
"cd {$docRoot} && chmod -R 755 .",
|
|
"cd {$docRoot} && chmod -R 775 wp-content/uploads",
|
|
];
|
|
|
|
foreach ($commands as $cmd) {
|
|
$result = $this->nodeProvider->executeCommand($account, $cmd);
|
|
if ($result['exit_code'] !== 0) {
|
|
throw new \RuntimeException("WordPress install failed: " . $result['output']);
|
|
}
|
|
}
|
|
|
|
return [
|
|
'admin_path' => '/wp-admin',
|
|
'admin_password' => $adminPassword,
|
|
];
|
|
}
|
|
|
|
private function installJoomla(HostedSite $site, AppInstallation $installation, HostedDatabase $database, string $dbPassword, array $config): array
|
|
{
|
|
$account = $site->account;
|
|
$docRoot = $site->document_root;
|
|
$adminPassword = $config['admin_password'] ?? Str::password(12);
|
|
|
|
$commands = [
|
|
"cd {$docRoot} && curl -sL https://downloads.joomla.org/cms/joomla5/{$installation->app_version}/Joomla_{$installation->app_version}-Stable-Full_Package.zip -o joomla.zip",
|
|
"cd {$docRoot} && unzip -q joomla.zip && rm joomla.zip",
|
|
"cd {$docRoot} && php installation/joomla.php install --site-name='" . addslashes($config['site_title'] ?? $site->domain) . "' --admin-user={$installation->admin_username} --admin-username={$installation->admin_username} --admin-password={$adminPassword} --admin-email={$installation->admin_email} --db-type=mysqli --db-host=localhost --db-user={$database->username} --db-pass={$dbPassword} --db-name={$database->name} --db-prefix=jos_ --db-encryption=0",
|
|
"cd {$docRoot} && rm -rf installation",
|
|
"cd {$docRoot} && chmod -R 755 .",
|
|
"cd {$docRoot} && chmod 775 .",
|
|
"cd {$docRoot} && chmod -R 775 cache tmp administrator/cache administrator/logs",
|
|
"cd {$docRoot} && echo '<?php return [];' > administrator/cache/autoload_psr4.php && echo '<?php return [];' > cache/autoload_psr4.php",
|
|
];
|
|
|
|
foreach ($commands as $cmd) {
|
|
$this->nodeProvider->executeCommand($account, $cmd);
|
|
}
|
|
|
|
$this->nodeProvider->setWebServerGroupOwnership($account, $docRoot);
|
|
|
|
return ['admin_path' => '/administrator'];
|
|
}
|
|
|
|
private function installDrupal(HostedSite $site, AppInstallation $installation, HostedDatabase $database, string $dbPassword, array $config): array
|
|
{
|
|
$account = $site->account;
|
|
$docRoot = $site->document_root;
|
|
$adminPassword = $config['admin_password'] ?? Str::password(12);
|
|
|
|
$commands = [
|
|
"cd {$docRoot} && composer create-project drupal/recommended-project:{$installation->app_version} . --no-interaction",
|
|
"cd {$docRoot}/web && ../vendor/bin/drush site:install standard --db-url=mysql://{$database->username}:{$dbPassword}@localhost/{$database->name} --site-name='" . addslashes($config['site_title'] ?? $site->domain) . "' --account-name={$installation->admin_username} --account-pass={$adminPassword} --account-mail={$installation->admin_email} -y",
|
|
"cd {$docRoot} && chmod -R 755 .",
|
|
];
|
|
|
|
foreach ($commands as $cmd) {
|
|
$this->nodeProvider->executeCommand($account, $cmd);
|
|
}
|
|
|
|
return ['admin_path' => '/user/login'];
|
|
}
|
|
|
|
private function installOpenCart(HostedSite $site, AppInstallation $installation, HostedDatabase $database, string $dbPassword, array $config): array
|
|
{
|
|
$account = $site->account;
|
|
$docRoot = $site->document_root;
|
|
$adminPassword = $config['admin_password'] ?? Str::password(12);
|
|
|
|
$commands = [
|
|
"cd {$docRoot} && curl -sL https://github.com/opencart/opencart/releases/download/{$installation->app_version}.0.0/opencart-{$installation->app_version}.0.0.zip -o opencart.zip",
|
|
"cd {$docRoot} && unzip -q opencart.zip && mv upload/* . && rm -rf upload opencart.zip",
|
|
"cd {$docRoot} && php install/cli_install.php install --db_hostname localhost --db_username {$database->username} --db_password {$dbPassword} --db_database {$database->name} --username {$installation->admin_username} --password {$adminPassword} --email {$installation->admin_email} --http_server https://{$site->domain}/",
|
|
"cd {$docRoot} && rm -rf install",
|
|
"cd {$docRoot} && chmod -R 755 .",
|
|
];
|
|
|
|
foreach ($commands as $cmd) {
|
|
$this->nodeProvider->executeCommand($account, $cmd);
|
|
}
|
|
|
|
return ['admin_path' => '/admin'];
|
|
}
|
|
|
|
private function installMagento(HostedSite $site, AppInstallation $installation, HostedDatabase $database, string $dbPassword, array $config): array
|
|
{
|
|
$account = $site->account;
|
|
$docRoot = $site->document_root;
|
|
$adminPassword = $config['admin_password'] ?? Str::password(12);
|
|
|
|
$commands = [
|
|
"cd {$docRoot} && composer create-project --repository-url=https://repo.magento.com/ magento/project-community-edition={$installation->app_version} . --no-interaction",
|
|
"cd {$docRoot} && bin/magento setup:install --base-url=https://{$site->domain}/ --db-host=localhost --db-name={$database->name} --db-user={$database->username} --db-password={$dbPassword} --admin-firstname=Admin --admin-lastname=User --admin-email={$installation->admin_email} --admin-user={$installation->admin_username} --admin-password={$adminPassword} --language=en_US --currency=USD --timezone=UTC --use-rewrites=1",
|
|
"cd {$docRoot} && bin/magento deploy:mode:set production",
|
|
"cd {$docRoot} && chmod -R 755 .",
|
|
];
|
|
|
|
foreach ($commands as $cmd) {
|
|
$this->nodeProvider->executeCommand($account, $cmd);
|
|
}
|
|
|
|
return ['admin_path' => '/admin'];
|
|
}
|
|
}
|