Commit c491f9b1 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Refactor to shared PostgreSQL: one PG instance, many databases per tenant

Instead of creating a separate PostgreSQL container per El Captain instance,
all instances now share the existing `elcaptainsportsonlydb` container.
Provisioner creates a dedicated database + user per instance via the
`pg_admin` superuser connection.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 58845233
...@@ -30,6 +30,16 @@ FILESYSTEM_DISK=local ...@@ -30,6 +30,16 @@ FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=database
CACHE_STORE=database CACHE_STORE=database
# Shared PostgreSQL (admin connection — creates DBs/users for instances)
PG_ADMIN_HOST=srv-captain--elcaptainsportsonlydb
PG_ADMIN_PORT=5432
PG_ADMIN_DATABASE=postgres
PG_ADMIN_USERNAME=postgres
PG_ADMIN_PASSWORD=
SHARED_PG_SERVICE=elcaptainsportsonlydb
SHARED_PG_PORT=5432
SHARED_PG_ADMIN_CONNECTION=pg_admin
# CapRover API (runs on same server) # CapRover API (runs on same server)
CAPROVER_API_URL=http://captain-captain:3000 CAPROVER_API_URL=http://captain-captain:3000
CAPROVER_PASSWORD= CAPROVER_PASSWORD=
......
...@@ -18,12 +18,13 @@ public function pullFeesForDate(Instance $instance, string $date): ?PlatformFee ...@@ -18,12 +18,13 @@ public function pullFeesForDate(Instance $instance, string $date): ?PlatformFee
try { try {
$dbPassword = Crypt::decryptString($instance->db_password_encrypted); $dbPassword = Crypt::decryptString($instance->db_password_encrypted);
$rootDomain = config('manager.caprover_root_domain'); $sharedPgService = config('manager.shared_pg_service');
$sharedPgPort = config('manager.shared_pg_port');
$config = [ $config = [
'driver' => 'pgsql', 'driver' => 'pgsql',
'host' => config('manager.caprover_server_ip'), 'host' => "srv-captain--{$sharedPgService}",
'port' => $this->resolveDbPort($instance), 'port' => $sharedPgPort,
'database' => $instance->db_name, 'database' => $instance->db_name,
'username' => $instance->db_user, 'username' => $instance->db_user,
'password' => $dbPassword, 'password' => $dbPassword,
...@@ -115,10 +116,4 @@ public function verifyReport(Instance $instance, string $date): bool ...@@ -115,10 +116,4 @@ public function verifyReport(Instance $instance, string $date): bool
return $match; return $match;
} }
private function resolveDbPort(Instance $instance): int
{
// CapRover DB containers expose PG on internal docker network port 5432
// We access via the server's mapped port or internal network
return 5432;
}
} }
...@@ -20,13 +20,12 @@ public function __construct( ...@@ -20,13 +20,12 @@ public function __construct(
public function provision(Client $client, Plan $plan, array $data): Instance public function provision(Client $client, Plan $plan, array $data): Instance
{ {
$appName = $this->generateAppName($data['academy_name_en']); $appName = $this->generateAppName($data['academy_name_en']);
$dbAppName = "{$appName}-db";
$dbName = str_replace('-', '_', $appName); $dbName = str_replace('-', '_', $appName);
$dbUser = $dbName; $dbUser = $dbName;
$dbPassword = Str::random(24); $dbPassword = Str::random(24);
$adminPassword = $data['admin_password'] ?? Str::random(12); $adminPassword = $data['admin_password'] ?? Str::random(12);
return DB::transaction(function () use ($client, $plan, $data, $appName, $dbAppName, $dbName, $dbUser, $dbPassword, $adminPassword) { return DB::transaction(function () use ($client, $plan, $data, $appName, $dbName, $dbUser, $dbPassword, $adminPassword) {
$instance = Instance::create([ $instance = Instance::create([
'client_id' => $client->id, 'client_id' => $client->id,
'plan_id' => $plan->id, 'plan_id' => $plan->id,
...@@ -35,7 +34,7 @@ public function provision(Client $client, Plan $plan, array $data): Instance ...@@ -35,7 +34,7 @@ public function provision(Client $client, Plan $plan, array $data): Instance
'academy_name_en' => $data['academy_name_en'], 'academy_name_en' => $data['academy_name_en'],
'admin_email' => $data['admin_email'], 'admin_email' => $data['admin_email'],
'status' => 'provisioning', 'status' => 'provisioning',
'db_app_name' => $dbAppName, 'db_app_name' => config('manager.shared_pg_service'),
'db_name' => $dbName, 'db_name' => $dbName,
'db_user' => $dbUser, 'db_user' => $dbUser,
'db_password_encrypted' => Crypt::encryptString($dbPassword), 'db_password_encrypted' => Crypt::encryptString($dbPassword),
...@@ -45,7 +44,8 @@ public function provision(Client $client, Plan $plan, array $data): Instance ...@@ -45,7 +44,8 @@ public function provision(Client $client, Plan $plan, array $data): Instance
]); ]);
try { try {
$this->deployToCapRover($instance, $dbPassword, $adminPassword); $this->createDatabase($dbName, $dbUser, $dbPassword);
$this->deployApp($instance, $dbPassword, $adminPassword);
$instance->update(['status' => $plan->trial_days > 0 ? 'trial' : 'active']); $instance->update(['status' => $plan->trial_days > 0 ? 'trial' : 'active']);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$instance->update([ $instance->update([
...@@ -64,28 +64,27 @@ public function provision(Client $client, Plan $plan, array $data): Instance ...@@ -64,28 +64,27 @@ public function provision(Client $client, Plan $plan, array $data): Instance
}); });
} }
private function deployToCapRover(Instance $instance, string $dbPassword, string $adminPassword): void private function createDatabase(string $dbName, string $dbUser, string $dbPassword): void
{
$adminConn = config('manager.shared_pg_admin_connection');
$escapedPassword = str_replace("'", "''", $dbPassword);
DB::connection($adminConn)->statement("CREATE USER \"{$dbUser}\" WITH PASSWORD '{$escapedPassword}'");
DB::connection($adminConn)->statement("CREATE DATABASE \"{$dbName}\" OWNER \"{$dbUser}\"");
DB::connection($adminConn)->statement("GRANT ALL PRIVILEGES ON DATABASE \"{$dbName}\" TO \"{$dbUser}\"");
DB::connection($adminConn)->statement("ALTER USER \"{$dbUser}\" CREATEDB");
}
private function deployApp(Instance $instance, string $dbPassword, string $adminPassword): void
{ {
$rootDomain = config('manager.caprover_root_domain'); $rootDomain = config('manager.caprover_root_domain');
$gitRepoUrl = config('manager.elcaptain_git_repo'); $gitRepoUrl = config('manager.elcaptain_git_repo');
$gitUser = config('manager.elcaptain_git_user'); $gitUser = config('manager.elcaptain_git_user');
$gitPassword = config('manager.elcaptain_git_password'); $gitPassword = config('manager.elcaptain_git_password');
$sharedPgService = config('manager.shared_pg_service');
$sharedPgPort = config('manager.shared_pg_port');
// 1. Create the PostgreSQL database app // 1. Create the app on CapRover
$this->caprover->createApp($instance->db_app_name, true);
$this->caprover->deployImage($instance->db_app_name, 'postgres:16-alpine');
$this->caprover->setEnvVars($instance->db_app_name, [
'POSTGRES_DB' => $instance->db_name,
'POSTGRES_USER' => $instance->db_user,
'POSTGRES_PASSWORD' => $dbPassword,
]);
$this->caprover->addVolume(
$instance->db_app_name,
'/var/lib/postgresql/data',
"{$instance->db_app_name}-data"
);
// 2. Create the main app
$this->caprover->createApp($instance->app_name, true); $this->caprover->createApp($instance->app_name, true);
$this->caprover->setContainerHttpPort($instance->app_name, 80); $this->caprover->setContainerHttpPort($instance->app_name, 80);
$this->caprover->addVolume( $this->caprover->addVolume(
...@@ -94,7 +93,7 @@ private function deployToCapRover(Instance $instance, string $dbPassword, string ...@@ -94,7 +93,7 @@ private function deployToCapRover(Instance $instance, string $dbPassword, string
"{$instance->app_name}-storage" "{$instance->app_name}-storage"
); );
// 3. Set environment variables // 2. Set environment variables (points to SHARED PG)
$this->caprover->setEnvVars($instance->app_name, [ $this->caprover->setEnvVars($instance->app_name, [
'APP_NAME' => $instance->academy_name_ar, 'APP_NAME' => $instance->academy_name_ar,
'APP_ENV' => 'production', 'APP_ENV' => 'production',
...@@ -109,8 +108,8 @@ private function deployToCapRover(Instance $instance, string $dbPassword, string ...@@ -109,8 +108,8 @@ private function deployToCapRover(Instance $instance, string $dbPassword, string
'LOG_STACK' => 'single', 'LOG_STACK' => 'single',
'LOG_LEVEL' => 'error', 'LOG_LEVEL' => 'error',
'DB_CONNECTION' => 'pgsql', 'DB_CONNECTION' => 'pgsql',
'DB_HOST' => "srv-captain--{$instance->db_app_name}", 'DB_HOST' => "srv-captain--{$sharedPgService}",
'DB_PORT' => '5432', 'DB_PORT' => (string) $sharedPgPort,
'DB_DATABASE' => $instance->db_name, 'DB_DATABASE' => $instance->db_name,
'DB_USERNAME' => $instance->db_user, 'DB_USERNAME' => $instance->db_user,
'DB_PASSWORD' => $dbPassword, 'DB_PASSWORD' => $dbPassword,
...@@ -132,7 +131,7 @@ private function deployToCapRover(Instance $instance, string $dbPassword, string ...@@ -132,7 +131,7 @@ private function deployToCapRover(Instance $instance, string $dbPassword, string
'MAIL_VERIFY_PEER' => 'false', 'MAIL_VERIFY_PEER' => 'false',
]); ]);
// 4. Deploy from git repo // 3. Deploy from git repo (builds the Docker image on CapRover)
$this->caprover->deployFromGit( $this->caprover->deployFromGit(
$instance->app_name, $instance->app_name,
$gitRepoUrl, $gitRepoUrl,
...@@ -141,13 +140,13 @@ private function deployToCapRover(Instance $instance, string $dbPassword, string ...@@ -141,13 +140,13 @@ private function deployToCapRover(Instance $instance, string $dbPassword, string
$gitPassword, $gitPassword,
); );
// 5. Enable SSL (wildcard already covers *.caprover.al-arcade.com) // 4. Enable SSL (wildcard already covers *.caprover.al-arcade.com)
sleep(2); sleep(2);
try { try {
$this->caprover->enableSsl($instance->app_name); $this->caprover->enableSsl($instance->app_name);
$this->caprover->forceSsl($instance->app_name); $this->caprover->forceSsl($instance->app_name);
} catch (\Throwable $e) { } catch (\Throwable $e) {
// SSL might fail if wildcard covers it — not critical // Wildcard cert handles this automatically — not critical
} }
} }
...@@ -175,15 +174,20 @@ public function resume(Instance $instance): void ...@@ -175,15 +174,20 @@ public function resume(Instance $instance): void
public function delete(Instance $instance): void public function delete(Instance $instance): void
{ {
// Remove the CapRover app
try { try {
$this->caprover->deleteApp($instance->app_name); $this->caprover->deleteApp($instance->app_name);
} catch (\Throwable $e) { } catch (\Throwable $e) {
// App might already be gone // App might already be gone
} }
// Drop the database and user from shared PG
try { try {
$this->caprover->deleteApp($instance->db_app_name); $adminConn = config('manager.shared_pg_admin_connection');
DB::connection($adminConn)->statement("DROP DATABASE IF EXISTS \"{$instance->db_name}\"");
DB::connection($adminConn)->statement("DROP USER IF EXISTS \"{$instance->db_user}\"");
} catch (\Throwable $e) { } catch (\Throwable $e) {
// DB app might already be gone // Non-critical — log it
} }
AuditLog::record('deleted', $instance); AuditLog::record('deleted', $instance);
......
...@@ -99,6 +99,20 @@ ...@@ -99,6 +99,20 @@
'sslmode' => env('DB_SSLMODE', 'prefer'), 'sslmode' => env('DB_SSLMODE', 'prefer'),
], ],
'pg_admin' => [
'driver' => 'pgsql',
'host' => env('PG_ADMIN_HOST', 'srv-captain--elcaptainsportsonlydb'),
'port' => env('PG_ADMIN_PORT', '5432'),
'database' => env('PG_ADMIN_DATABASE', 'postgres'),
'username' => env('PG_ADMIN_USERNAME', 'postgres'),
'password' => env('PG_ADMIN_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('PG_ADMIN_SSLMODE', 'prefer'),
],
'sqlsrv' => [ 'sqlsrv' => [
'driver' => 'sqlsrv', 'driver' => 'sqlsrv',
'url' => env('DB_URL'), 'url' => env('DB_URL'),
......
...@@ -7,13 +7,18 @@ ...@@ -7,13 +7,18 @@
'caprover_root_domain' => env('CAPROVER_ROOT_DOMAIN', 'caprover.al-arcade.com'), 'caprover_root_domain' => env('CAPROVER_ROOT_DOMAIN', 'caprover.al-arcade.com'),
'caprover_server_ip' => env('CAPROVER_SERVER_IP', '18.192.166.221'), 'caprover_server_ip' => env('CAPROVER_SERVER_IP', '18.192.166.221'),
// Shared PostgreSQL (one PG instance, many databases)
'shared_pg_service' => env('SHARED_PG_SERVICE', 'elcaptainsportsonlydb'),
'shared_pg_port' => env('SHARED_PG_PORT', 5432),
'shared_pg_admin_connection' => env('SHARED_PG_ADMIN_CONNECTION', 'pg_admin'),
// El Captain Git Repo (for deploying instances from source) // El Captain Git Repo (for deploying instances from source)
'elcaptain_git_repo' => env('ELCAPTAIN_GIT_REPO', 'https://gitlab.caprover.al-arcade.com/root/el-captain-sports-management.git'), 'elcaptain_git_repo' => env('ELCAPTAIN_GIT_REPO', 'https://gitlab.caprover.al-arcade.com/root/el-captain-sports-management.git'),
'elcaptain_git_user' => env('ELCAPTAIN_GIT_USER', 'root'), 'elcaptain_git_user' => env('ELCAPTAIN_GIT_USER', 'root'),
'elcaptain_git_password' => env('ELCAPTAIN_GIT_PASSWORD', ''), 'elcaptain_git_password' => env('ELCAPTAIN_GIT_PASSWORD', ''),
// Billing // Billing
'billing_day' => env('BILLING_DAY', 1), // day of month billing periods start 'billing_day' => env('BILLING_DAY', 1),
'auto_suspend_enabled' => env('AUTO_SUSPEND_ENABLED', true), 'auto_suspend_enabled' => env('AUTO_SUSPEND_ENABLED', true),
'warning_email_days' => [3, 7], // days after due date to send warnings 'warning_email_days' => [3, 7],
]; ];
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment