diff --git a/app/Http/Controllers/Api/AuthenticatedApiController.php b/app/Http/Controllers/Api/AuthenticatedApiController.php index 838957f2..6381eb37 100644 --- a/app/Http/Controllers/Api/AuthenticatedApiController.php +++ b/app/Http/Controllers/Api/AuthenticatedApiController.php @@ -49,6 +49,7 @@ public function getStoreApps(): JsonResponse 'author' => $app->author, 'available_for_trials' => $app->available_for_trials, 'app_status' => $app->status?->value, + 'git_url' => $app->lagoon_deploy_git, 'store' => [ 'name' => $app->store->name, 'status' => $app->store->status?->value, @@ -135,13 +136,15 @@ public function getInstances(Request $request): JsonResponse /** * Create/Provision a new instance * - * Deploy a new PolydockStoreApp instance. If the user associated with the email does not exist, a new user account will automatically be created. + * Deploy a new PolydockStoreApp instance. If the user associated with the email does not exist, a new user account will automatically be created using the provided first and last names. For existing users, names will be updated only if current values are placeholders or empty. * * @group External API * * @subgroup Instance Management * * @bodyParam email email required The email address of the user. Example: new.user@example.com + * @bodyParam first_name string optional The first name of the user. Example: Jane + * @bodyParam last_name string optional The last name of the user. Example: Doe * @bodyParam storeAppId string required The UUID of the store app to provision. Example: 3a105da1-9c87-43ca-9ac8-72787fc5e315 * @bodyParam name string optional The display name for this instance. Defaults to lagoon-project-name if not provided. Example: "My awesome instance" * @bodyParam secret object optional Sensitive AI and VectorDB credentials. Example: {"ai": {"llm_url": "https://llm", "api_key": "sk-123"}, "vector": {"db_host": "localhost", "db_port": 5432, "db_name": "db_d1234", "db_user": "admin", "db_pass": "pass"}} @@ -169,6 +172,8 @@ public function createInstance(Request $request): JsonResponse { $request->validate([ 'email' => 'required|email', + 'first_name' => 'nullable|string|max:255', + 'last_name' => 'nullable|string|max:255', 'storeAppId' => 'required|string|exists:polydock_store_apps,uuid', 'name' => 'nullable|string|max:255', 'secret' => 'nullable|array', @@ -207,12 +212,32 @@ function ($attribute, $value, $fail) { $user = User::firstOrCreate( ['email' => $email], [ - 'first_name' => 'Auto', // Dummy default - 'last_name' => 'User', + 'first_name' => $request->filled('first_name') ? $request->input('first_name') : 'Auto', // Dummy default + 'last_name' => $request->filled('last_name') ? $request->input('last_name') : 'User', 'password' => Hash::make(Str::random(32)), ] ); + // Update user names cautiously for existing users: + // Only replace placeholder/empty names, and do not arbitrarily overwrite real names. + if (! $user->wasRecentlyCreated) { + if ( + $request->filled('first_name') && + (\is_null($user->first_name) || $user->first_name === '' || $user->first_name === 'Auto') + ) { + $user->first_name = $request->input('first_name'); + } + if ( + $request->filled('last_name') && + (\is_null($user->last_name) || $user->last_name === '' || $user->last_name === 'User') + ) { + $user->last_name = $request->input('last_name'); + } + } + if ($user->isDirty()) { + $user->save(); + } + // Find or create a default primary user group for this user if they don't have one $primaryGroup = $user->primaryGroups()->first(); if (! $primaryGroup) { @@ -233,6 +258,14 @@ function ($attribute, $value, $fail) { // Use the existing allocation mechanism or create a new instance $instance = UserGroup::getNewAppInstanceForThisAppForThisGroup($storeApp, $primaryGroup, $name ? (string) $name : null); + // Add user information to the app instance data - this enables claiming + $instance->data = array_merge($instance->data ?? [], [ + 'user-email' => $user->email, + 'user-first-name' => $user->first_name, + 'user-last-name' => $user->last_name, + ]); + $instance->save(); + // Handle top-level secret if provided if ($request->filled('secret')) { $instance->storeKeyValue('secret', $request->input('secret')); diff --git a/app/Jobs/ProcessPolydockAppInstanceJobs/ProgressToNextStageJob.php b/app/Jobs/ProcessPolydockAppInstanceJobs/ProgressToNextStageJob.php index 7944a83f..f2036e5f 100644 --- a/app/Jobs/ProcessPolydockAppInstanceJobs/ProgressToNextStageJob.php +++ b/app/Jobs/ProcessPolydockAppInstanceJobs/ProgressToNextStageJob.php @@ -73,7 +73,7 @@ public function handle(): void ->save(); break; case PolydockAppInstanceStatus::POST_DEPLOY_COMPLETED: - if ($appInstance->remoteRegistration) { + if ($appInstance->remoteRegistration || $appInstance->getKeyValue('user-email')) { Log::info('Progressing app instance ' .$appInstance->id .' to next stage from POST_DEPLOY_COMPLETED to PENDING_POLYDOCK_CLAIM'); diff --git a/app/Services/LagoonClientService.php b/app/Services/LagoonClientService.php index f294a756..386d762c 100644 --- a/app/Services/LagoonClientService.php +++ b/app/Services/LagoonClientService.php @@ -4,6 +4,7 @@ use FreedomtechHosting\FtLagoonPhp\Client; use FreedomtechHosting\FtLagoonPhp\Ssh; +use Symfony\Component\Process\Process; class LagoonClientService { @@ -17,12 +18,16 @@ public function getAuthenticatedClient(): Client $clientConfig = $this->getClientConfig(); if (! $clientConfig['ssh_private_key_file'] || ! file_exists($clientConfig['ssh_private_key_file'])) { - throw new \Exception('Global SSH private key not found.'); + $msg = 'Global SSH private key not found at: '.($clientConfig['ssh_private_key_file'] ?: 'not set'); + \Log::error($msg); + throw new \Exception($msg); } $token = $this->getLagoonToken($clientConfig); if (empty($token)) { - throw new \Exception('Failed to retrieve Lagoon API token.'); + $msg = 'Failed to retrieve Lagoon API token. Ensure the SSH key at '.$clientConfig['ssh_private_key_file'].' is valid and authorized in Lagoon.'; + \Log::error($msg); + throw new \Exception($msg); } return $this->buildClientWithToken($clientConfig, $token); @@ -54,12 +59,73 @@ public function getClientConfig(): array { $sshConfig = config('polydock.service_providers_singletons.PolydockServiceProviderFTLagoon', []); + // Primary source: config (which reads FTLAGOON_PRIVATE_KEY_FILE) + $keyFile = $sshConfig['ssh_private_key_file'] ?? null; + + // Fallback to POLYDOCK_LAGOON_DEPLOY_PRIVATE_KEY_FILE if first is missing or default + if (empty($keyFile) || $keyFile === 'tests/fixtures/lagoon-private-key') { + $keyFile = config('polydock.lagoon_deploy_private_key_file'); + } + + // Final fallback to system default + if (empty($keyFile)) { + $home = getenv('HOME'); + if ($home === false || $home === '') { + $home = $_SERVER['HOME'] ?? null; + } + + if (! empty($home)) { + $keyFile = rtrim($home, '/').'/.ssh/id_rsa'; + } else { + // Leave $keyFile empty; it will be validated later in getAuthenticatedClient() + $keyFile = null; + } + } + + // Fallback or override via content if provided (from config, not env()) + $keyContent = config('polydock.ftlagoon_private_key_content'); + + if ($keyContent) { + // Use storage/app/ssh as a safe default for writing the temp key + $baseDir = storage_path('app/ssh'); + + $tempKeyFile = $baseDir.'/env_id_rsa'; + + $dir = dirname($tempKeyFile); + if (! is_dir($dir)) { + if (! @mkdir($dir, 0700, true) && ! is_dir($dir)) { + throw new \RuntimeException('Failed to create SSH key directory: '.$dir); + } + } + + if (! file_exists($tempKeyFile) || file_get_contents($tempKeyFile) !== $keyContent) { + $tmpFile = $tempKeyFile.'.tmp'; + + $bytesWritten = @file_put_contents($tmpFile, $keyContent, LOCK_EX); + if ($bytesWritten === false) { + @unlink($tmpFile); + throw new \RuntimeException('Failed to write SSH private key to temporary file: '.$tmpFile); + } + + if (! @chmod($tmpFile, 0600)) { + @unlink($tmpFile); + throw new \RuntimeException('Failed to set permissions on SSH private key file: '.$tmpFile); + } + + if (! @rename($tmpFile, $tempKeyFile)) { + @unlink($tmpFile); + throw new \RuntimeException('Failed to move SSH private key file into place: '.$tempKeyFile); + } + } + $keyFile = $tempKeyFile; + } + return [ 'ssh_user' => $sshConfig['ssh_user'] ?? 'lagoon', 'ssh_server' => $sshConfig['ssh_server'] ?? 'ssh.lagoon.amazeeio.cloud', 'ssh_port' => $sshConfig['ssh_port'] ?? '32222', 'endpoint' => $sshConfig['endpoint'] ?? 'https://api.lagoon.amazeeio.cloud/graphql', - 'ssh_private_key_file' => $sshConfig['ssh_private_key_file'] ?? getenv('HOME').'/.ssh/id_rsa', + 'ssh_private_key_file' => $keyFile, ]; } @@ -81,6 +147,26 @@ public function getLagoonToken(?array $config = null): string privateKeyFile: $config['ssh_private_key_file'] ); - return $ssh->executeLagoonGetToken(); + // Add IdentitiesOnly to prevent fallback to local keys, making it fail faster and more predictably + $ssh->addExtraOption('-o IdentitiesOnly=yes'); + + $sshCommand = $ssh->getTokenCommand(); + $process = Process::fromShellCommandline($sshCommand); + $process->setTimeout(30); + $process->run(); + + if ($process->isSuccessful()) { + return ltrim(rtrim($process->getOutput())); + } + + \Log::error('Lagoon SSH token fetch failed', [ + 'exit_code' => $process->getExitCode(), + 'output' => $process->getOutput(), + 'error' => $process->getErrorOutput(), + 'command' => $sshCommand, + 'key_file' => $config['ssh_private_key_file'], + ]); + + return ''; } } diff --git a/tests/Feature/Api/AuthenticatedApiTest.php b/tests/Feature/Api/AuthenticatedApiTest.php index df269faa..cae171c1 100644 --- a/tests/Feature/Api/AuthenticatedApiTest.php +++ b/tests/Feature/Api/AuthenticatedApiTest.php @@ -112,6 +112,7 @@ public function test_get_store_apps_returns_formatted_data(): void 'name', 'description', 'author', // etc... + 'git_url', 'store' => [ 'name', 'status', @@ -124,6 +125,7 @@ public function test_get_store_apps_returns_formatted_data(): void $this->assertCount(1, $response->json('data')); $this->assertEquals('Test App', $response->json('data.0.name')); $this->assertEquals('Test Store', $response->json('data.0.store.name')); + $this->assertEquals('git@github.com:example/repo.git', $response->json('data.0.git_url')); } public function test_get_instances_returns_user_instances(): void @@ -172,6 +174,98 @@ public function test_create_instance_provisions_instance_and_creates_user(): voi $this->assertEquals(1, $instanceCount); } + public function test_create_instance_provisions_instance_and_creates_user_with_names(): void + { + Sanctum::actingAs($this->user, ['instances.write']); + + $newEmail = 'named.user@example.com'; + $firstName = 'Jane'; + $lastName = 'Doe'; + + $response = $this->postJson('/api/instance', [ + 'email' => $newEmail, + 'first_name' => $firstName, + 'last_name' => $lastName, + 'storeAppId' => $this->storeApp->uuid, + ]); + + $response->assertCreated(); + + $newUser = User::where('email', $newEmail)->first(); + $this->assertNotNull($newUser); + $this->assertEquals($firstName, $newUser->first_name); + $this->assertEquals($lastName, $newUser->last_name); + + $instance = PolydockAppInstance::where('uuid', $response->json('data.uuid'))->first(); + $this->assertEquals($newEmail, $instance->data['user-email']); + $this->assertEquals($firstName, $instance->data['user-first-name']); + $this->assertEquals($lastName, $instance->data['user-last-name']); + } + + public function test_create_instance_updates_placeholder_names_for_existing_user(): void + { + Sanctum::actingAs($this->user, ['instances.write']); + + $existingUser = User::factory()->create([ + 'email' => 'placeholder@example.com', + 'first_name' => 'Auto', + 'last_name' => 'User', + ]); + + $firstName = 'Jane'; + $lastName = 'Doe'; + + $response = $this->postJson('/api/instance', [ + 'email' => $existingUser->email, + 'first_name' => $firstName, + 'last_name' => $lastName, + 'storeAppId' => $this->storeApp->uuid, + ]); + + $response->assertCreated(); + + $existingUser->refresh(); + $this->assertEquals($firstName, $existingUser->first_name); + $this->assertEquals($lastName, $existingUser->last_name); + + $instance = PolydockAppInstance::where('uuid', $response->json('data.uuid'))->first(); + $this->assertEquals($firstName, $instance->data['user-first-name']); + $this->assertEquals($lastName, $instance->data['user-last-name']); + } + + public function test_create_instance_does_not_overwrite_real_names_for_existing_user(): void + { + Sanctum::actingAs($this->user, ['instances.write']); + + $originalFirstName = 'John'; + $originalLastName = 'Smith'; + $existingUser = User::factory()->create([ + 'email' => 'real.name@example.com', + 'first_name' => $originalFirstName, + 'last_name' => $originalLastName, + ]); + + $newFirstName = 'Jane'; + $newLastName = 'Doe'; + + $response = $this->postJson('/api/instance', [ + 'email' => $existingUser->email, + 'first_name' => $newFirstName, + 'last_name' => $newLastName, + 'storeAppId' => $this->storeApp->uuid, + ]); + + $response->assertCreated(); + + $existingUser->refresh(); + $this->assertEquals($originalFirstName, $existingUser->first_name); + $this->assertEquals($originalLastName, $existingUser->last_name); + + $instance = PolydockAppInstance::where('uuid', $response->json('data.uuid'))->first(); + $this->assertEquals($originalFirstName, $instance->data['user-first-name']); + $this->assertEquals($originalLastName, $instance->data['user-last-name']); + } + public function test_get_instance_status(): void { Sanctum::actingAs($this->user, ['instances.read']);