Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions app/Http/Controllers/Api/AuthenticatedApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"}}
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
Comment on lines +261 to +267
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These reserved user-* keys are set on the instance here, but later in this method the generic config loop can overwrite them (e.g. a client can pass config: {"user-email": "other@example.com"}), which would desync claiming/stage-progression data from the provisioning email. Prevent config from setting reserved keys (blacklist/whitelist), or apply these user-* keys after processing config so they always win.

Copilot uses AI. Check for mistakes.

// Handle top-level secret if provided
if ($request->filled('secret')) {
$instance->storeKeyValue('secret', $request->input('secret'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
94 changes: 90 additions & 4 deletions app/Services/LagoonClientService.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use FreedomtechHosting\FtLagoonPhp\Client;
use FreedomtechHosting\FtLagoonPhp\Ssh;
use Symfony\Component\Process\Process;

class LagoonClientService
{
Expand All @@ -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);
Expand Down Expand Up @@ -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');
Comment on lines +85 to +86
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

config('polydock.ftlagoon_private_key_content') is referenced here, but there is no ftlagoon_private_key_content key defined under config/polydock.php. As-is, this override-by-content path will always be null unless something sets it at runtime. Define this config key (likely from an env var) or change this to read the intended env/config source so key content provisioning actually works.

Suggested change
// Fallback or override via content if provided (from config, not env())
$keyContent = config('polydock.ftlagoon_private_key_content');
// Fallback or override via content if provided (prefer config, then env)
$keyContent = config('polydock.ftlagoon_private_key_content', env('FTLAGOON_PRIVATE_KEY_CONTENT'));

Copilot uses AI. Check for mistakes.

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,
];
}

Expand All @@ -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 '';
}
}
94 changes: 94 additions & 0 deletions tests/Feature/Api/AuthenticatedApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ public function test_get_store_apps_returns_formatted_data(): void
'name',
'description',
'author', // etc...
'git_url',
'store' => [
'name',
'status',
Expand All @@ -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
Expand Down Expand Up @@ -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']);
Expand Down
Loading