diff --git a/app/Http/Controllers/Api/AuthenticatedApiController.php b/app/Http/Controllers/Api/AuthenticatedApiController.php index 6381eb3..85c6155 100644 --- a/app/Http/Controllers/Api/AuthenticatedApiController.php +++ b/app/Http/Controllers/Api/AuthenticatedApiController.php @@ -118,6 +118,7 @@ public function getInstances(Request $request): JsonResponse $formattedInstances = $instances->map(fn (PolydockAppInstance $instance) => [ 'uuid' => $instance->uuid, 'name' => $instance->name, + 'label' => $instance->getKeyValue('instance-label') ?: null, 'status' => $instance->status?->value, 'status_message' => $instance->status_message, 'app_url' => $instance->app_url, @@ -147,6 +148,7 @@ public function getInstances(Request $request): JsonResponse * @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 label string optional A free-form human-readable label for this instance. Not used as an identifier; may contain spaces and special characters. Example: "Acme Corp trial" * @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"}} * @bodyParam secret.ai object optional AI LLM configuration. * @bodyParam secret.ai.llm_url string optional The LLM API base URL. Example: https://llm.local @@ -164,6 +166,7 @@ public function getInstances(Request $request): JsonResponse * "data": { * "uuid": "3a105da1-9c87-43ca-9ac8-72787fc5e315", * "name": "My awesome instance", + * "label": "Acme Corp trial", * "status": "new" * } * } @@ -176,6 +179,7 @@ public function createInstance(Request $request): JsonResponse 'last_name' => 'nullable|string|max:255', 'storeAppId' => 'required|string|exists:polydock_store_apps,uuid', 'name' => 'nullable|string|max:255', + 'label' => 'nullable|string|max:255', 'secret' => 'nullable|array', 'secret.ai' => 'nullable|array', 'secret.ai.llm_url' => 'nullable|string', @@ -266,6 +270,11 @@ function ($attribute, $value, $fail) { ]); $instance->save(); + // Store the optional free-form label + if ($request->filled('label')) { + $instance->storeKeyValue('instance-label', $request->input('label')); + } + // Handle top-level secret if provided if ($request->filled('secret')) { $instance->storeKeyValue('secret', $request->input('secret')); @@ -287,6 +296,7 @@ function ($attribute, $value, $fail) { 'data' => [ 'uuid' => $instance->uuid, 'name' => $instance->name, + 'label' => $instance->getKeyValue('instance-label') ?: null, 'status' => $instance->status?->value, ], ], 201); diff --git a/app/Listeners/CreateWebhookCallForAppInstanceStatusChanged.php b/app/Listeners/CreateWebhookCallForAppInstanceStatusChanged.php index 58d96ec..e8a6268 100644 --- a/app/Listeners/CreateWebhookCallForAppInstanceStatusChanged.php +++ b/app/Listeners/CreateWebhookCallForAppInstanceStatusChanged.php @@ -2,6 +2,7 @@ namespace App\Listeners; +use App\Events\PolydockAppInstanceCreatedWithNewStatus; use App\Events\PolydockAppInstanceStatusChanged; use App\Models\PolydockStoreWebhookCall; use Illuminate\Support\Facades\Log; @@ -11,7 +12,7 @@ class CreateWebhookCallForAppInstanceStatusChanged /** * Handle the event. */ - public function handle(PolydockAppInstanceStatusChanged $event): void + public function handle(PolydockAppInstanceStatusChanged|PolydockAppInstanceCreatedWithNewStatus $event): void { $webhooks = $event ->appInstance @@ -30,17 +31,19 @@ public function handle(PolydockAppInstanceStatusChanged $event): void return; } + $previousStatus = property_exists($event, 'previousStatus') ? $event->previousStatus : null; + foreach ($webhooks as $webhook) { PolydockStoreWebhookCall::create([ 'polydock_store_webhook_id' => $webhook->id, - 'event' => $event->previousStatus === null ? 'app_instance.created' : 'app_instance.status_changed', + 'event' => $previousStatus === null ? 'app_instance.created' : 'app_instance.status_changed', 'payload' => [ 'app_instance_id' => $event->appInstance->id, 'store_id' => $event->appInstance->storeApp->store->id, 'store_name' => $event->appInstance->storeApp->store->name, 'store_app_id' => $event->appInstance->polydock_store_app_id, 'store_app_name' => $event->appInstance->storeApp->name, - 'previous_status' => $event->previousStatus?->value, + 'previous_status' => $previousStatus?->value, 'current_status' => $event->appInstance->status->value, 'data' => $event->appInstance->getWebhookSafeData(), 'timestamp' => now()->toIso8601String(), @@ -48,13 +51,13 @@ public function handle(PolydockAppInstanceStatusChanged $event): void ]); Log::info( - $event->previousStatus === null + $previousStatus === null ? 'Created webhook call for new app instance' : 'Created webhook call for app instance status change', [ 'webhook_id' => $webhook->id, 'app_instance_id' => $event->appInstance->id, - 'previous_status' => $event->previousStatus?->value, + 'previous_status' => $previousStatus?->value, 'current_status' => $event->appInstance->status->value, ], ); diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index bd1468c..47cc677 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -31,6 +31,7 @@ class EventServiceProvider extends ServiceProvider ], PolydockAppInstanceCreatedWithNewStatus::class => [ ProcessNewPolydockAppInstance::class, + CreateWebhookCallForAppInstanceStatusChanged::class, ], PolydockAppInstanceStatusChanged::class => [ CreateWebhookCallForAppInstanceStatusChanged::class, diff --git a/tests/Feature/Api/AuthenticatedApiTest.php b/tests/Feature/Api/AuthenticatedApiTest.php index cae171c..841bc9b 100644 --- a/tests/Feature/Api/AuthenticatedApiTest.php +++ b/tests/Feature/Api/AuthenticatedApiTest.php @@ -398,4 +398,81 @@ public function test_create_instance_with_nested_secrets(): void $instance = PolydockAppInstance::where('uuid', $response->json('data.uuid'))->first(); $this->assertEquals($secret, $instance->getKeyValue('secret')); } + + public function test_create_instance_stores_and_returns_label(): void + { + Sanctum::actingAs($this->user, ['instances.write']); + + $label = 'Acme Corp trial instance'; + + $response = $this->postJson('/api/instance', [ + 'email' => $this->user->email, + 'storeAppId' => $this->storeApp->uuid, + 'label' => $label, + ]); + + $response->assertCreated(); + $this->assertEquals($label, $response->json('data.label')); + + $instance = PolydockAppInstance::where('uuid', $response->json('data.uuid'))->first(); + $this->assertEquals($label, $instance->getKeyValue('instance-label')); + } + + public function test_create_instance_without_label_returns_null_label(): void + { + Sanctum::actingAs($this->user, ['instances.write']); + + $response = $this->postJson('/api/instance', [ + 'email' => $this->user->email, + 'storeAppId' => $this->storeApp->uuid, + ]); + + $response->assertCreated(); + $this->assertNull($response->json('data.label')); + } + + public function test_get_instances_returns_label(): void + { + Sanctum::actingAs($this->user, ['instances.read']); + + $group = UserGroup::create(['name' => 'Label Test Group']); + $this->user->groups()->attach($group->id, ['role' => 'owner']); + + $instance = PolydockAppInstance::create([ + 'polydock_store_app_id' => $this->storeApp->id, + 'user_group_id' => $group->id, + 'name' => 'labelled-instance', + 'status' => PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED, + ]); + $instance->storeKeyValue('instance-label', 'My readable label'); + + $response = $this->getJson('/api/instances?email='.$this->user->email); + + $response->assertOk(); + $found = collect($response->json('data'))->firstWhere('uuid', $instance->uuid); + $this->assertNotNull($found); + $this->assertEquals('My readable label', $found['label']); + } + + public function test_get_instances_returns_null_label_when_not_set(): void + { + Sanctum::actingAs($this->user, ['instances.read']); + + $group = UserGroup::create(['name' => 'No Label Group']); + $this->user->groups()->attach($group->id, ['role' => 'owner']); + + PolydockAppInstance::create([ + 'polydock_store_app_id' => $this->storeApp->id, + 'user_group_id' => $group->id, + 'name' => 'unlabelled-instance', + 'status' => PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED, + ]); + + $response = $this->getJson('/api/instances?email='.$this->user->email); + + $response->assertOk(); + $found = collect($response->json('data'))->firstWhere('name', 'unlabelled-instance'); + $this->assertNotNull($found); + $this->assertNull($found['label']); + } }