diff --git a/app/Console/Commands/RunLagoonCommandOnAppInstances.php b/app/Console/Commands/RunLagoonCommandOnAppInstances.php new file mode 100644 index 0000000..10f797b --- /dev/null +++ b/app/Console/Commands/RunLagoonCommandOnAppInstances.php @@ -0,0 +1,383 @@ +argument('app_uuid'); + $envOverride = $this->option('environment'); + $variablesOnly = $this->option('variables-only'); + $concurrency = (int) $this->option('concurrency'); + $instanceId = $this->option('instance-id'); + + // Configuration + $sshConfig = config('polydock.service_providers_singletons.PolydockServiceProviderFTLagoon', []); + $sshHost = $sshConfig['ssh_server'] ?? 'ssh.lagoon.amazeeio.cloud'; + $sshPort = $sshConfig['ssh_port'] ?? '32222'; + $globalKeyFile = $sshConfig['ssh_private_key_file'] ?? null; + $apiEndpoint = $sshConfig['endpoint'] ?? 'https://api.lagoon.amazeeio.cloud/graphql'; + + $clientConfig = [ + 'ssh_user' => $sshConfig['ssh_user'] ?? 'lagoon', + 'ssh_server' => $sshHost, + 'ssh_port' => $sshPort, + 'endpoint' => $apiEndpoint, + 'ssh_private_key_file' => $globalKeyFile, + ]; + + // --- Single Instance Mode (Worker) --- + if ($instanceId) { + $instance = PolydockAppInstance::find($instanceId); + if (! $instance) { + $this->error("Instance ID {$instanceId} not found."); + + return 1; + } + + return $this->deployToInstance($instance, $clientConfig, null, $envOverride, $variablesOnly); + } + + // --- Bulk Mode (Coordinator) --- + + $storeApp = PolydockStoreApp::where('uuid', $appUuid)->first(); + if (! $storeApp) { + $this->error("Store App with UUID {$appUuid} not found."); + + return 1; + } + + $this->info("Found Store App: {$storeApp->name} (ID: {$storeApp->id})"); + + // Get running instances + $instances = PolydockAppInstance::where('polydock_store_app_id', $storeApp->id) + ->whereIn('status', PolydockAppInstance::$stageRunningStatuses) + ->get(); + + $count = $instances->count(); + if ($count === 0) { + $this->info('No running instances found for this app.'); + + return 0; + } + + $this->info("Found {$count} running instances."); + + if (! $this->option('force')) { + // Pre-calculate column widths + $maxWidths = [ + 'id' => strlen('ID'), + 'name' => strlen('Name'), + 'project' => strlen('Lagoon Project'), + 'branch' => strlen('Branch'), + ]; + + $instanceData = []; + + foreach ($instances as $instance) { + $projectName = $instance->getKeyValue('lagoon-project-name'); + $branch = $envOverride ?: $instance->getKeyValue('lagoon-deploy-branch'); + + $data = [ + 'id' => (string) $instance->id, + 'name' => $instance->name, + 'project' => $projectName, + 'branch' => $branch, + ]; + + $instanceData[$instance->id] = $data; + + foreach ($maxWidths as $key => $width) { + $maxWidths[$key] = max($width, strlen((string) $data[$key])); + } + } + + // Create formatted options + $options = []; + foreach ($instanceData as $id => $data) { + $label = sprintf( + '%s %s %s %s', + str_pad($data['id'], $maxWidths['id']), + str_pad((string) $data['name'], $maxWidths['name']), + str_pad((string) $data['project'], $maxWidths['project']), + str_pad((string) $data['branch'], $maxWidths['branch']) + ); + $options[$id] = $label; + } + + $header = sprintf( + '%s %s %s %s', + str_pad('ID', $maxWidths['id']), + str_pad('Name', $maxWidths['name']), + str_pad('Lagoon Project', $maxWidths['project']), + str_pad('Branch', $maxWidths['branch']) + ); + + $selectedIds = multiselect( + label: 'Select instances to trigger deploy on:', + options: $options, + default: array_keys($options), + scroll: 15, + hint: $header + ); + + if (empty($selectedIds)) { + $this->info('No instances selected.'); + + return 0; + } + + // Filter instances to only those selected + $instances = $instances->whereIn('id', $selectedIds); + $count = $instances->count(); + + if (! $this->confirm("Are you sure you want to trigger deployments on {$count} selected instances?")) { + $this->info('Operation cancelled.'); + + return 0; + } + + if (! $variablesOnly) { + $variablesOnly = $this->confirm('Do you want to run a variables-only deployment?', false); + } + } else { + // Force mode: show table for audit/info purposes + $headers = ['ID', 'Name', 'Lagoon Project', 'Branch']; + $rows = []; + + foreach ($instances as $instance) { + $projectName = $instance->getKeyValue('lagoon-project-name'); + $branch = $envOverride ?: $instance->getKeyValue('lagoon-deploy-branch'); + $rows[] = [$instance->id, $instance->name, $projectName, $branch]; + } + + $this->table($headers, $rows); + } + + // Concurrency Logic + if ($concurrency > 1) { + $this->info("Running deployments concurrently on {$count} instances (concurrency: {$concurrency})..."); + + $phpBinary = PHP_BINARY; + $artisan = base_path('artisan'); + $commandBase = [ + $phpBinary, + $artisan, + 'polydock:instances:run-lagoon-command', + $appUuid, + '--force', + ]; + + if ($envOverride) { + $commandBase[] = "--environment={$envOverride}"; + } + if ($variablesOnly) { + $commandBase[] = '--variables-only'; + } + + $pool = Process::pool(function (Pool $pool) use ($instances, $commandBase) { + foreach ($instances as $instance) { + $command = array_merge($commandBase, ["--instance-id={$instance->id}"]); + $pool->as($instance->id)->command($command); + } + }); + + try { + $pool->concurrency($concurrency); + } catch (\Throwable) { + // Ignore if method doesn't exist + } + + $poolResults = $pool->wait(); + + foreach ($poolResults as $instanceId => $result) { + $instance = $instances->find($instanceId); + if (! $instance) { + continue; + } + + $projectName = $instance->getKeyValue('lagoon-project-name'); + + if ($result->successful()) { + // Output already contains "SUCCESS" or "FAILED" messages from child process + $this->output->write($result->output()); + } else { + $this->error("\n[FAILED] {$projectName} (Process Error): ".trim((string) $result->errorOutput())); + } + } + + $this->info('Done.'); + + return 0; + } + + // Serial Logic + $this->info('Authenticating with Lagoon (Serial Mode)...'); + try { + if (! $clientConfig['ssh_private_key_file'] || ! file_exists($clientConfig['ssh_private_key_file'])) { + $this->error('Global SSH private key not found or not configured.'); + + return 1; + } + + $token = $this->getLagoonToken($clientConfig); + if (empty($token)) { + $this->error('Failed to retrieve Lagoon API token.'); + + return 1; + } + + if (app()->bound(Client::class)) { + $client = app(Client::class); + } else { + $client = app()->makeWith(Client::class, ['config' => $clientConfig]); + } + + $client->setLagoonToken($token); + $client->initGraphqlClient(); + + } catch (\Exception $e) { + $this->error("Authentication failed: {$e->getMessage()}"); + + return 1; + } + + $bar = $this->output->createProgressBar($count); + $bar->start(); + + foreach ($instances as $instance) { + $this->deployToInstance($instance, $clientConfig, $client, $envOverride, $variablesOnly); + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + $this->info('Done.'); + + return 0; + } + + protected function deployToInstance( + PolydockAppInstance $instance, + array $clientConfig, + ?Client $client = null, + ?string $envOverride = null, + bool $variablesOnly = false + ): int { + $projectName = $instance->getKeyValue('lagoon-project-name'); + $branch = $envOverride ?: $instance->getKeyValue('lagoon-deploy-branch'); + + if (empty($projectName) || empty($branch)) { + $this->error("\nMissing project name or branch for instance {$instance->id} ({$instance->name})"); + + return 1; + } + + // If client is not provided (e.g. concurrent/child mode), authenticate now + if (! $client) { + try { + if (! $clientConfig['ssh_private_key_file'] || ! file_exists($clientConfig['ssh_private_key_file'])) { + $this->error('Global SSH private key not found.'); + + return 1; + } + + $token = $this->getLagoonToken($clientConfig); + if (empty($token)) { + $this->error("Failed to retrieve Lagoon API token for instance {$instance->id}."); + + return 1; + } + + if (app()->bound(Client::class)) { + $client = app(Client::class); + } else { + $client = app()->makeWith(Client::class, ['config' => $clientConfig]); + } + + $client->setLagoonToken($token); + $client->initGraphqlClient(); + } catch (\Exception $e) { + $this->error("Authentication failed for instance {$instance->id}: {$e->getMessage()}"); + + return 1; + } + } + + $buildVars = []; + if ($variablesOnly) { + $buildVars['LAGOON_VARIABLES_ONLY'] = 'true'; + } + + try { + $result = $client->deployProjectEnvironmentByName($projectName, $branch, $buildVars); + + if (isset($result['error'])) { + $errors = is_array($result['error']) ? json_encode($result['error']) : $result['error']; + $this->error("\n[FAILED] {$projectName}: {$errors}"); + + return 1; + } else { + // In concurrent mode, we want to output to stdout so the parent can see it + // In serial mode, we might want to be quieter or use the progress bar, but for now verbose is fine + $this->info("\n[SUCCESS] {$projectName}: Deployment triggered."); + + return 0; + } + } catch (\Exception $e) { + $this->error("\n[FAILED] {$projectName}: {$e->getMessage()}"); + + return 1; + } + } + + protected function getLagoonToken(array $config): string + { + if (app()->bound('polydock.lagoon.token_fetcher')) { + return app('polydock.lagoon.token_fetcher')($config); + } + + $ssh = Ssh::createLagoonConfigured( + $config['ssh_user'], + $config['ssh_server'], + $config['ssh_port'], + $config['ssh_private_key_file'] + ); + + return $ssh->executeLagoonGetToken(); + } +} diff --git a/app/Console/Commands/TriggerLagoonDeployOnAppInstances.php b/app/Console/Commands/TriggerLagoonDeployOnAppInstances.php new file mode 100644 index 0000000..3f2ab37 --- /dev/null +++ b/app/Console/Commands/TriggerLagoonDeployOnAppInstances.php @@ -0,0 +1,191 @@ +argument('app_uuid'); + $envOverride = $this->option('environment'); + + $storeApp = PolydockStoreApp::where('uuid', $appUuid)->first(); + if (! $storeApp) { + $this->error("Store App with UUID {$appUuid} not found."); + + return 1; + } + + $this->info("Found Store App: {$storeApp->name} (ID: {$storeApp->id})"); + + // Get running instances + $instances = PolydockAppInstance::where('polydock_store_app_id', $storeApp->id) + ->whereIn('status', PolydockAppInstance::$stageRunningStatuses) + ->get(); + + $count = $instances->count(); + if ($count === 0) { + $this->info('No running instances found for this app.'); + + return 0; + } + + $this->info("Found {$count} running instances."); + + if (! $this->option('force')) { + // Pre-calculate column widths + $maxWidths = [ + 'id' => strlen('ID'), + 'name' => strlen('Name'), + 'project' => strlen('Lagoon Project'), + 'branch' => strlen('Branch'), + ]; + + $instanceData = []; + + foreach ($instances as $instance) { + $projectName = $instance->getKeyValue('lagoon-project-name'); + $branch = $envOverride ?: $instance->getKeyValue('lagoon-deploy-branch'); + + $data = [ + 'id' => (string) $instance->id, + 'name' => $instance->name, + 'project' => $projectName, + 'branch' => $branch, + ]; + + $instanceData[$instance->id] = $data; + + foreach ($maxWidths as $key => $width) { + $maxWidths[$key] = max($width, strlen($data[$key])); + } + } + + // Create formatted options + $options = []; + foreach ($instanceData as $id => $data) { + $label = sprintf( + '%s %s %s %s', + str_pad($data['id'], $maxWidths['id']), + str_pad($data['name'], $maxWidths['name']), + str_pad($data['project'], $maxWidths['project']), + str_pad($data['branch'], $maxWidths['branch']) + ); + $options[$id] = $label; + } + + // Create a header for the prompt label + $header = sprintf( + '%s %s %s %s', + str_pad('ID', $maxWidths['id']), + str_pad('Name', $maxWidths['name']), + str_pad('Lagoon Project', $maxWidths['project']), + str_pad('Branch', $maxWidths['branch']) + ); + + $selectedIds = multiselect( + label: 'Select instances to trigger deploy on:', + options: $options, + default: array_keys($options), + scroll: 15, + hint: $header + ); + + if (empty($selectedIds)) { + $this->info('No instances selected.'); + + return 0; + } + + // Filter instances to only those selected + $instances = $instances->whereIn('id', $selectedIds); + $count = $instances->count(); + + if (! $this->confirm("Are you sure you want to trigger deployments on {$count} selected instances?")) { + $this->info('Operation cancelled.'); + + return 0; + } + } else { + // Force mode: show table for audit/info purposes + $headers = ['ID', 'Name', 'Lagoon Project', 'Branch']; + $rows = []; + + foreach ($instances as $instance) { + $projectName = $instance->getKeyValue('lagoon-project-name'); + $branch = $envOverride ?: $instance->getKeyValue('lagoon-deploy-branch'); + $rows[] = [$instance->id, $instance->name, $projectName, $branch]; + } + + $this->table($headers, $rows); + } + + $bar = $this->output->createProgressBar($count); + $bar->start(); + + foreach ($instances as $instance) { + $projectName = $instance->getKeyValue('lagoon-project-name'); + $branch = $envOverride ?: $instance->getKeyValue('lagoon-deploy-branch'); + + if (empty($projectName) || empty($branch)) { + $this->error("\nMissing project name or branch for instance {$instance->id} ({$instance->name})"); + $bar->advance(); + + continue; + } + + // Construct Lagoon CLI command + // lagoon deploy -p -e + + $fullCommand = sprintf('lagoon deploy -p %s -e %s', + escapeshellarg($projectName), + escapeshellarg($branch) + ); + + $result = Process::run($fullCommand); + + if ($result->successful()) { + if ($this->output->isVerbose()) { + $this->info("\n[SUCCESS] {$projectName}: ".trim($result->output())); + } + } else { + $this->error("\n[FAILED] {$projectName}: ".trim($result->errorOutput())); + } + + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + $this->info('Done.'); + + return 0; + } +} diff --git a/app/Filament/Admin/Resources/PolydockAppInstanceResource.php b/app/Filament/Admin/Resources/PolydockAppInstanceResource.php index 59e5fef..8976203 100644 --- a/app/Filament/Admin/Resources/PolydockAppInstanceResource.php +++ b/app/Filament/Admin/Resources/PolydockAppInstanceResource.php @@ -241,12 +241,17 @@ public static function infolist(Infolist $infolist): Infolist public static function getRenderedSafeDataForRecord(PolydockAppInstance $record): array { $safeData = $record->data; + $sensitiveKeys = $record->getSensitiveDataKeys(); $renderedArray = []; foreach ($safeData as $key => $value) { - if ($record->shouldFilterKey($key, $record->getSensitiveDataKeys())) { + if ($record->shouldFilterKey($key, $sensitiveKeys)) { $value = 'REDACTED'; } + if ($value === null) { + $value = ''; + } + $renderKey = 'webhook_data_'.$key; $renderedItem = \Filament\Infolists\Components\TextEntry::make($renderKey) ->label($key) diff --git a/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/ViewPolydockAppInstance.php b/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/ViewPolydockAppInstance.php index 8096287..1f4a049 100644 --- a/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/ViewPolydockAppInstance.php +++ b/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/ViewPolydockAppInstance.php @@ -7,8 +7,12 @@ use Filament\Actions; use Filament\Actions\Action; use Filament\Forms\Components\DatePicker; +use Filament\Forms\Components\Placeholder; +use Filament\Forms\Components\TextInput; use Filament\Notifications\Notification; use Filament\Resources\Pages\ViewRecord; +use FreedomtechHosting\PolydockApp\Enums\PolydockAppInstanceStatus; +use Illuminate\Support\Facades\Process; class ViewPolydockAppInstance extends ViewRecord { @@ -19,6 +23,63 @@ protected function getHeaderActions(): array { return [ Actions\EditAction::make(), + Action::make('trigger_deploy') + ->label('Trigger Deploy') + ->icon('heroicon-o-rocket-launch') + ->color('warning') + ->requiresConfirmation() + ->form([ + TextInput::make('branch') + ->label('Branch / Environment') + ->required() + ->disabled() + ->default(fn ($record) => $record->getKeyValue('lagoon-deploy-branch')), + Placeholder::make('last_deployment_date') + ->label('Last Deployment Date') + ->content(function ($record) { + $lastDeployLog = $record->logs() + ->whereJsonContains('data->new_status', PolydockAppInstanceStatus::DEPLOY_COMPLETED->value) + ->latest() + ->first(); + + return $lastDeployLog ? $lastDeployLog->created_at->toDateTimeString() : 'Never'; + }), + ]) + ->action(function (array $data, $record): void { + $projectName = $record->getKeyValue('lagoon-project-name'); + $branch = $data['branch']; + + if (empty($projectName) || empty($branch)) { + Notification::make() + ->title('Deployment Failed') + ->danger() + ->body('Missing project name or branch configuration.') + ->send(); + + return; + } + + $fullCommand = sprintf('lagoon deploy -p %s -e %s', + escapeshellarg($projectName), + escapeshellarg($branch) + ); + + $result = Process::run($fullCommand); + + if ($result->successful()) { + Notification::make() + ->title('Deployment Triggered') + ->success() + ->body('Output: '.trim($result->output())) + ->send(); + } else { + Notification::make() + ->title('Deployment Failed') + ->danger() + ->body('Error: '.trim($result->errorOutput())) + ->send(); + } + }), Action::make('extend_trial') ->label('Extend Trial') ->icon('heroicon-o-calendar') diff --git a/app/Filament/Admin/Resources/UserRemoteRegistrationResource/Pages/ViewUserRemoteRegistration.php b/app/Filament/Admin/Resources/UserRemoteRegistrationResource/Pages/ViewUserRemoteRegistration.php index 367f889..81120fc 100644 --- a/app/Filament/Admin/Resources/UserRemoteRegistrationResource/Pages/ViewUserRemoteRegistration.php +++ b/app/Filament/Admin/Resources/UserRemoteRegistrationResource/Pages/ViewUserRemoteRegistration.php @@ -107,6 +107,10 @@ public static function getRenderedSafeDataForRecord(UserRemoteRegistration $reco $value = 'REDACTED'; } + if ($value === null) { + $value = ''; + } + $renderKey = 'request_data_'.$key; $renderedItem = \Filament\Infolists\Components\TextEntry::make($renderKey) ->label($key) diff --git a/app/Models/PolydockAppInstance.php b/app/Models/PolydockAppInstance.php index 4b70fe5..e79b016 100644 --- a/app/Models/PolydockAppInstance.php +++ b/app/Models/PolydockAppInstance.php @@ -593,6 +593,24 @@ public function getEngine(): PolydockEngineInterface return $this->engine; } + /** + * Set the name of the app instance + */ + public function setName(string $name): PolydockAppInstanceInterface + { + $this->name = $name; + + return $this; + } + + /** + * Get the name of the app instance + */ + public function getName(): string + { + return $this->name; + } + /** * Pick a random animal name */ diff --git a/tests/Feature/Console/Commands/RunLagoonCommandOnAppInstancesTest.php b/tests/Feature/Console/Commands/RunLagoonCommandOnAppInstancesTest.php new file mode 100644 index 0000000..5ed73a3 --- /dev/null +++ b/tests/Feature/Console/Commands/RunLagoonCommandOnAppInstancesTest.php @@ -0,0 +1,323 @@ + [ + 'ssh_server' => 'ssh.lagoon.test', + 'ssh_port' => '2222', + 'ssh_private_key_file' => base_path('tests/fixtures/lagoon-private-key'), + ]]); + + // Ensure key file exists for test + if (! file_exists(base_path('tests/fixtures'))) { + mkdir(base_path('tests/fixtures'), 0777, true); + } + file_put_contents(base_path('tests/fixtures/lagoon-private-key'), 'dummy-key'); + + $this->app->instance('polydock.lagoon.token_fetcher', fn () => 'fake-token'); + + $mock = \Mockery::mock(Client::class); + $mock->shouldReceive('setLagoonToken')->with('fake-token')->once(); + $mock->shouldReceive('initGraphqlClient')->once(); + $mock->shouldReceive('deployProjectEnvironmentByName') + ->with('project-1', 'main', []) + ->once() + ->andReturn(['data' => 'success']); + $mock->shouldReceive('deployProjectEnvironmentByName') + ->with('project-2', 'develop', []) + ->once() + ->andReturn(['data' => 'success']); + $this->app->instance(Client::class, $mock); + + // Arrange + $store = \App\Models\PolydockStore::factory()->create([ + 'lagoon_deploy_project_prefix' => 'test-prefix', + ]); + + $storeApp = PolydockStoreApp::factory()->create([ + 'polydock_store_id' => $store->id, + 'uuid' => 'test-app-uuid', + 'name' => 'Test App', + 'lagoon_deploy_branch' => 'main', + ]); + + // Manually create instances since factory might not exist or be complex + $instance1 = new PolydockAppInstance; + $instance1->polydock_store_app_id = $storeApp->id; + $instance1->name = 'test-instance-1'; + $instance1->status = PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED; + $instance1->app_type = 'test_app_type'; + $instance1->data = [ + 'lagoon-project-name' => 'project-1', + 'lagoon-deploy-branch' => 'main', + ]; + $instance1->saveQuietly(); // Skip events + + $instance2 = new PolydockAppInstance; + $instance2->polydock_store_app_id = $storeApp->id; + $instance2->name = 'test-instance-2'; + $instance2->status = PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED; + $instance2->app_type = 'test_app_type'; + $instance2->data = [ + 'lagoon-project-name' => 'project-2', + 'lagoon-deploy-branch' => 'develop', + ]; + $instance2->saveQuietly(); + + Process::fake(); + + // Act + $this->artisan('polydock:instances:run-lagoon-command', [ + 'app_uuid' => $storeApp->uuid, + '--force' => true, + ]) + ->expectsOutput('Found 2 running instances.') + ->expectsOutput('Authenticating with Lagoon (Serial Mode)...') + ->assertExitCode(0); + } + + public function test_it_runs_concurrently() + { + // Arrange + $store = \App\Models\PolydockStore::factory()->create([ + 'lagoon_deploy_project_prefix' => 'test-prefix', + ]); + + $storeApp = PolydockStoreApp::factory()->create([ + 'polydock_store_id' => $store->id, + 'uuid' => 'test-app-uuid', + 'lagoon_deploy_branch' => 'main', + ]); + + $instance1 = new PolydockAppInstance; + $instance1->polydock_store_app_id = $storeApp->id; + $instance1->name = 'test-instance-1'; + $instance1->status = PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED; + $instance1->app_type = 'test_app_type'; + $instance1->data = [ + 'lagoon-project-name' => 'project-1', + 'lagoon-deploy-branch' => 'main', + ]; + $instance1->saveQuietly(); + + $instance2 = new PolydockAppInstance; + $instance2->polydock_store_app_id = $storeApp->id; + $instance2->name = 'test-instance-2'; + $instance2->status = PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED; + $instance2->app_type = 'test_app_type'; + $instance2->data = [ + 'lagoon-project-name' => 'project-2', + 'lagoon-deploy-branch' => 'main', + ]; + $instance2->saveQuietly(); + + Process::fake(); + + // Act + $this->artisan('polydock:instances:run-lagoon-command', [ + 'app_uuid' => $storeApp->uuid, + '--force' => true, + '--concurrency' => 2, + ]) + ->expectsOutput('Running deployments concurrently on 2 instances (concurrency: 2)...') + ->assertExitCode(0); + + // Assert + Process::assertRan(function ($process) use ($instance1) { + $cmd = $process->command; + if (is_array($cmd)) { + return in_array("--instance-id={$instance1->id}", $cmd); + } + + return str_contains($cmd, "--instance-id={$instance1->id}"); + }); + Process::assertRan(function ($process) use ($instance2) { + $cmd = $process->command; + if (is_array($cmd)) { + return in_array("--instance-id={$instance2->id}", $cmd); + } + + return str_contains($cmd, "--instance-id={$instance2->id}"); + }); + } + + public function test_it_passes_variables_only_flag() + { + config(['polydock.service_providers_singletons.PolydockServiceProviderFTLagoon' => [ + 'ssh_server' => 'ssh.lagoon.test', + 'ssh_port' => '2222', + 'ssh_private_key_file' => base_path('tests/fixtures/lagoon-private-key'), + ]]); + + if (! file_exists(base_path('tests/fixtures'))) { + mkdir(base_path('tests/fixtures'), 0777, true); + } + file_put_contents(base_path('tests/fixtures/lagoon-private-key'), 'dummy-key'); + + $this->app->instance('polydock.lagoon.token_fetcher', fn () => 'fake-token'); + + $mock = \Mockery::mock(Client::class); + $mock->shouldReceive('setLagoonToken')->with('fake-token')->once(); + $mock->shouldReceive('initGraphqlClient')->once(); + $mock->shouldReceive('deployProjectEnvironmentByName') + ->with('project-1', 'main', ['LAGOON_VARIABLES_ONLY' => 'true']) + ->once() + ->andReturn(['data' => 'success']); + $this->app->instance(Client::class, $mock); + + // Arrange + $store = \App\Models\PolydockStore::factory()->create([ + 'lagoon_deploy_project_prefix' => 'test-prefix', + ]); + + $storeApp = PolydockStoreApp::factory()->create([ + 'polydock_store_id' => $store->id, + 'uuid' => 'test-app-uuid', + 'lagoon_deploy_branch' => 'main', + ]); + + $instance1 = new PolydockAppInstance; + $instance1->polydock_store_app_id = $storeApp->id; + $instance1->name = 'test-instance-1'; + $instance1->status = PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED; + $instance1->app_type = 'test_app_type'; + $instance1->data = [ + 'lagoon-project-name' => 'project-1', + 'lagoon-deploy-branch' => 'main', + ]; + $instance1->saveQuietly(); + + Process::fake(); + + // Act + $this->artisan('polydock:instances:run-lagoon-command', [ + 'app_uuid' => $storeApp->uuid, + '--force' => true, + '--variables-only' => true, + ]) + ->assertExitCode(0); + } + + public function test_it_skips_instances_missing_metadata() + { + // Arrange + $store = \App\Models\PolydockStore::factory()->create(); + + $storeApp = PolydockStoreApp::factory()->create([ + 'polydock_store_id' => $store->id, + 'uuid' => 'test-app-uuid', + ]); + + $instance1 = new PolydockAppInstance; + $instance1->polydock_store_app_id = $storeApp->id; + $instance1->name = 'test-instance-broken'; + $instance1->status = PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED; + $instance1->app_type = 'test_app_type'; + $instance1->data = []; // Missing lagoon-project-name + $instance1->saveQuietly(); + + config(['polydock.service_providers_singletons.PolydockServiceProviderFTLagoon' => [ + 'ssh_private_key_file' => base_path('tests/fixtures/lagoon-private-key'), + ]]); + if (! file_exists(base_path('tests/fixtures'))) { + mkdir(base_path('tests/fixtures'), 0777, true); + } + file_put_contents(base_path('tests/fixtures/lagoon-private-key'), 'dummy-key'); + + $this->app->instance('polydock.lagoon.token_fetcher', fn () => 'fake-token'); + + $mock = \Mockery::mock(Client::class); + $mock->shouldReceive('setLagoonToken')->with('fake-token')->once(); + $mock->shouldReceive('initGraphqlClient')->once(); + $mock->shouldNotReceive('deployProjectEnvironmentByName'); + $this->app->instance(Client::class, $mock); + + // Act + $this->artisan('polydock:instances:run-lagoon-command', [ + 'app_uuid' => $storeApp->uuid, + '--force' => true, + ]) + ->assertExitCode(0); + } + + public function test_it_asks_for_variables_only() + { + config(['polydock.service_providers_singletons.PolydockServiceProviderFTLagoon' => [ + 'ssh_server' => 'ssh.lagoon.test', + 'ssh_port' => '2222', + 'ssh_private_key_file' => base_path('tests/fixtures/lagoon-private-key'), + ]]); + + if (! file_exists(base_path('tests/fixtures'))) { + mkdir(base_path('tests/fixtures'), 0777, true); + } + file_put_contents(base_path('tests/fixtures/lagoon-private-key'), 'dummy-key'); + + $this->app->instance('polydock.lagoon.token_fetcher', fn () => 'fake-token'); + + $mock = \Mockery::mock(Client::class); + $mock->shouldReceive('setLagoonToken')->with('fake-token')->once(); + $mock->shouldReceive('initGraphqlClient')->once(); + $mock->shouldReceive('deployProjectEnvironmentByName') + ->with('project-1', 'main', ['LAGOON_VARIABLES_ONLY' => 'true']) + ->once() + ->andReturn(['data' => 'success']); + $this->app->instance(Client::class, $mock); + + // Arrange + $store = \App\Models\PolydockStore::factory()->create([ + 'lagoon_deploy_project_prefix' => 'test-prefix', + ]); + + $storeApp = PolydockStoreApp::factory()->create([ + 'polydock_store_id' => $store->id, + 'uuid' => 'test-app-uuid', + 'lagoon_deploy_branch' => 'main', + ]); + + $instance1 = new PolydockAppInstance; + $instance1->polydock_store_app_id = $storeApp->id; + $instance1->name = 'test-instance-1'; + $instance1->status = PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED; + $instance1->app_type = 'test_app_type'; + $instance1->data = [ + 'lagoon-project-name' => 'project-1', + 'lagoon-deploy-branch' => 'main', + ]; + $instance1->saveQuietly(); + + Process::fake(); + + // Act + $this->artisan('polydock:instances:run-lagoon-command', [ + 'app_uuid' => $storeApp->uuid, + // No force, no variables-only + ]) + ->expectsQuestion('Select instances to trigger deploy on:', [$instance1->id]) + ->expectsConfirmation('Are you sure you want to trigger deployments on 1 selected instances?', 'yes') + ->expectsConfirmation('Do you want to run a variables-only deployment?', 'yes') + ->expectsOutput('Found 1 running instances.') + ->expectsOutput('Authenticating with Lagoon (Serial Mode)...') + ->assertExitCode(0); + } +}