Commit 30b5f1d7 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Fix deploy: use working CapRover git-repo + trigger-build API

Replace broken deployFromGit() with setGitRepo() + forceBuild().
Add Force Build button per instance + Force Build All on list page.
No auto-deploy — builds only trigger when user clicks the button.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 72ec2356
......@@ -3,6 +3,7 @@
namespace App\Livewire;
use App\Models\Instance;
use App\Services\CapRoverService;
use App\Services\InstanceProvisionerService;
use Livewire\Attributes\Url;
use Livewire\Component;
......@@ -28,6 +29,40 @@ public function updatedStatus(): void
$this->resetPage();
}
public function forceBuild(int $id): void
{
$instance = Instance::findOrFail($id);
try {
app(CapRoverService::class)->forceBuild($instance->app_name);
session()->flash('success', "Build triggered for {$instance->academy_name_en}.");
} catch (\Throwable $e) {
session()->flash('error', "Build failed for {$instance->academy_name_en}: {$e->getMessage()}");
}
}
public function forceBuildAll(): void
{
$instances = Instance::whereIn('status', ['active', 'trial'])->get();
$caprover = app(CapRoverService::class);
$success = 0;
$failed = 0;
foreach ($instances as $instance) {
try {
$caprover->forceBuild($instance->app_name);
$success++;
} catch (\Throwable $e) {
$failed++;
}
}
if ($failed > 0) {
session()->flash('error', "Build triggered for {$success} instances. {$failed} failed.");
} else {
session()->flash('success', "Build triggered for all {$success} active instances.");
}
}
public function suspend(int $id): void
{
$instance = Instance::findOrFail($id);
......
......@@ -70,6 +70,17 @@ public function hideLogs(): void
$this->logs = '';
}
public function forceBuild(): void
{
try {
app(CapRoverService::class)->forceBuild($this->instance->app_name);
session()->flash('success', 'Build triggered — CapRover is pulling from git and building.');
} catch (\Throwable $e) {
session()->flash('error', 'Build failed: ' . $e->getMessage());
}
$this->stateLoaded = false;
}
public function suspend(): void
{
app(InstanceProvisionerService::class)->suspend($this->instance, 'Manual suspension from dashboard');
......
......@@ -22,9 +22,10 @@ private function authenticate(): string
return $this->token;
}
$response = Http::post("{$this->baseUrl}/api/v2/login", [
'password' => config('manager.caprover_password'),
])->json();
$response = Http::withoutVerifying()
->post("{$this->baseUrl}/api/v2/login", [
'password' => config('manager.caprover_password'),
])->json();
if (($response['status'] ?? 0) !== 100) {
throw new RuntimeException('CapRover authentication failed: ' . ($response['description'] ?? 'unknown'));
......@@ -38,16 +39,18 @@ private function api(string $method, string $endpoint, array $data = []): array
{
$token = $this->authenticate();
$response = Http::withHeaders([
'x-namespace' => 'captain',
'x-captain-auth' => $token,
])->{$method}("{$this->baseUrl}{$endpoint}", $data);
$response = Http::withoutVerifying()
->withHeaders([
'x-namespace' => 'captain',
'x-captain-auth' => $token,
])->{$method}("{$this->baseUrl}{$endpoint}", $data);
$result = $response->json();
if (($result['status'] ?? 0) !== 100) {
Log::error('CapRover API error', [
'endpoint' => $endpoint,
'data' => array_diff_key($data, array_flip(['envVars', 'password'])),
'response' => $result,
]);
throw new RuntimeException('CapRover API error: ' . ($result['description'] ?? 'unknown'));
......@@ -71,11 +74,15 @@ public function deleteApp(string $appName): array
]);
}
public function deployImage(string $appName, string $imageName): array
/**
* Deploy a pre-built Docker image to an app.
* The image must exist on the server (locally built or pulled from registry).
*/
public function deployImage(string $appName, string $imageName, int $instanceCount = 1): array
{
return $this->api('post', '/api/v2/user/apps/appDefinitions/update', [
'appName' => $appName,
'instanceCount' => 1,
'instanceCount' => $instanceCount,
'captainDefinitionContent' => json_encode([
'schemaVersion' => 2,
'imageName' => $imageName,
......@@ -85,7 +92,11 @@ public function deployImage(string $appName, string $imageName): array
public function setEnvVars(string $appName, array $envVars): array
{
$formatted = array_map(fn ($key, $value) => ['key' => $key, 'value' => (string) $value], array_keys($envVars), array_values($envVars));
$formatted = array_map(
fn ($key, $value) => ['key' => $key, 'value' => (string) $value],
array_keys($envVars),
array_values($envVars)
);
return $this->api('post', '/api/v2/user/apps/appDefinitions/update', [
'appName' => $appName,
......@@ -93,6 +104,37 @@ public function setEnvVars(string $appName, array $envVars): array
]);
}
/**
* Deploy image + set env vars + configure app in ONE call.
* This prevents CapRover from reverting to a stale image on env var updates.
*/
public function deployWithConfig(string $appName, string $imageName, array $envVars, array $options = []): array
{
$formatted = array_map(
fn ($key, $value) => ['key' => $key, 'value' => (string) $value],
array_keys($envVars),
array_values($envVars)
);
$payload = [
'appName' => $appName,
'instanceCount' => $options['instanceCount'] ?? 1,
'containerHttpPort' => $options['containerHttpPort'] ?? 80,
'forceSsl' => $options['forceSsl'] ?? true,
'envVars' => $formatted,
'captainDefinitionContent' => json_encode([
'schemaVersion' => 2,
'imageName' => $imageName,
]),
];
if (!empty($options['volumes'])) {
$payload['volumes'] = $options['volumes'];
}
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', [
......@@ -136,22 +178,31 @@ public function resumeApp(string $appName): array
return $this->scaleApp($appName, 1);
}
public function setContainerHttpPort(string $appName, int $port): array
{
return $this->api('post', '/api/v2/user/apps/appDefinitions/update', [
'appName' => $appName,
'containerHttpPort' => $port,
]);
}
public function getAppInfo(string $appName): array
{
return $this->api('get', "/api/v2/user/apps/appDefinitions/{$appName}");
return $this->api('get', "/api/v2/user/apps/appData/{$appName}");
}
public function getAppLogs(string $appName, string $encoding = 'utf-8'): string
public function getAppLogs(string $appName): string
{
$token = $this->authenticate();
$response = Http::withHeaders([
'x-namespace' => 'captain',
'x-captain-auth' => $token,
])->post("{$this->baseUrl}/api/v2/user/apps/appDefinitions/logs", [
'appName' => $appName,
'encoding' => $encoding,
]);
$response = Http::withoutVerifying()
->withHeaders([
'x-namespace' => 'captain',
'x-captain-auth' => $token,
])->post("{$this->baseUrl}/api/v2/user/apps/appDefinitions/logs", [
'appName' => $appName,
'encoding' => 'utf-8',
]);
$result = $response->json();
return $result['data']['logs'] ?? '';
......@@ -163,27 +214,74 @@ public function listApps(): array
return $data['appDefinitions'] ?? [];
}
public function setContainerHttpPort(string $appName, int $port): array
/**
* Configure the git repo that CapRover will pull from when building.
* This sets appPushWebhook.repoInfo and CapRover auto-generates a webhook token.
*/
public function setGitRepo(string $appName, string $repoUrl, string $branch, string $user, string $password): array
{
return $this->api('post', '/api/v2/user/apps/appDefinitions/update', [
'appName' => $appName,
'containerHttpPort' => $port,
'appPushWebhook' => [
'repoInfo' => [
'repo' => $repoUrl,
'user' => $user,
'password' => $password,
'branch' => $branch,
'sshKey' => '',
],
],
]);
}
public function deployFromGit(string $appName, string $repoUrl, string $branch = 'main', ?string $user = null, ?string $password = null): array
/**
* Trigger a build — CapRover pulls from the configured git repo and builds.
* Returns true on success.
*/
public function forceBuild(string $appName): bool
{
$gitData = [
'appName' => $appName,
'gitRepoUrl' => $repoUrl,
'branch' => $branch,
];
$webhookToken = $this->getWebhookToken($appName);
if (!$webhookToken) {
throw new RuntimeException("No webhook token found for app '{$appName}'. Configure git repo first.");
}
$url = "{$this->baseUrl}/api/v2/user/apps/webhooks/triggerbuild?namespace=captain&token={$webhookToken}";
if ($user && $password) {
$gitData['gitUser'] = $user;
$gitData['gitPassword'] = $password;
$response = Http::withoutVerifying()->post($url, []);
$result = $response->json();
if (($result['status'] ?? 0) !== 100) {
throw new RuntimeException('Force build failed: ' . ($result['description'] ?? 'unknown'));
}
return $this->api('post', '/api/v2/user/apps/appDefinitions/triggerbuild', $gitData);
return true;
}
/**
* Get the webhook token for an app (needed to trigger builds).
*/
public function getWebhookToken(string $appName): ?string
{
$apps = $this->listApps();
foreach ($apps as $app) {
if ($app['appName'] === $appName) {
return $app['appPushWebhook']['pushWebhookToken'] ?? null;
}
}
return null;
}
/**
* Get full app definition (env vars, volumes, webhook config, etc.)
*/
public function getAppDefinition(string $appName): ?array
{
$apps = $this->listApps();
foreach ($apps as $app) {
if ($app['appName'] === $appName) {
return $app;
}
}
return null;
}
}
......@@ -81,6 +81,7 @@ private function deployApp(Instance $instance, string $dbPassword, string $admin
$gitRepoUrl = config('manager.elcaptain_git_repo');
$gitUser = config('manager.elcaptain_git_user');
$gitPassword = config('manager.elcaptain_git_password');
$gitBranch = config('manager.elcaptain_git_branch', 'main');
$sharedPgService = config('manager.shared_pg_service');
$sharedPgPort = config('manager.shared_pg_port');
......@@ -131,17 +132,19 @@ private function deployApp(Instance $instance, string $dbPassword, string $admin
'MAIL_VERIFY_PEER' => 'false',
]);
// 3. Deploy from git repo (builds the Docker image on CapRover)
$this->caprover->deployFromGit(
// 3. Configure git repo (tells CapRover WHERE to pull from)
$this->caprover->setGitRepo(
$instance->app_name,
$gitRepoUrl,
'main',
$gitBranch,
$gitUser,
$gitPassword,
);
// 4. Enable SSL (wildcard already covers *.caprover.al-arcade.com)
sleep(2);
// 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)
try {
$this->caprover->enableSsl($instance->app_name);
$this->caprover->forceSsl($instance->app_name);
......
......@@ -14,6 +14,7 @@
// 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_branch' => env('ELCAPTAIN_GIT_BRANCH', 'main'),
'elcaptain_git_user' => env('ELCAPTAIN_GIT_USER', 'root'),
'elcaptain_git_password' => env('ELCAPTAIN_GIT_PASSWORD', ''),
......
<div>
{{-- Flash Messages --}}
@if(session('success'))
<div class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700">{{ session('success') }}</div>
@endif
@if(session('error'))
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{{ session('error') }}</div>
@endif
{{-- Header --}}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
<div class="flex items-center gap-3">
......@@ -12,11 +20,20 @@ class="w-64 rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-
<option value="provisioning">Provisioning</option>
</select>
</div>
<a href="{{ route('instances.deploy') }}" wire:navigate
class="inline-flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
Deploy New
</a>
<div class="flex items-center gap-2">
<button wire:click="forceBuildAll" wire:confirm="Trigger a fresh build on ALL active instances? This will pull from git and rebuild each one."
wire:loading.attr="disabled" wire:target="forceBuildAll"
class="inline-flex items-center gap-2 px-4 py-2.5 bg-orange-500 text-white rounded-lg hover:bg-orange-600 text-sm font-medium">
<svg wire:loading.remove wire:target="forceBuildAll" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
<svg wire:loading wire:target="forceBuildAll" class="w-4 h-4 animate-spin" 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>
Force Build All
</button>
<a href="{{ route('instances.deploy') }}" wire:navigate
class="inline-flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
Deploy New
</a>
</div>
</div>
{{-- Table --}}
......@@ -57,6 +74,10 @@ class="inline-flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white rounded
<td class="px-4 py-3 text-gray-600">{{ $instance->platform_fee_percent }}%</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<button wire:click="forceBuild({{ $instance->id }})" wire:confirm="Trigger build for {{ $instance->academy_name_en }}?"
class="p-1.5 text-gray-400 hover:text-orange-600 rounded" title="Force Build">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button>
<a href="{{ $instance->url }}" target="_blank" class="p-1.5 text-gray-400 hover:text-blue-600 rounded" title="Open">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
......
<div>
{{-- Flash Messages --}}
@if(session('success'))
<div class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700">{{ session('success') }}</div>
@endif
@if(session('error'))
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{{ session('error') }}</div>
@endif
{{-- Header --}}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
<div>
......@@ -14,6 +22,13 @@
<p class="text-sm text-gray-500 mt-0.5">{{ $instance->academy_name_en }} &middot; {{ $instance->app_name }}</p>
</div>
<div class="flex items-center gap-2">
<button wire:click="forceBuild" wire:confirm="Trigger a fresh build? CapRover will pull from git and rebuild."
wire:loading.attr="disabled" wire:target="forceBuild"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-orange-700 bg-orange-50 border border-orange-200 rounded-lg hover:bg-orange-100 transition">
<svg wire:loading.remove wire:target="forceBuild" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
<svg wire:loading wire:target="forceBuild" class="w-4 h-4 animate-spin" 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>
Force Build
</button>
<a href="{{ $instance->url }}" target="_blank"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
......
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