Commit eb5a517f authored by Mahmoud Aglan's avatar Mahmoud Aglan

Fix instance provisioning: single API call + live progress UI

- Add configureAppFull() to CapRoverService that sends port, instanceCount,
  forceSsl, volumes, envVars, and appPushWebhook in ONE update call
  (CapRover wipes fields not included in each call)
- Rewrite InstanceProvisionerService::deployApp() to use configureAppFull()
- Add progress callback through provision() → deployApp() chain
- Add deploy progress panel in wizard (loading state + completion log)
- Fix provision() to not wrap in DB::transaction (was blocking progress)
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent eceda1f3
......@@ -37,6 +37,8 @@ class DeployInstanceWizard extends Component
public bool $deployed = false;
public ?string $deployError = null;
public ?string $instanceUrl = null;
public array $progressMessages = [];
public ?string $currentProgress = null;
public function nextStep(): void
{
......@@ -53,6 +55,8 @@ public function deploy(InstanceProvisionerService $provisioner): void
{
$this->deploying = true;
$this->deployError = null;
$this->progressMessages = [];
$this->currentProgress = 'Starting deployment...';
try {
$client = $this->resolveClient();
......@@ -65,12 +69,18 @@ public function deploy(InstanceProvisionerService $provisioner): void
'admin_password' => $this->admin_password,
'platform_fee_percent' => $this->custom_platform_fee !== '' ? (float) $this->custom_platform_fee : null,
'monthly_fee' => $this->custom_monthly_fee !== '' ? (int) round((float) $this->custom_monthly_fee * 100) : null,
]);
], function (string $message) {
$this->progressMessages[] = $message;
$this->currentProgress = $message;
$this->stream('progressStream', $message, true);
});
$this->deployed = true;
$this->instanceUrl = $instance->url;
$this->currentProgress = 'Deployment complete!';
} catch (\Throwable $e) {
$this->deployError = $e->getMessage();
$this->currentProgress = null;
} finally {
$this->deploying = false;
}
......
......@@ -135,6 +135,47 @@ public function deployWithConfig(string $appName, string $imageName, array $envV
return $this->api('post', '/api/v2/user/apps/appDefinitions/update', $payload);
}
/**
* Configure an app fully in ONE API call.
* CapRover wipes any field not included in an update call,
* so we must send everything together: port, instanceCount, forceSsl,
* volumes, envVars, and appPushWebhook.
*/
public function configureAppFull(string $appName, array $config): array
{
$payload = ['appName' => $appName];
if (isset($config['containerHttpPort'])) {
$payload['containerHttpPort'] = $config['containerHttpPort'];
}
if (isset($config['instanceCount'])) {
$payload['instanceCount'] = $config['instanceCount'];
}
if (isset($config['forceSsl'])) {
$payload['forceSsl'] = $config['forceSsl'];
}
if (!empty($config['volumes'])) {
$payload['volumes'] = $config['volumes'];
}
if (!empty($config['envVars'])) {
$payload['envVars'] = array_map(
fn ($key, $value) => ['key' => $key, 'value' => (string) $value],
array_keys($config['envVars']),
array_values($config['envVars'])
);
}
if (!empty($config['appPushWebhook'])) {
$payload['appPushWebhook'] = $config['appPushWebhook'];
}
return $this->api('post', '/api/v2/user/apps/appDefinitions/update', $payload);
}
public function addVolume(string $appName, string $containerPath, string $volumeName): array
{
return $this->api('post', '/api/v2/user/apps/appDefinitions/update', [
......
......@@ -17,51 +17,53 @@ public function __construct(
private CapRoverService $caprover,
) {}
public function provision(Client $client, Plan $plan, array $data): Instance
public function provision(Client $client, Plan $plan, array $data, ?callable $onProgress = null): Instance
{
$appName = $this->generateAppName($data['academy_name_en']);
$dbName = str_replace('-', '_', $appName);
$dbUser = $dbName;
$dbPassword = Str::random(24);
$adminPassword = $data['admin_password'] ?? Str::random(12);
$progress = $onProgress ?? fn(string $msg) => null;
$instance = Instance::create([
'client_id' => $client->id,
'plan_id' => $plan->id,
'app_name' => $appName,
'academy_name_ar' => $data['academy_name_ar'],
'academy_name_en' => $data['academy_name_en'],
'admin_email' => $data['admin_email'],
'status' => 'provisioning',
'db_app_name' => config('manager.shared_pg_service'),
'db_name' => $dbName,
'db_user' => $dbUser,
'db_password_encrypted' => Crypt::encryptString($dbPassword),
'platform_fee_percent' => $data['platform_fee_percent'] ?? $plan->platform_fee_percent,
'monthly_fee' => $data['monthly_fee'] ?? $plan->monthly_fee,
'trial_ends_at' => $plan->trial_days > 0 ? now()->addDays($plan->trial_days) : null,
]);
return DB::transaction(function () use ($client, $plan, $data, $appName, $dbName, $dbUser, $dbPassword, $adminPassword) {
$instance = Instance::create([
'client_id' => $client->id,
'plan_id' => $plan->id,
'app_name' => $appName,
'academy_name_ar' => $data['academy_name_ar'],
'academy_name_en' => $data['academy_name_en'],
'admin_email' => $data['admin_email'],
'status' => 'provisioning',
'db_app_name' => config('manager.shared_pg_service'),
'db_name' => $dbName,
'db_user' => $dbUser,
'db_password_encrypted' => Crypt::encryptString($dbPassword),
'platform_fee_percent' => $data['platform_fee_percent'] ?? $plan->platform_fee_percent,
'monthly_fee' => $data['monthly_fee'] ?? $plan->monthly_fee,
'trial_ends_at' => $plan->trial_days > 0 ? now()->addDays($plan->trial_days) : null,
]);
try {
$progress('Creating database...');
$this->createDatabase($dbName, $dbUser, $dbPassword);
try {
$this->createDatabase($dbName, $dbUser, $dbPassword);
$this->deployApp($instance, $dbPassword, $adminPassword);
$instance->update(['status' => $plan->trial_days > 0 ? 'trial' : 'active']);
} catch (\Throwable $e) {
$instance->update([
'status' => 'provisioning',
'caprover_deploy_log' => $e->getMessage(),
]);
throw new RuntimeException("Provisioning failed: {$e->getMessage()}", 0, $e);
}
AuditLog::record('deployed', $instance, [
'plan' => $plan->slug,
'admin_email' => $data['admin_email'],
$this->deployApp($instance, $dbPassword, $adminPassword, $progress);
$instance->update(['status' => $plan->trial_days > 0 ? 'trial' : 'active']);
} catch (\Throwable $e) {
$instance->update([
'status' => 'failed',
'caprover_deploy_log' => $e->getMessage(),
]);
throw new RuntimeException("Provisioning failed: {$e->getMessage()}", 0, $e);
}
AuditLog::record('deployed', $instance, [
'plan' => $plan->slug,
'admin_email' => $data['admin_email'],
]);
return $instance;
});
return $instance;
}
private function createDatabase(string $dbName, string $dbUser, string $dbPassword): void
......@@ -75,7 +77,7 @@ private function createDatabase(string $dbName, string $dbUser, string $dbPasswo
DB::connection($adminConn)->statement("ALTER USER \"{$dbUser}\" CREATEDB");
}
private function deployApp(Instance $instance, string $dbPassword, string $adminPassword): void
private function deployApp(Instance $instance, string $dbPassword, string $adminPassword, ?callable $onProgress = null): void
{
$rootDomain = config('manager.caprover_root_domain');
$gitRepoUrl = config('manager.elcaptain_git_repo');
......@@ -85,17 +87,17 @@ private function deployApp(Instance $instance, string $dbPassword, string $admin
$sharedPgService = config('manager.shared_pg_service');
$sharedPgPort = config('manager.shared_pg_port');
// 1. Create the app on CapRover
$progress = $onProgress ?? fn(string $msg) => null;
// Step 1: Create the app
$progress('Creating CapRover app...');
$this->caprover->createApp($instance->app_name, true);
$this->caprover->setContainerHttpPort($instance->app_name, 80);
$this->caprover->addVolume(
$instance->app_name,
'/var/www/html/storage/app',
"{$instance->app_name}-storage"
);
// 2. Set environment variables (points to SHARED PG)
$this->caprover->setEnvVars($instance->app_name, [
// Step 2: Configure EVERYTHING in ONE call (env + volume + port + git + instance count)
// CapRover wipes fields not included in an update call, so we send it all at once
$progress('Configuring app (env vars, volume, git repo)...');
$envVars = [
'APP_NAME' => $instance->academy_name_ar,
'APP_ENV' => 'production',
'APP_DEBUG' => 'false',
......@@ -130,27 +132,40 @@ private function deployApp(Instance $instance, string $dbPassword, string $admin
'RUN_SEED_ON_FIRST_DEPLOY' => 'true',
'MAIL_MAILER' => 'log',
'MAIL_VERIFY_PEER' => 'false',
];
$this->caprover->configureAppFull($instance->app_name, [
'containerHttpPort' => 80,
'instanceCount' => 1,
'forceSsl' => true,
'volumes' => [
['containerPath' => '/var/www/html/storage/app', 'volumeName' => "{$instance->app_name}-storage"],
],
'envVars' => $envVars,
'appPushWebhook' => [
'repoInfo' => [
'repo' => $gitRepoUrl,
'user' => $gitUser,
'password' => $gitPassword,
'branch' => $gitBranch,
'sshKey' => '',
],
],
]);
// 3. Configure git repo (tells CapRover WHERE to pull from)
$this->caprover->setGitRepo(
$instance->app_name,
$gitRepoUrl,
$gitBranch,
$gitUser,
$gitPassword,
);
// 4. Trigger the first build (pulls from git and builds Docker image)
$this->caprover->forceBuild($instance->app_name);
// 5. Enable SSL (wildcard already covers *.caprover.al-arcade.com)
// Step 3: Enable SSL
$progress('Enabling SSL...');
try {
$this->caprover->enableSsl($instance->app_name);
$this->caprover->forceSsl($instance->app_name);
} catch (\Throwable $e) {
// Wildcard cert handles this automatically — not critical
// Wildcard cert handles this — not critical
}
// Step 4: Trigger the build
$progress('Triggering build (pulling from git)...');
$this->caprover->forceBuild($instance->app_name);
$progress('Build triggered — app is building from git.');
}
public function suspend(Instance $instance, string $reason): void
......
......@@ -213,9 +213,46 @@
</div>
</div>
@if(!$deploying && !$deployError)
<div class="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800">
<strong>This will:</strong> Create a PostgreSQL database, deploy El Captain from git, run migrations, seed the admin user, and enable HTTPS. Takes ~3 minutes.
</div>
@endif
{{-- Deploy Progress (visible during and after deploy) --}}
<div wire:loading.block wire:target="deploy" class="mt-6 p-4 bg-gray-900 rounded-lg font-mono text-sm">
<div class="flex items-center gap-2 mb-3">
<div class="w-2 h-2 rounded-full bg-green-400 animate-pulse"></div>
<span class="text-green-400 font-semibold">Deploying...</span>
</div>
<div class="space-y-2 text-gray-300">
<div class="flex items-center gap-2 text-yellow-400">
<svg class="animate-spin w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
<span>Creating database &amp; CapRover app...</span>
</div>
<div class="text-gray-500 text-xs mt-2">This takes 30-90 seconds. Do not close this page.</div>
</div>
</div>
{{-- Completed Progress Log --}}
@if(count($progressMessages) > 0 && !$deploying)
<div class="mt-6 p-4 bg-gray-900 rounded-lg font-mono text-sm">
<div class="flex items-center gap-2 mb-3">
<div class="w-2 h-2 rounded-full {{ $deployError ? 'bg-red-400' : 'bg-green-400' }}"></div>
<span class="{{ $deployError ? 'text-red-400' : 'text-green-400' }} font-semibold">
{{ $deployError ? 'Deploy Failed' : 'Deploy Complete' }}
</span>
</div>
<div class="space-y-1 text-gray-300">
@foreach($progressMessages as $msg)
<div class="flex items-start gap-2">
<span class="text-green-500 flex-shrink-0">&#10003;</span>
<span>{{ $msg }}</span>
</div>
@endforeach
</div>
</div>
@endif
@endif
@endif
......@@ -234,14 +271,13 @@
<button wire:click="nextStep" class="px-5 py-2.5 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition">
Next
</button>
@elseif($step === $totalSteps && !$deploying)
<button wire:click="deploy" class="px-6 py-2.5 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition">
Deploy Now
</button>
@elseif($deploying)
<button disabled class="px-6 py-2.5 text-sm font-medium text-white bg-green-400 rounded-lg cursor-wait flex items-center gap-2">
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
Deploying...
@elseif($step === $totalSteps)
<button wire:click="deploy" wire:loading.attr="disabled" wire:target="deploy" class="px-6 py-2.5 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition disabled:bg-green-400 disabled:cursor-wait">
<span wire:loading.remove wire:target="deploy">Deploy Now</span>
<span wire:loading.flex wire:target="deploy" class="items-center gap-2">
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
Deploying...
</span>
</button>
@endif
</div>
......
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