Files
isaaccladandCursor e251a4cf60
Deploy Ladill Hosting / deploy (push) Failing after 17s
Initial Ladill Hosting app with Gitea deploy pipeline.
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>
2026-06-06 16:24:20 +00:00

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'];
}
}