From 970e5daa576bedd40c53cc2f1b8d26694d5e0d0f Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Wed, 24 Dec 2025 20:14:32 +0000 Subject: [PATCH 1/5] chore: artisan add trigger lagoon deploy and run lagoon commands on app instances --- .../RunLagoonCommandOnAppInstances.php | 198 ++++++++++++++++++ .../TriggerLagoonDeployOnAppInstances.php | 190 +++++++++++++++++ 2 files changed, 388 insertions(+) create mode 100644 app/Console/Commands/RunLagoonCommandOnAppInstances.php create mode 100644 app/Console/Commands/TriggerLagoonDeployOnAppInstances.php diff --git a/app/Console/Commands/RunLagoonCommandOnAppInstances.php b/app/Console/Commands/RunLagoonCommandOnAppInstances.php new file mode 100644 index 0000000..e7743b6 --- /dev/null +++ b/app/Console/Commands/RunLagoonCommandOnAppInstances.php @@ -0,0 +1,198 @@ +argument('app_uuid'); + $commandToRun = $this->argument('cmd'); + $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; + } + + $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 run the command 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 run '{$commandToRun}' 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 ssh -p -e -- + // We escape the project and branch, but we assume the command is provided as desired. + // Note: If the command contains quotes, the user should escape them or wrap the whole arg in quotes in the shell. + + $fullCommand = sprintf('lagoon ssh -p %s -e %s -- %s', + escapeshellarg($projectName), + escapeshellarg($branch), + $commandToRun + ); + + // Log what we are doing + // $this->line("\nExecuting on {$projectName} ({$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/Console/Commands/TriggerLagoonDeployOnAppInstances.php b/app/Console/Commands/TriggerLagoonDeployOnAppInstances.php new file mode 100644 index 0000000..f1ce066 --- /dev/null +++ b/app/Console/Commands/TriggerLagoonDeployOnAppInstances.php @@ -0,0 +1,190 @@ +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; + } +} From 6c88a3c2c30237be62a91cf91fb6ce5ce062b106 Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Mon, 5 Jan 2026 16:53:00 +0100 Subject: [PATCH 2/5] chore: add a button to trigger a deploy from app instance --- .../TriggerLagoonDeployOnAppInstances.php | 5 +- .../Pages/ViewPolydockAppInstance.php | 61 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/app/Console/Commands/TriggerLagoonDeployOnAppInstances.php b/app/Console/Commands/TriggerLagoonDeployOnAppInstances.php index f1ce066..3f2ab37 100644 --- a/app/Console/Commands/TriggerLagoonDeployOnAppInstances.php +++ b/app/Console/Commands/TriggerLagoonDeployOnAppInstances.php @@ -6,6 +6,7 @@ use App\Models\PolydockStoreApp; use Illuminate\Console\Command; use Illuminate\Support\Facades\Process; + use function Laravel\Prompts\multiselect; class TriggerLagoonDeployOnAppInstances extends Command @@ -91,7 +92,7 @@ public function handle() $options = []; foreach ($instanceData as $id => $data) { $label = sprintf( - "%s %s %s %s", + '%s %s %s %s', str_pad($data['id'], $maxWidths['id']), str_pad($data['name'], $maxWidths['name']), str_pad($data['project'], $maxWidths['project']), @@ -102,7 +103,7 @@ public function handle() // Create a header for the prompt label $header = sprintf( - "%s %s %s %s", + '%s %s %s %s', str_pad('ID', $maxWidths['id']), str_pad('Name', $maxWidths['name']), str_pad('Lagoon Project', $maxWidths['project']), 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') From 6f153c001ec9e42d75502d6e1796f9afeb99940b Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Sun, 15 Feb 2026 18:46:49 +0100 Subject: [PATCH 3/5] Use upstream lib Ssh code --- .../RunLagoonCommandOnAppInstances.php | 192 +++++++++++++---- app/Models/PolydockAppInstance.php | 18 ++ .../RunLagoonCommandOnAppInstancesTest.php | 200 ++++++++++++++++++ 3 files changed, 374 insertions(+), 36 deletions(-) create mode 100644 tests/Feature/Console/Commands/RunLagoonCommandOnAppInstancesTest.php diff --git a/app/Console/Commands/RunLagoonCommandOnAppInstances.php b/app/Console/Commands/RunLagoonCommandOnAppInstances.php index e7743b6..2c9a62f 100644 --- a/app/Console/Commands/RunLagoonCommandOnAppInstances.php +++ b/app/Console/Commands/RunLagoonCommandOnAppInstances.php @@ -4,7 +4,9 @@ use App\Models\PolydockAppInstance; use App\Models\PolydockStoreApp; +use FreedomtechHosting\FtLagoonPhp\Ssh; use Illuminate\Console\Command; +use Illuminate\Process\Pool; use Illuminate\Support\Facades\Process; use function Laravel\Prompts\multiselect; @@ -20,7 +22,8 @@ class RunLagoonCommandOnAppInstances extends Command {app_uuid : The UUID of the store app} {cmd : The command to run on the remote instances} {--environment= : Optional environment override} - {--force : Force execution without confirmation}'; + {--force : Force execution without confirmation} + {--concurrency=1 : Number of concurrent processes to run (default: 1 for serial execution)}'; /** * The console command description. @@ -37,6 +40,7 @@ public function handle() $appUuid = $this->argument('app_uuid'); $commandToRun = $this->argument('cmd'); $envOverride = $this->option('environment'); + $concurrency = (int) $this->option('concurrency'); $storeApp = PolydockStoreApp::where('uuid', $appUuid)->first(); if (! $storeApp) { @@ -86,7 +90,7 @@ public function handle() $instanceData[$instance->id] = $data; foreach ($maxWidths as $key => $width) { - $maxWidths[$key] = max($width, strlen($data[$key])); + $maxWidths[$key] = max($width, strlen((string) $data[$key])); } } @@ -96,9 +100,9 @@ public function handle() $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']) + str_pad((string) $data['name'], $maxWidths['name']), + str_pad((string) $data['project'], $maxWidths['project']), + str_pad((string) $data['branch'], $maxWidths['branch']) ); $options[$id] = $label; } @@ -148,51 +152,167 @@ public function handle() $this->table($headers, $rows); } - $bar = $this->output->createProgressBar($count); - $bar->start(); + // 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; + + // Prepare temp files array to clean up later + $tempKeyFiles = []; + + try { + if ($concurrency > 1) { + $this->info("Running commands concurrently on {$count} instances (concurrency: {$concurrency})..."); + + $pool = Process::pool(function (Pool $pool) use ($instances, $envOverride, $commandToRun, $sshHost, $sshPort, $globalKeyFile, &$tempKeyFiles) { + foreach ($instances as $instance) { + $fullCommand = $this->getLagoonSshCommand($instance, $commandToRun, $envOverride, $sshHost, $sshPort, $globalKeyFile, $tempKeyFiles); + + if ($fullCommand) { + $pool->as($instance->id)->command($fullCommand); + } else { + $this->error("\nMissing project name or branch for instance {$instance->id} ({$instance->name})"); + } + } + }); + + try { + // Handle potential issues with Process::fake() returning an object that doesn't support concurrency + $pool->concurrency($concurrency); + } catch (\Throwable) { + // Ignore if method doesn't exist (e.g. in tests) + } - foreach ($instances as $instance) { - $projectName = $instance->getKeyValue('lagoon-project-name'); - $branch = $envOverride ?: $instance->getKeyValue('lagoon-deploy-branch'); + $poolResults = $pool->wait(); + + foreach ($poolResults as $instanceId => $result) { + $instance = $instances->find($instanceId); + // If instance was skipped (no command generated), result might not exist or need handling? + // Actually pool results only contain what was added to the pool. + // If we skipped adding it to the pool, it won't be in $poolResults. + if (! $instance) { + continue; + } + + $projectName = $instance->getKeyValue('lagoon-project-name'); + + if ($result->successful()) { + if ($this->output->isVerbose()) { + $this->info("\n[SUCCESS] {$projectName}: ".trim((string) $result->output())); + } + } else { + $this->error("\n[FAILED] {$projectName}: ".trim((string) $result->errorOutput())); + } + } - if (empty($projectName) || empty($branch)) { - $this->error("\nMissing project name or branch for instance {$instance->id} ({$instance->name})"); - $bar->advance(); + $this->info('Done.'); - continue; + return 0; } - // Construct Lagoon CLI command - // lagoon ssh -p -e -- - // We escape the project and branch, but we assume the command is provided as desired. - // Note: If the command contains quotes, the user should escape them or wrap the whole arg in quotes in the shell. + $bar = $this->output->createProgressBar($count); + $bar->start(); - $fullCommand = sprintf('lagoon ssh -p %s -e %s -- %s', - escapeshellarg($projectName), - escapeshellarg($branch), - $commandToRun - ); + foreach ($instances as $instance) { + $projectName = $instance->getKeyValue('lagoon-project-name'); + $fullCommand = $this->getLagoonSshCommand($instance, $commandToRun, $envOverride, $sshHost, $sshPort, $globalKeyFile, $tempKeyFiles); + + if (! $fullCommand) { + $this->error("\nMissing project name or branch for instance {$instance->id} ({$instance->name})"); + $bar->advance(); - // Log what we are doing - // $this->line("\nExecuting on {$projectName} ({$branch})..."); + continue; + } + + // Log what we are doing + // $this->line("\nExecuting on {$projectName} ({$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(); + } - $result = Process::run($fullCommand); + $bar->finish(); + $this->newLine(); + $this->info('Done.'); - if ($result->successful()) { - if ($this->output->isVerbose()) { - $this->info("\n[SUCCESS] {$projectName}: ".trim($result->output())); + return 0; + } finally { + // Cleanup temp files + foreach ($tempKeyFiles as $file) { + if (file_exists($file)) { + @unlink($file); } - } else { - $this->error("\n[FAILED] {$projectName}: ".trim($result->errorOutput())); } + } + } + + /** + * Helper to construct the Lagoon SSH command using the library. + * Returns null if project name or branch is missing. + */ + protected function getLagoonSshCommand( + PolydockAppInstance $instance, + string $commandToRun, + ?string $envOverride, + string $sshHost, + string $sshPort, + ?string $globalKeyFile, + array &$tempKeyFiles + ): ?string { + $projectName = $instance->getKeyValue('lagoon-project-name'); + $branch = $envOverride ?: $instance->getKeyValue('lagoon-deploy-branch'); + + if (empty($projectName) || empty($branch)) { + return null; + } + + // Construct SSH user (project-environment) + // Replace / with - in branch name as per Lagoon convention + $sshUser = $projectName.'-'.str_replace('/', '-', $branch); + + // Determine private key + $privateKeyContent = $instance->getKeyValue('lagoon-deploy-private-key'); + $privateKeyFile = $globalKeyFile; + + if (! empty($privateKeyContent)) { + // Create temp file for the key + $tempFile = tempnam(sys_get_temp_dir(), 'lagoon_key_'); + if ($tempFile === false) { + $this->error("Failed to create temporary key file for instance {$instance->id}"); + + return null; + } + file_put_contents($tempFile, $privateKeyContent); + chmod($tempFile, 0600); // Secure the key file + $tempKeyFiles[] = $tempFile; + $privateKeyFile = $tempFile; + } + + if (empty($privateKeyFile)) { + $this->error("No private key found for instance {$instance->id} and no global key configured."); - $bar->advance(); + return null; } - $bar->finish(); - $this->newLine(); - $this->info('Done.'); + try { + // Use the library to create the SSH command + $ssh = Ssh::createLagoonConfigured($sshUser, $sshHost, $sshPort, $privateKeyFile); - return 0; + return $ssh->getCommandForExecute($commandToRun); + } catch (\Exception $e) { + $this->error("Failed to create SSH command for instance {$instance->id}: {$e->getMessage()}"); + + return null; + } } } 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..fcb406c --- /dev/null +++ b/tests/Feature/Console/Commands/RunLagoonCommandOnAppInstancesTest.php @@ -0,0 +1,200 @@ + [ + 'ssh_server' => 'ssh.lagoon.test', + 'ssh_port' => '32222', + ]]); + + // 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', + 'lagoon-deploy-private-key' => 'test-key', + ]; + $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', + 'lagoon-deploy-private-key' => 'test-key', + ]; + $instance2->saveQuietly(); + + Process::fake(); + + // Act + $this->artisan('polydock:instances:run-lagoon-command', [ + 'app_uuid' => $storeApp->uuid, + 'cmd' => 'ls -la', + '--force' => true, + ]) + ->expectsOutput('Found 2 running instances.') + ->assertExitCode(0); + + // Assert + Process::assertRan(function ($process, $result) { + $cmd = (string) $process->command; + + return str_contains($cmd, 'ssh') && + str_contains($cmd, 'project-1-main@ssh.lagoon.test') && + str_contains($cmd, 'service=cli container=cli ls -la'); + }); + + Process::assertRan(function ($process, $result) { + $cmd = (string) $process->command; + + return str_contains($cmd, 'ssh') && + str_contains($cmd, 'project-2-develop@ssh.lagoon.test') && + str_contains($cmd, 'service=cli container=cli ls -la'); + }); + } + + public function test_it_runs_commands_concurrently_with_concurrency_option() + { + config(['polydock.service_providers_singletons.PolydockServiceProviderFTLagoon' => [ + 'ssh_server' => 'ssh.lagoon.test', + 'ssh_port' => '32222', + ]]); + + // 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', + 'lagoon-deploy-private-key' => 'test-key', + ]; + $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', + 'lagoon-deploy-private-key' => 'test-key', + ]; + $instance2->saveQuietly(); + + Process::fake(); + + // Act + $this->artisan('polydock:instances:run-lagoon-command', [ + 'app_uuid' => $storeApp->uuid, + 'cmd' => 'whoami', + '--force' => true, + '--concurrency' => 2, + ]) + ->expectsOutput('Running commands concurrently on 2 instances (concurrency: 2)...') + ->assertExitCode(0); + + // Assert + Process::assertRan(function ($process, $result) { + $cmd = (string) $process->command; + + return str_contains($cmd, 'ssh') && + str_contains($cmd, 'project-1-main@ssh.lagoon.test') && + str_contains($cmd, 'service=cli container=cli whoami'); + }); + + Process::assertRan(function ($process, $result) { + $cmd = (string) $process->command; + + return str_contains($cmd, 'ssh') && + str_contains($cmd, 'project-2-main@ssh.lagoon.test') && + str_contains($cmd, 'service=cli container=cli whoami'); + }); + } + + 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(); + + Process::fake(); + + // Act + $this->artisan('polydock:instances:run-lagoon-command', [ + 'app_uuid' => $storeApp->uuid, + 'cmd' => 'ls', + '--force' => true, + ]) + ->assertExitCode(0); + + Process::assertNotRan(fn ($process) => str_contains((string) $process->command, 'ssh ')); + } +} From 579e6f639abcbf187a75e782d14d1e916432c7b8 Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Sun, 15 Feb 2026 19:34:29 +0100 Subject: [PATCH 4/5] chore: add support for buildVariables --- .../RunLagoonCommandOnAppInstances.php | 315 +++++++++++------- .../RunLagoonCommandOnAppInstancesTest.php | 227 ++++++++++--- 2 files changed, 365 insertions(+), 177 deletions(-) diff --git a/app/Console/Commands/RunLagoonCommandOnAppInstances.php b/app/Console/Commands/RunLagoonCommandOnAppInstances.php index 2c9a62f..10f797b 100644 --- a/app/Console/Commands/RunLagoonCommandOnAppInstances.php +++ b/app/Console/Commands/RunLagoonCommandOnAppInstances.php @@ -4,6 +4,7 @@ use App\Models\PolydockAppInstance; use App\Models\PolydockStoreApp; +use FreedomtechHosting\FtLagoonPhp\Client; use FreedomtechHosting\FtLagoonPhp\Ssh; use Illuminate\Console\Command; use Illuminate\Process\Pool; @@ -20,17 +21,18 @@ class RunLagoonCommandOnAppInstances extends Command */ protected $signature = 'polydock:instances:run-lagoon-command {app_uuid : The UUID of the store app} - {cmd : The command to run on the remote instances} {--environment= : Optional environment override} {--force : Force execution without confirmation} - {--concurrency=1 : Number of concurrent processes to run (default: 1 for serial execution)}'; + {--variables-only : Only deploy variables} + {--concurrency=1 : Number of concurrent processes to run (default: 1 for serial execution)} + {--instance-id= : (Internal) Run for a specific instance ID}'; /** * The console command description. * * @var string */ - protected $description = 'Run a command via Lagoon CLI on all running instances of a specific app'; + protected $description = 'Trigger a deployment via Lagoon API on all running instances of a specific app'; /** * Execute the console command. @@ -38,9 +40,39 @@ class RunLagoonCommandOnAppInstances extends Command public function handle() { $appUuid = $this->argument('app_uuid'); - $commandToRun = $this->argument('cmd'); $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) { @@ -116,7 +148,7 @@ public function handle() ); $selectedIds = multiselect( - label: 'Select instances to run the command on:', + label: 'Select instances to trigger deploy on:', options: $options, default: array_keys($options), scroll: 15, @@ -133,11 +165,15 @@ public function handle() $instances = $instances->whereIn('id', $selectedIds); $count = $instances->count(); - if (! $this->confirm("Are you sure you want to run '{$commandToRun}' on {$count} selected instances?")) { + 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']; @@ -152,167 +188,196 @@ public function handle() $this->table($headers, $rows); } - // 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; - - // Prepare temp files array to clean up later - $tempKeyFiles = []; + // 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', + ]; - try { - if ($concurrency > 1) { - $this->info("Running commands concurrently on {$count} instances (concurrency: {$concurrency})..."); - - $pool = Process::pool(function (Pool $pool) use ($instances, $envOverride, $commandToRun, $sshHost, $sshPort, $globalKeyFile, &$tempKeyFiles) { - foreach ($instances as $instance) { - $fullCommand = $this->getLagoonSshCommand($instance, $commandToRun, $envOverride, $sshHost, $sshPort, $globalKeyFile, $tempKeyFiles); - - if ($fullCommand) { - $pool->as($instance->id)->command($fullCommand); - } else { - $this->error("\nMissing project name or branch for instance {$instance->id} ({$instance->name})"); - } - } - }); - - try { - // Handle potential issues with Process::fake() returning an object that doesn't support concurrency - $pool->concurrency($concurrency); - } catch (\Throwable) { - // Ignore if method doesn't exist (e.g. in tests) - } + if ($envOverride) { + $commandBase[] = "--environment={$envOverride}"; + } + if ($variablesOnly) { + $commandBase[] = '--variables-only'; + } - $poolResults = $pool->wait(); - - foreach ($poolResults as $instanceId => $result) { - $instance = $instances->find($instanceId); - // If instance was skipped (no command generated), result might not exist or need handling? - // Actually pool results only contain what was added to the pool. - // If we skipped adding it to the pool, it won't be in $poolResults. - if (! $instance) { - continue; - } - - $projectName = $instance->getKeyValue('lagoon-project-name'); - - if ($result->successful()) { - if ($this->output->isVerbose()) { - $this->info("\n[SUCCESS] {$projectName}: ".trim((string) $result->output())); - } - } else { - $this->error("\n[FAILED] {$projectName}: ".trim((string) $result->errorOutput())); - } + $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); } + }); - $this->info('Done.'); - - return 0; + try { + $pool->concurrency($concurrency); + } catch (\Throwable) { + // Ignore if method doesn't exist } - $bar = $this->output->createProgressBar($count); - $bar->start(); - - foreach ($instances as $instance) { - $projectName = $instance->getKeyValue('lagoon-project-name'); - $fullCommand = $this->getLagoonSshCommand($instance, $commandToRun, $envOverride, $sshHost, $sshPort, $globalKeyFile, $tempKeyFiles); - - if (! $fullCommand) { - $this->error("\nMissing project name or branch for instance {$instance->id} ({$instance->name})"); - $bar->advance(); + $poolResults = $pool->wait(); + foreach ($poolResults as $instanceId => $result) { + $instance = $instances->find($instanceId); + if (! $instance) { continue; } - // Log what we are doing - // $this->line("\nExecuting on {$projectName} ({$branch})..."); - - $result = Process::run($fullCommand); + $projectName = $instance->getKeyValue('lagoon-project-name'); if ($result->successful()) { - if ($this->output->isVerbose()) { - $this->info("\n[SUCCESS] {$projectName}: ".trim($result->output())); - } + // Output already contains "SUCCESS" or "FAILED" messages from child process + $this->output->write($result->output()); } else { - $this->error("\n[FAILED] {$projectName}: ".trim($result->errorOutput())); + $this->error("\n[FAILED] {$projectName} (Process Error): ".trim((string) $result->errorOutput())); } - - $bar->advance(); } - $bar->finish(); - $this->newLine(); $this->info('Done.'); return 0; - } finally { - // Cleanup temp files - foreach ($tempKeyFiles as $file) { - if (file_exists($file)) { - @unlink($file); - } + } + + // 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; } - /** - * Helper to construct the Lagoon SSH command using the library. - * Returns null if project name or branch is missing. - */ - protected function getLagoonSshCommand( + protected function deployToInstance( PolydockAppInstance $instance, - string $commandToRun, - ?string $envOverride, - string $sshHost, - string $sshPort, - ?string $globalKeyFile, - array &$tempKeyFiles - ): ?string { + 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)) { - return null; + $this->error("\nMissing project name or branch for instance {$instance->id} ({$instance->name})"); + + return 1; } - // Construct SSH user (project-environment) - // Replace / with - in branch name as per Lagoon convention - $sshUser = $projectName.'-'.str_replace('/', '-', $branch); + // 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.'); - // Determine private key - $privateKeyContent = $instance->getKeyValue('lagoon-deploy-private-key'); - $privateKeyFile = $globalKeyFile; + return 1; + } - if (! empty($privateKeyContent)) { - // Create temp file for the key - $tempFile = tempnam(sys_get_temp_dir(), 'lagoon_key_'); - if ($tempFile === false) { - $this->error("Failed to create temporary key file for instance {$instance->id}"); + $token = $this->getLagoonToken($clientConfig); + if (empty($token)) { + $this->error("Failed to retrieve Lagoon API token for instance {$instance->id}."); - return null; + 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; } - file_put_contents($tempFile, $privateKeyContent); - chmod($tempFile, 0600); // Secure the key file - $tempKeyFiles[] = $tempFile; - $privateKeyFile = $tempFile; } - if (empty($privateKeyFile)) { - $this->error("No private key found for instance {$instance->id} and no global key configured."); - - return null; + $buildVars = []; + if ($variablesOnly) { + $buildVars['LAGOON_VARIABLES_ONLY'] = 'true'; } try { - // Use the library to create the SSH command - $ssh = Ssh::createLagoonConfigured($sshUser, $sshHost, $sshPort, $privateKeyFile); + $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 $ssh->getCommandForExecute($commandToRun); + return 0; + } } catch (\Exception $e) { - $this->error("Failed to create SSH command for instance {$instance->id}: {$e->getMessage()}"); + $this->error("\n[FAILED] {$projectName}: {$e->getMessage()}"); - return null; + 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/tests/Feature/Console/Commands/RunLagoonCommandOnAppInstancesTest.php b/tests/Feature/Console/Commands/RunLagoonCommandOnAppInstancesTest.php index fcb406c..5ed73a3 100644 --- a/tests/Feature/Console/Commands/RunLagoonCommandOnAppInstancesTest.php +++ b/tests/Feature/Console/Commands/RunLagoonCommandOnAppInstancesTest.php @@ -4,6 +4,7 @@ use App\Models\PolydockAppInstance; use App\Models\PolydockStoreApp; +use FreedomtechHosting\FtLagoonPhp\Client; use FreedomtechHosting\PolydockApp\Enums\PolydockAppInstanceStatus; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Process; @@ -13,22 +14,41 @@ class RunLagoonCommandOnAppInstancesTest extends TestCase { use RefreshDatabase; - protected function setUp(): void + protected function tearDown(): void { - parent::setUp(); - - // Disable events to avoid complex setup requirements unless needed - // PolydockAppInstance::unsetEventDispatcher(); - // Actually, the creating event sets vital data like 'data' array. We probably need it or simulate it. + \Mockery::close(); + parent::tearDown(); } - public function test_it_runs_commands_serially_by_default() + public function test_it_runs_serially_by_default() { config(['polydock.service_providers_singletons.PolydockServiceProviderFTLagoon' => [ 'ssh_server' => 'ssh.lagoon.test', - 'ssh_port' => '32222', + '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', @@ -50,7 +70,6 @@ public function test_it_runs_commands_serially_by_default() $instance1->data = [ 'lagoon-project-name' => 'project-1', 'lagoon-deploy-branch' => 'main', - 'lagoon-deploy-private-key' => 'test-key', ]; $instance1->saveQuietly(); // Skip events @@ -62,7 +81,6 @@ public function test_it_runs_commands_serially_by_default() $instance2->data = [ 'lagoon-project-name' => 'project-2', 'lagoon-deploy-branch' => 'develop', - 'lagoon-deploy-private-key' => 'test-key', ]; $instance2->saveQuietly(); @@ -71,37 +89,15 @@ public function test_it_runs_commands_serially_by_default() // Act $this->artisan('polydock:instances:run-lagoon-command', [ 'app_uuid' => $storeApp->uuid, - 'cmd' => 'ls -la', '--force' => true, ]) ->expectsOutput('Found 2 running instances.') + ->expectsOutput('Authenticating with Lagoon (Serial Mode)...') ->assertExitCode(0); - - // Assert - Process::assertRan(function ($process, $result) { - $cmd = (string) $process->command; - - return str_contains($cmd, 'ssh') && - str_contains($cmd, 'project-1-main@ssh.lagoon.test') && - str_contains($cmd, 'service=cli container=cli ls -la'); - }); - - Process::assertRan(function ($process, $result) { - $cmd = (string) $process->command; - - return str_contains($cmd, 'ssh') && - str_contains($cmd, 'project-2-develop@ssh.lagoon.test') && - str_contains($cmd, 'service=cli container=cli ls -la'); - }); } - public function test_it_runs_commands_concurrently_with_concurrency_option() + public function test_it_runs_concurrently() { - config(['polydock.service_providers_singletons.PolydockServiceProviderFTLagoon' => [ - 'ssh_server' => 'ssh.lagoon.test', - 'ssh_port' => '32222', - ]]); - // Arrange $store = \App\Models\PolydockStore::factory()->create([ 'lagoon_deploy_project_prefix' => 'test-prefix', @@ -121,7 +117,6 @@ public function test_it_runs_commands_concurrently_with_concurrency_option() $instance1->data = [ 'lagoon-project-name' => 'project-1', 'lagoon-deploy-branch' => 'main', - 'lagoon-deploy-private-key' => 'test-key', ]; $instance1->saveQuietly(); @@ -133,7 +128,6 @@ public function test_it_runs_commands_concurrently_with_concurrency_option() $instance2->data = [ 'lagoon-project-name' => 'project-2', 'lagoon-deploy-branch' => 'main', - 'lagoon-deploy-private-key' => 'test-key', ]; $instance2->saveQuietly(); @@ -142,31 +136,88 @@ public function test_it_runs_commands_concurrently_with_concurrency_option() // Act $this->artisan('polydock:instances:run-lagoon-command', [ 'app_uuid' => $storeApp->uuid, - 'cmd' => 'whoami', '--force' => true, '--concurrency' => 2, ]) - ->expectsOutput('Running commands concurrently on 2 instances (concurrency: 2)...') + ->expectsOutput('Running deployments concurrently on 2 instances (concurrency: 2)...') ->assertExitCode(0); // Assert - Process::assertRan(function ($process, $result) { - $cmd = (string) $process->command; + 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, 'ssh') && - str_contains($cmd, 'project-1-main@ssh.lagoon.test') && - str_contains($cmd, 'service=cli container=cli whoami'); + 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); + } - Process::assertRan(function ($process, $result) { - $cmd = (string) $process->command; - - return str_contains($cmd, 'ssh') && - str_contains($cmd, 'project-2-main@ssh.lagoon.test') && - str_contains($cmd, 'service=cli container=cli whoami'); + 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 @@ -185,16 +236,88 @@ public function test_it_skips_instances_missing_metadata() $instance1->data = []; // Missing lagoon-project-name $instance1->saveQuietly(); - Process::fake(); + 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, - 'cmd' => 'ls', '--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', + ]); - Process::assertNotRan(fn ($process) => str_contains((string) $process->command, 'ssh ')); + $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); } } From d904296594a114ee66efd282b68dd8f49e864e3c Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Tue, 17 Feb 2026 00:46:47 +0100 Subject: [PATCH 5/5] fix: ensure null is not passed to markdown call --- .../Admin/Resources/PolydockAppInstanceResource.php | 7 ++++++- .../Pages/ViewUserRemoteRegistration.php | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) 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/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)