diff --git a/app/Actions/V5/Flux/ApplyFluxResourceStatusUpdate.php b/app/Actions/V5/Flux/ApplyFluxResourceStatusUpdate.php index f169145e3..e763ab44f 100644 --- a/app/Actions/V5/Flux/ApplyFluxResourceStatusUpdate.php +++ b/app/Actions/V5/Flux/ApplyFluxResourceStatusUpdate.php @@ -152,21 +152,16 @@ class ApplyFluxResourceStatusUpdate private function findApplication(array $payload): ?V5Application { $query = V5Application::query()->with('server'); - $teamId = $this->intValue($payload, 'team_id'); $server = $this->findServer($payload); - if ($teamId !== null) { - $query->where('team_id', $teamId); - } - if ($server instanceof V5Server) { $query->where('server_id', $server->id); } - $applicationId = $this->intValue($payload, 'application_id') ?? $this->intValue($payload, 'resource_id'); + $applicationUuid = $this->stringValue($payload, 'application_uuid') ?? $this->stringValue($payload, 'resource_uuid'); - if ($applicationId !== null) { - return $query->whereKey($applicationId)->first(); + if ($applicationUuid !== null) { + return $query->where('uuid', $applicationUuid)->first(); } $containerName = $this->stringValue($payload, 'container_name') ?? $this->stringValue($payload, 'name'); @@ -202,10 +197,10 @@ class ApplyFluxResourceStatusUpdate */ private function findServer(array $payload): ?V5Server { - $serverId = $this->intValue($payload, 'server_id') ?? $this->intValue($payload, 'host_server_id'); + $serverUuid = $this->stringValue($payload, 'server_uuid') ?? $this->stringValue($payload, 'host_server_uuid'); - if ($serverId !== null) { - return V5Server::query()->find($serverId); + if ($serverUuid !== null) { + return V5Server::query()->where('uuid', $serverUuid)->first(); } $hostId = $this->stringValue($payload, 'host_id') diff --git a/app/Http/Controllers/Api/Internal/FluxResourceStatusController.php b/app/Http/Controllers/Api/Internal/FluxResourceStatusController.php index 81c0322e1..682804957 100644 --- a/app/Http/Controllers/Api/Internal/FluxResourceStatusController.php +++ b/app/Http/Controllers/Api/Internal/FluxResourceStatusController.php @@ -20,14 +20,18 @@ class FluxResourceStatusController extends Controller $validated = Validator::make($request->all(), [ 'resource_type' => ['required', 'string', 'max:64'], - 'team_id' => ['nullable', 'integer'], - 'application_id' => ['nullable', 'integer'], - 'resource_id' => ['nullable', 'integer'], + 'team_id' => ['prohibited'], + 'application_id' => ['prohibited'], + 'resource_id' => ['prohibited'], + 'server_id' => ['prohibited'], + 'host_server_id' => ['prohibited'], + 'application_uuid' => ['nullable', 'string', 'max:255'], + 'resource_uuid' => ['nullable', 'string', 'max:255'], + 'server_uuid' => ['nullable', 'string', 'max:255'], + 'host_server_uuid' => ['nullable', 'string', 'max:255'], 'host_id' => ['nullable', 'string', 'max:255'], 'node_id' => ['nullable', 'string', 'max:255'], 'server_host' => ['nullable', 'string', 'max:255'], - 'server_id' => ['nullable', 'integer'], - 'host_server_id' => ['nullable', 'integer'], 'container_id' => ['nullable', 'string', 'max:255'], 'runtime_container_id' => ['nullable', 'string', 'max:255'], 'container_name' => ['nullable', 'string', 'max:255'], diff --git a/app/Http/Controllers/V5/DashboardController.php b/app/Http/Controllers/V5/DashboardController.php index 73dcc814a..d46a2ad04 100644 --- a/app/Http/Controllers/V5/DashboardController.php +++ b/app/Http/Controllers/V5/DashboardController.php @@ -197,7 +197,7 @@ class DashboardController extends Controller } $validated = $request->validate([ - 'server_id' => ['nullable', 'integer'], + 'server_uuid' => ['nullable', 'string', 'max:255'], 'image' => ['nullable', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\/:@-]*$/'], ]); $image = trim($validated['image'] ?? '') ?: self::DEFAULT_NGINX_IMAGE; @@ -205,8 +205,8 @@ class DashboardController extends Controller $server = V5Server::query() ->where('team_id', $currentTeam->id) ->when( - isset($validated['server_id']), - fn (Builder $query) => $query->whereKey($validated['server_id']), + isset($validated['server_uuid']), + fn (Builder $query) => $query->where('uuid', $validated['server_uuid']), fn (Builder $query) => $query ->orderByRaw('last_bootstrapped_at is null') ->orderBy('name') @@ -520,10 +520,10 @@ class DashboardController extends Controller $validated = $request->validate([ 'resource_one' => ['required', 'array'], 'resource_one.type' => ['required', 'string', Rule::in(['application'])], - 'resource_one.id' => ['required', 'integer'], + 'resource_one.uuid' => ['required', 'string', 'max:255'], 'resource_two' => ['required', 'array'], 'resource_two.type' => ['required', 'string', Rule::in(['application'])], - 'resource_two.id' => ['required', 'integer'], + 'resource_two.uuid' => ['required', 'string', 'max:255'], ]); $resourceOne = $this->resolveConnectableResource($currentTeam, $project, $environment, $validated['resource_one']); @@ -574,20 +574,23 @@ class DashboardController extends Controller DB::transaction(function () use ($connection, $validated): void { $connection->rules()->delete(); + $resourcesByUuid = $this->connectionApplicationsByUuid($connection); foreach ($validated['ports_by_direction'] as $direction => $ports) { - [$sourceResourceId, $targetResourceId] = array_pad(explode('->', (string) $direction, 2), 2, null); + [$sourceResourceUuid, $targetResourceUuid] = array_pad(explode('->', (string) $direction, 2), 2, null); + $sourceResource = is_string($sourceResourceUuid) ? $resourcesByUuid->get($sourceResourceUuid) : null; + $targetResource = is_string($targetResourceUuid) ? $resourcesByUuid->get($targetResourceUuid) : null; - if (! $this->connectionHasResourceId($connection, $sourceResourceId) || ! $this->connectionHasResourceId($connection, $targetResourceId)) { + if (! $sourceResource instanceof V5Application || ! $targetResource instanceof V5Application) { continue; } foreach (array_unique($ports) as $port) { $connection->rules()->create([ - 'source_resource_type' => $this->resourceTypeForConnectionId($connection, (int) $sourceResourceId), - 'source_resource_id' => (int) $sourceResourceId, - 'target_resource_type' => $this->resourceTypeForConnectionId($connection, (int) $targetResourceId), - 'target_resource_id' => (int) $targetResourceId, + 'source_resource_type' => $this->resourceTypeForConnectionUuid($connection, $sourceResource->uuid), + 'source_resource_id' => $sourceResource->id, + 'target_resource_type' => $this->resourceTypeForConnectionUuid($connection, $targetResource->uuid), + 'target_resource_id' => $targetResource->id, 'protocol' => 'tcp', 'port' => (int) $port, ]); @@ -775,10 +778,10 @@ class DashboardController extends Controller ], 'ssh_user' => ['required', 'string', 'max:255'], 'ssh_port' => ['required', 'integer', 'min:1', 'max:65535'], - 'private_key_id' => [ + 'private_key_uuid' => [ 'required', - 'integer', - Rule::exists('private_keys', 'id')->where('team_id', $currentTeam->id), + 'string', + Rule::exists('private_keys', 'uuid')->where('team_id', $currentTeam->id), ], 'node_address' => ['nullable', 'string', 'max:255'], 'builder_enabled' => ['sometimes', 'boolean'], @@ -803,6 +806,10 @@ class DashboardController extends Controller $builderCapacity = (int) ($validated['builder_capacity'] ?? $cluster->builder_capacity); $builderCpuQuota = $validated['builder_cpu_quota'] ?? $cluster->builder_cpu_quota; $devWireguardOverrides = $this->devLimaWireguardOverrides($validated['host'], (int) $validated['ssh_port']); + $privateKey = PrivateKey::query() + ->where('team_id', $currentTeam->id) + ->where('uuid', $validated['private_key_uuid']) + ->firstOrFail(); V5Server::query()->create([ 'team_id' => $currentTeam->id, @@ -812,7 +819,7 @@ class DashboardController extends Controller 'host' => $validated['host'], 'ssh_user' => $validated['ssh_user'], 'ssh_port' => $validated['ssh_port'], - 'private_key_id' => $validated['private_key_id'] ?? null, + 'private_key_id' => $privateKey->id, 'status' => 'added', 'ingress_type' => $ingressType, 'capabilities' => $this->serverCapabilities($builderEnabled, $ingressEnabled), @@ -1363,9 +1370,9 @@ class DashboardController extends Controller ->where('team_id', $currentTeam->id) ->orderByRaw('last_bootstrapped_at is null') ->orderBy('name') - ->get(['id', 'name', 'host', 'status']) + ->get(['id', 'uuid', 'name', 'host', 'status']) ->map(fn (V5Server $server) => [ - 'id' => (string) $server->id, + 'id' => $server->uuid, 'name' => $server->name, 'host' => $server->host, 'status' => $server->status, @@ -1440,7 +1447,7 @@ class DashboardController extends Controller $isServerReachable = $this->isServerReachable($server); return [ - 'id' => (string) $server->id, + 'id' => $server->uuid, 'name' => $server->name, 'host' => $server->host, 'type' => $server->ingressType(), @@ -1456,16 +1463,27 @@ class DashboardController extends Controller */ private function serializeResourceConnection(ResourceConnection $connection): array { + $applications = $this->connectionApplicationsById($connection); + $resourceOneUuid = $applications->get($connection->resource_one_id)?->uuid; + $resourceTwoUuid = $applications->get($connection->resource_two_id)?->uuid; + $applicationsById = $applications; + return [ - 'id' => (string) $connection->id, - 'applicationIds' => [ - (string) $connection->resource_one_id, - (string) $connection->resource_two_id, - ], - 'fromApplicationId' => (string) $connection->resource_one_id, - 'toApplicationId' => (string) $connection->resource_two_id, + 'id' => $connection->uuid, + 'applicationIds' => array_values(array_filter([ + $resourceOneUuid, + $resourceTwoUuid, + ])), + 'fromApplicationId' => $resourceOneUuid, + 'toApplicationId' => $resourceTwoUuid, 'portsByDirection' => $connection->rules - ->groupBy(fn ($rule) => "{$rule->source_resource_id}->{$rule->target_resource_id}") + ->groupBy(function ($rule) use ($applicationsById): string { + $sourceUuid = $applicationsById->get($rule->source_resource_id)?->uuid; + $targetUuid = $applicationsById->get($rule->target_resource_id)?->uuid; + + return "{$sourceUuid}->{$targetUuid}"; + }) + ->filter(fn (Collection $rules, string $direction): bool => ! str_starts_with($direction, '->') && ! str_ends_with($direction, '->')) ->map(fn (Collection $rules) => $rules ->sortBy('port') ->pluck('port') @@ -1477,7 +1495,7 @@ class DashboardController extends Controller } /** - * @param array{type: string, id: int} $resource + * @param array{type: string, uuid: string} $resource */ private function resolveConnectableResource(Team $team, Project $project, Environment $environment, array $resource): Model { @@ -1486,7 +1504,7 @@ class DashboardController extends Controller ->where('team_id', $team->id) ->where('project_id', $project->id) ->where('environment_id', $environment->id) - ->whereKey($resource['id']) + ->where('uuid', $resource['uuid']) ->firstOrFail(), }; } @@ -1504,17 +1522,34 @@ class DashboardController extends Controller return $resource->getMorphClass().':'.$resource->getKey(); } - private function connectionHasResourceId(ResourceConnection $connection, mixed $resourceId): bool + /** + * @return Collection + */ + private function connectionApplicationsByUuid(ResourceConnection $connection): Collection { - return in_array((int) $resourceId, [ - (int) $connection->resource_one_id, - (int) $connection->resource_two_id, - ], true); + return $this->connectionApplicationsById($connection)->keyBy('uuid'); } - private function resourceTypeForConnectionId(ResourceConnection $connection, int $resourceId): string + /** + * @return Collection + */ + private function connectionApplicationsById(ResourceConnection $connection): Collection { - return (int) $connection->resource_one_id === $resourceId + return V5Application::query() + ->whereIn('id', [ + (int) $connection->resource_one_id, + (int) $connection->resource_two_id, + ]) + ->get() + ->keyBy('id'); + } + + private function resourceTypeForConnectionUuid(ResourceConnection $connection, string $resourceUuid): string + { + $resourcesByUuid = $this->connectionApplicationsByUuid($connection); + $resource = $resourcesByUuid->get($resourceUuid); + + return $resource instanceof V5Application && (int) $connection->resource_one_id === $resource->id ? $connection->resource_one_type : $connection->resource_two_type; } @@ -1686,7 +1721,7 @@ class DashboardController extends Controller $isServerReachable = ! $server instanceof V5Server || $this->isServerReachable($server); return [ - 'id' => (string) $application->id, + 'id' => $application->uuid, 'name' => $application->name, 'image' => $application->image, 'containerName' => $application->container_name, @@ -1756,9 +1791,9 @@ class DashboardController extends Controller ->where('team_id', $currentTeam->id) ->where('is_git_related', false) ->orderBy('name') - ->get(['id', 'name']) + ->get(['id', 'uuid', 'name']) ->map(fn (PrivateKey $privateKey) => [ - 'id' => (string) $privateKey->id, + 'id' => $privateKey->uuid, 'name' => $privateKey->name, ]) ->all(); @@ -1830,7 +1865,7 @@ class DashboardController extends Controller private function serializeCluster(V5Cluster $cluster): array { return [ - 'id' => (string) $cluster->id, + 'id' => $cluster->uuid, 'name' => $cluster->name, 'description' => $cluster->description, 'wireguardInterface' => $cluster->wireguard_interface, @@ -1855,7 +1890,7 @@ class DashboardController extends Controller 'lastCliRanAt' => $cluster->last_cli_ran_at?->toJSON(), 'serversCount' => $cluster->servers_count ?? $cluster->servers->count(), 'servers' => $cluster->servers->map(fn (V5Server $server) => [ - 'id' => (string) $server->id, + 'id' => $server->uuid, 'name' => $server->name, 'host' => $server->host, 'status' => $server->status, diff --git a/app/Models/V5/Application.php b/app/Models/V5/Application.php index f1e279d61..9052ef11a 100644 --- a/app/Models/V5/Application.php +++ b/app/Models/V5/Application.php @@ -15,6 +15,7 @@ class Application extends V5Model protected $table = 'v5_applications'; protected $fillable = [ + 'uuid', 'team_id', 'project_id', 'environment_id', diff --git a/app/Models/V5/Cluster.php b/app/Models/V5/Cluster.php index b5007d685..d910ae698 100644 --- a/app/Models/V5/Cluster.php +++ b/app/Models/V5/Cluster.php @@ -40,6 +40,7 @@ class Cluster extends V5Model public const DEFAULT_BUILDER_TIMEOUT_SECS = 1800; protected $fillable = [ + 'uuid', 'team_id', 'created_by_user_id', 'name', diff --git a/app/Models/V5/ResourceConnection.php b/app/Models/V5/ResourceConnection.php index 12755819d..01b9b4d7a 100644 --- a/app/Models/V5/ResourceConnection.php +++ b/app/Models/V5/ResourceConnection.php @@ -15,6 +15,7 @@ class ResourceConnection extends V5Model protected $table = 'v5_resource_connections'; protected $fillable = [ + 'uuid', 'team_id', 'project_id', 'environment_id', diff --git a/app/Models/V5/V5Model.php b/app/Models/V5/V5Model.php index 4180ad3e3..d1df6e12b 100644 --- a/app/Models/V5/V5Model.php +++ b/app/Models/V5/V5Model.php @@ -7,6 +7,11 @@ use Illuminate\Support\Facades\Schema; abstract class V5Model extends Model { + public function getRouteKeyName(): string + { + return 'uuid'; + } + protected static function boot(): void { parent::boot(); diff --git a/database/migrations/2026_06_16_130649_v5_create_clusters_table.php b/database/migrations/2026_06_16_130649_v5_create_clusters_table.php index fcbabc813..b3d9c8af4 100644 --- a/database/migrations/2026_06_16_130649_v5_create_clusters_table.php +++ b/database/migrations/2026_06_16_130649_v5_create_clusters_table.php @@ -13,6 +13,7 @@ return new class extends Migration { Schema::create('v5_clusters', function (Blueprint $table) { $table->id(); + $table->string('uuid')->unique(); $table->foreignId('team_id')->constrained('teams')->cascadeOnDelete(); $table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete(); $table->string('name'); diff --git a/database/migrations/2026_06_19_140000_v5_create_applications_table.php b/database/migrations/2026_06_19_140000_v5_create_applications_table.php index c89aa1d33..051eafa55 100644 --- a/database/migrations/2026_06_19_140000_v5_create_applications_table.php +++ b/database/migrations/2026_06_19_140000_v5_create_applications_table.php @@ -13,6 +13,7 @@ return new class extends Migration { Schema::create('v5_applications', function (Blueprint $table) { $table->id(); + $table->string('uuid')->unique(); $table->foreignId('team_id')->constrained('teams')->cascadeOnDelete(); $table->foreignId('project_id')->constrained('projects')->cascadeOnDelete(); $table->foreignId('environment_id')->constrained('environments')->cascadeOnDelete(); diff --git a/database/migrations/2026_06_19_142000_v5_create_resource_connections_table.php b/database/migrations/2026_06_19_142000_v5_create_resource_connections_table.php index 384c46223..428683b44 100644 --- a/database/migrations/2026_06_19_142000_v5_create_resource_connections_table.php +++ b/database/migrations/2026_06_19_142000_v5_create_resource_connections_table.php @@ -13,6 +13,7 @@ return new class extends Migration { Schema::create('v5_resource_connections', function (Blueprint $table) { $table->id(); + $table->string('uuid')->unique(); $table->foreignId('team_id')->constrained('teams')->cascadeOnDelete(); $table->foreignId('project_id')->constrained('projects')->cascadeOnDelete(); $table->foreignId('environment_id')->constrained('environments')->cascadeOnDelete(); diff --git a/resources/js/v5/Pages/Clusters.tsx b/resources/js/v5/Pages/Clusters.tsx index fa66d8b80..5f87bd307 100644 --- a/resources/js/v5/Pages/Clusters.tsx +++ b/resources/js/v5/Pages/Clusters.tsx @@ -59,7 +59,7 @@ type ServerFormErrors = { host?: string[]; ssh_user?: string[]; ssh_port?: string[]; - private_key_id?: string[]; + private_key_uuid?: string[]; node_address?: string[]; builder_enabled?: string[]; builder_capacity?: string[]; @@ -530,7 +530,7 @@ export default function Clusters({ host: serverHost, ssh_user: serverSshUser, ssh_port: Number(serverSshPort), - private_key_id: selectedPrivateKeyId === '' ? null : Number(selectedPrivateKeyId), + private_key_uuid: selectedPrivateKeyId === '' ? null : selectedPrivateKeyId, node_address: serverNodeAddress.trim() === '' ? null : serverNodeAddress, builder_enabled: serverBuilderEnabled, ingress_enabled: serverIngressEnabled, @@ -1835,7 +1835,7 @@ export default function Clusters({ value={selectedPrivateKeyId} onChange={(event) => setSelectedPrivateKeyId(event.target.value)} className="appearance-none rounded-md border border-border bg-background bg-[length:1rem_1rem] bg-[position:right_0.75rem_center] bg-no-repeat px-3 py-2 pr-10 text-sm outline-none transition focus:border-ring focus:ring-0 aria-invalid:border-destructive aria-invalid:ring-0 dark:aria-invalid:border-destructive/50" - aria-invalid={serverErrors.private_key_id ? true : undefined} + aria-invalid={serverErrors.private_key_uuid ? true : undefined} style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 256 256' fill='none' stroke='%23ffffff' stroke-width='28' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m64 96 64 64 64-64'/%3E%3C/svg%3E")`, }} @@ -1847,7 +1847,7 @@ export default function Clusters({ ))} - +
diff --git a/resources/js/v5/Pages/Dashboard.tsx b/resources/js/v5/Pages/Dashboard.tsx index e33e8c7e6..79fc4f6c8 100644 --- a/resources/js/v5/Pages/Dashboard.tsx +++ b/resources/js/v5/Pages/Dashboard.tsx @@ -413,8 +413,8 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection 'X-CSRF-TOKEN': csrfToken(), }, body: JSON.stringify({ - resource_one: { type: 'application', id: Number(fromApplicationId) }, - resource_two: { type: 'application', id: Number(toApplicationId) }, + resource_one: { type: 'application', uuid: fromApplicationId }, + resource_two: { type: 'application', uuid: toApplicationId }, }), }); const payload = (await response.json()) as { connection?: V5ResourceConnection; message?: string; detail?: string }; @@ -896,7 +896,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection 'X-CSRF-TOKEN': csrfToken(), }, body: JSON.stringify({ - server_id: selectedNginxServerId || null, + server_uuid: selectedNginxServerId || null, image: nginxImage.trim() || DEFAULT_NGINX_IMAGE, }), }); diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 811aee97e..0a2dfda9d 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -2361,7 +2361,7 @@ "category": "automation", "logo": "svgs/inngest.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-06-10T13:46:21+05:30", + "template_last_updated_at": "2026-07-02T13:25:47+02:00", "port": "8288" }, "invoice-ninja": { diff --git a/templates/service-templates.json b/templates/service-templates.json index 9721a52de..4d97d8d5e 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -2361,7 +2361,7 @@ "category": "automation", "logo": "svgs/inngest.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-06-10T13:46:21+05:30", + "template_last_updated_at": "2026-07-02T13:25:47+02:00", "port": "8288" }, "invoice-ninja": { diff --git a/tests/Feature/V5/DashboardTest.php b/tests/Feature/V5/DashboardTest.php index 26bae7947..d81153620 100644 --- a/tests/Feature/V5/DashboardTest.php +++ b/tests/Feature/V5/DashboardTest.php @@ -155,6 +155,160 @@ it('shows a loading state while toggling application ingress', function () { ->toContain("isSavingIngress ? 'Saving...' : 'Enable ingress'"); }); +it('uses v5 resource uuids at http boundaries while keeping database ids internal', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production'); + $server = V5Server::query()->create([ + 'uuid' => 'server-public-uuid', + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-01', + 'host' => '203.0.113.20', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => [], + 'builder_enabled' => false, + 'builder_capacity' => 0, + 'builder_cpu_quota' => '200%', + 'wireguard_management_ip' => '100.64.0.10', + 'last_bootstrapped_at' => now(), + ]); + $application = V5Application::query()->create([ + 'uuid' => 'application-public-uuid', + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $user->id, + 'name' => 'nginx-test', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-nginx-test', + 'status' => 'running', + 'mesh_namespace' => 'default', + ]); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->patchJson("/v5/applications/{$application->uuid}/position", [ + 'canvas_x' => 123, + 'canvas_y' => 456, + ]) + ->assertSuccessful() + ->assertJsonPath('application.id', $application->uuid); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->patchJson("/v5/applications/{$application->id}/position", [ + 'canvas_x' => 789, + 'canvas_y' => 999, + ]) + ->assertNotFound(); + + $fluxClient = Mockery::mock(FluxClient::class); + $fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.'); + $fluxClient->shouldReceive('createContainer')->once()->andReturn('container-id'); + $fluxClient->shouldReceive('startContainer')->once()->andReturn('Container started.'); + $fluxClient->shouldReceive('inspectContainer')->once()->andReturn(['State' => ['Running' => true]]); + app()->instance(FluxClient::class, $fluxClient); + + $this + ->actingAs($user) + ->withSession([ + 'currentTeam' => $team, + 'v5.selected_project' => ['uuid' => $project->uuid, 'name' => $project->name], + 'v5.selected_environment' => ['uuid' => $environment->uuid, 'name' => $environment->name], + ]) + ->postJson('/v5/applications/nginx', [ + 'server_uuid' => $server->uuid, + 'image' => 'docker.io/library/nginx:alpine', + ]) + ->assertSuccessful() + ->assertJsonPath('application.id', fn (string $id): bool => $id !== (string) V5Application::query()->latest('id')->value('id')); +}); + +it('uses v5 resource uuids for resource connection requests and responses', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production'); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-01', + 'host' => '203.0.113.20', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => [], + 'wireguard_management_ip' => '100.64.0.10', + ]); + $source = V5Application::query()->create([ + 'uuid' => 'source-application-uuid', + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $user->id, + 'name' => 'source', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'source-container', + 'status' => 'running', + ]); + $target = V5Application::query()->create([ + 'uuid' => 'target-application-uuid', + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $user->id, + 'name' => 'target', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'target-container', + 'status' => 'running', + ]); + + $response = $this + ->actingAs($user) + ->withSession([ + 'currentTeam' => $team, + 'v5.selected_project' => ['uuid' => $project->uuid, 'name' => $project->name], + 'v5.selected_environment' => ['uuid' => $environment->uuid, 'name' => $environment->name], + ]) + ->postJson('/v5/resource-connections', [ + 'resource_one' => ['type' => 'application', 'uuid' => $source->uuid], + 'resource_two' => ['type' => 'application', 'uuid' => $target->uuid], + ]) + ->assertCreated() + ->assertJsonPath('connection.fromApplicationId', $source->uuid) + ->assertJsonPath('connection.toApplicationId', $target->uuid); + + $connectionUuid = $response->json('connection.id'); + + $fluxClient = Mockery::mock(FluxClient::class); + $fluxClient->shouldReceive('applyFirewallRule')->once()->andReturn('Firewall rule applied.'); + app()->instance(FluxClient::class, $fluxClient); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->patchJson("/v5/resource-connections/{$connectionUuid}", [ + 'ports_by_direction' => [ + "{$source->uuid}->{$target->uuid}" => [8080], + ], + ]) + ->assertSuccessful() + ->assertJsonPath("connection.portsByDirection.{$source->uuid}->{$target->uuid}.0", '8080'); + + expect(ResourceConnection::query()->first()) + ->resource_one_id->toBe($source->id) + ->resource_two_id->toBe($target->id); +}); + it('generates http-only caddy routes for application ingress', function () { createSharedUserAndTeamTables(); @@ -215,7 +369,7 @@ it('generates http-only caddy routes for application ingress', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->patchJson("/v5/applications/{$application->id}/ingress", [ + ->patchJson("/v5/applications/{$application->uuid}/ingress", [ 'ingress_enabled' => true, 'internal_port' => 3000, 'domains' => ['app.example.com'], @@ -972,12 +1126,12 @@ it('persists generic v5 resource connections and direction-specific ports', func ]) ->withHeader('X-CSRF-TOKEN', 'test-csrf-token') ->postJson('/v5/resource-connections', [ - 'resource_one' => ['type' => 'application', 'id' => $source->id], - 'resource_two' => ['type' => 'application', 'id' => $target->id], + 'resource_one' => ['type' => 'application', 'uuid' => $source->uuid], + 'resource_two' => ['type' => 'application', 'uuid' => $target->uuid], ]) ->assertCreated() - ->assertJsonPath('connection.applicationIds.0', (string) $source->id) - ->assertJsonPath('connection.applicationIds.1', (string) $target->id); + ->assertJsonPath('connection.applicationIds.0', $source->uuid) + ->assertJsonPath('connection.applicationIds.1', $target->uuid); $connectionId = $response->json('connection.id'); @@ -987,13 +1141,13 @@ it('persists generic v5 resource connections and direction-specific ports', func ->withHeader('X-CSRF-TOKEN', 'test-csrf-token') ->patchJson("/v5/resource-connections/{$connectionId}", [ 'ports_by_direction' => [ - "{$source->id}->{$target->id}" => [80], - "{$target->id}->{$source->id}" => [443], + "{$source->uuid}->{$target->uuid}" => [80], + "{$target->uuid}->{$source->uuid}" => [443], ], ]) ->assertSuccessful() - ->assertJsonPath("connection.portsByDirection.{$source->id}->{$target->id}.0", '80') - ->assertJsonPath("connection.portsByDirection.{$target->id}->{$source->id}.0", '443'); + ->assertJsonPath("connection.portsByDirection.{$source->uuid}->{$target->uuid}.0", '80') + ->assertJsonPath("connection.portsByDirection.{$target->uuid}->{$source->uuid}.0", '443'); $this ->actingAs($user) @@ -1006,8 +1160,8 @@ it('persists generic v5 resource connections and direction-specific ports', func ->assertSuccessful() ->assertSee('"resourceConnections":[', false) ->assertSee("\"id\":\"{$connectionId}\"", false) - ->assertSee("\"{$source->id}->{$target->id}\":[\"80\"]", false) - ->assertSee("\"{$target->id}->{$source->id}\":[\"443\"]", false); + ->assertSee("\"{$source->uuid}->{$target->uuid}\":[\"80\"]", false) + ->assertSee("\"{$target->uuid}->{$source->uuid}\":[\"443\"]", false); }); it('reports flux failures when syncing v5 resource connection firewall rules', function () { @@ -1084,9 +1238,9 @@ it('reports flux failures when syncing v5 resource connection firewall rules', f ->actingAs($user) ->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token']) ->withHeader('X-CSRF-TOKEN', 'test-csrf-token') - ->patchJson("/v5/resource-connections/{$connection->id}", [ + ->patchJson("/v5/resource-connections/{$connection->uuid}", [ 'ports_by_direction' => [ - "{$source->id}->{$target->id}" => [5432], + "{$source->uuid}->{$target->uuid}" => [5432], ], ]) ->assertStatus(502) @@ -1196,9 +1350,9 @@ it('syncs cross-server v5 resource connection ports on both endpoint hosts', fun ->actingAs($user) ->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token']) ->withHeader('X-CSRF-TOKEN', 'test-csrf-token') - ->patchJson("/v5/resource-connections/{$connection->id}", [ + ->patchJson("/v5/resource-connections/{$connection->uuid}", [ 'ports_by_direction' => [ - "{$source->id}->{$target->id}" => [5432], + "{$source->uuid}->{$target->uuid}" => [5432], ], ]) ->assertSuccessful(); @@ -1293,9 +1447,9 @@ it('syncs v5 resource connection ports through flux firewall primitives', functi ->actingAs($user) ->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token']) ->withHeader('X-CSRF-TOKEN', 'test-csrf-token') - ->patchJson("/v5/resource-connections/{$connection->id}", [ + ->patchJson("/v5/resource-connections/{$connection->uuid}", [ 'ports_by_direction' => [ - "{$source->id}->{$target->id}" => [5432], + "{$source->uuid}->{$target->uuid}" => [5432], ], ]) ->assertSuccessful(); @@ -1304,7 +1458,7 @@ it('syncs v5 resource connection ports through flux firewall primitives', functi ->actingAs($user) ->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token']) ->withHeader('X-CSRF-TOKEN', 'test-csrf-token') - ->deleteJson("/v5/resource-connections/{$connection->id}") + ->deleteJson("/v5/resource-connections/{$connection->uuid}") ->assertNoContent(); }); @@ -1426,7 +1580,7 @@ it('creates an nginx v5 application with a custom image', function () { V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, - 'private_key_id' => $privateKey->id, + 'private_key_uuid' => $privateKey->uuid, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', @@ -1498,7 +1652,7 @@ it('creates an nginx v5 application on the selected team server', function () { 'v5.selectedEnvironmentUuid' => $environment->uuid, ]) ->postJson('/v5/applications/nginx', [ - 'server_id' => $selectedServer->id, + 'server_uuid' => $selectedServer->uuid, ]) ->assertCreated() ->assertJsonPath('application.serverName', 'edge-02'); @@ -1568,7 +1722,7 @@ it('marks an nginx v5 application failed when the launch command fails', functio V5Server::query()->create([ 'team_id' => $team->id, 'created_by_user_id' => $user->id, - 'private_key_id' => $privateKey->id, + 'private_key_uuid' => $privateKey->uuid, 'name' => 'edge-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', @@ -1605,7 +1759,7 @@ it('does not create an nginx v5 application on another teams selected server', f $otherServer = V5Server::query()->create([ 'team_id' => $otherTeam->id, 'created_by_user_id' => $otherUser->id, - 'private_key_id' => $privateKey->id, + 'private_key_uuid' => $privateKey->uuid, 'name' => 'other-edge-01', 'host' => '203.0.113.20', 'ssh_user' => 'root', @@ -1681,7 +1835,7 @@ it('deletes a v5 application for the current team', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->deleteJson("/v5/applications/{$application->id}") + ->deleteJson("/v5/applications/{$application->uuid}") ->assertNoContent(); expect(V5Application::query()->whereKey($application->id)->exists())->toBeFalse(); @@ -1724,7 +1878,7 @@ it('stops and deletes the nginx container before deleting a v5 application', fun $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->deleteJson("/v5/applications/{$application->id}") + ->deleteJson("/v5/applications/{$application->uuid}") ->assertNoContent(); Process::assertRan(function ($process): bool { @@ -1769,7 +1923,7 @@ it('does not delete another teams v5 application', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->deleteJson("/v5/applications/{$application->id}") + ->deleteJson("/v5/applications/{$application->uuid}") ->assertNotFound(); expect(V5Application::query()->whereKey($application->id)->exists())->toBeTrue(); @@ -1807,7 +1961,7 @@ it('updates v5 application canvas position for the current team', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->patchJson("/v5/applications/{$application->id}/position", [ + ->patchJson("/v5/applications/{$application->uuid}/position", [ 'canvas_x' => 320, 'canvas_y' => -160, ]) @@ -1839,7 +1993,7 @@ it('updates v5 caddy ingress canvas position for the current team', function () $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->patchJson("/v5/caddy-ingresses/{$server->id}/position", [ + ->patchJson("/v5/caddy-ingresses/{$server->uuid}/position", [ 'canvas_x' => -160, 'canvas_y' => 240, ]) @@ -2145,6 +2299,69 @@ it('rejects flux resource status http updates without the shared token', functio ->assertUnauthorized(); }); +it('accepts flux resource status http updates identified by resource uuids', function () { + createSharedUserAndTeamTables(); + Config::set('flux.laravel_api_token', 'test-flux-token'); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + $server = V5Server::query()->create([ + 'uuid' => 'server-public-uuid', + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => [], + 'wireguard_management_ip' => '100.64.0.5', + ]); + $application = V5Application::query()->create([ + 'uuid' => 'application-public-uuid', + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $user->id, + 'name' => 'nginx-test', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-nginx-1', + 'status' => 'starting', + 'status_message' => 'Container starting.', + ]); + + $this + ->withToken('test-flux-token') + ->postJson('/api/v1/internal/flux/resource-status', [ + 'resource_type' => 'application', + 'server_uuid' => $server->uuid, + 'application_uuid' => $application->uuid, + 'container_id' => 'new-container-id', + 'status' => 'running', + 'status_message' => 'Status received from coold through flux.', + ]) + ->assertSuccessful() + ->assertJsonPath('message', 'Resource status updated.'); + + expect($application->refresh()->status)->toBe('running') + ->and($application->runtime_container_id)->toBe('new-container-id'); +}); + +it('rejects numeric laravel ids in flux resource status http payloads', function () { + Config::set('flux.laravel_api_token', 'test-flux-token'); + + $this + ->withToken('test-flux-token') + ->postJson('/api/v1/internal/flux/resource-status', [ + 'resource_type' => 'application', + 'server_id' => 1, + 'application_id' => 1, + 'status' => 'running', + ]) + ->assertInvalid(['server_id', 'application_id']); +}); + it('accepts flux resource status http updates and stores them in the database', function () { createSharedUserAndTeamTables(); Config::set('flux.laravel_api_token', 'test-flux-token'); @@ -2440,7 +2657,7 @@ it('refreshes v5 application state from flux container inventory', function () { ]) ->postJson('/v5/applications/refresh') ->assertSuccessful() - ->assertJsonPath('applications.0.id', (string) $application->id) + ->assertJsonPath('applications.0.id', $application->uuid) ->assertJsonPath('applications.0.status', 'exited') ->assertJsonPath('applications.0.statusMessage', 'Container state refreshed from coold.'); @@ -2491,7 +2708,7 @@ it('refreshes v5 caddy ingress state from flux container inventory', function () ]) ->postJson('/v5/applications/refresh') ->assertSuccessful() - ->assertJsonPath('caddyIngresses.0.id', (string) $server->id) + ->assertJsonPath('caddyIngresses.0.id', $server->uuid) ->assertJsonPath('caddyIngresses.0.type', 'caddy') ->assertJsonPath('caddyIngresses.0.status', 'exited'); @@ -2537,7 +2754,7 @@ it('broadcasts v5 cluster updates when bootstrap state changes', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/bootstrap") + ->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/bootstrap") ->assertAccepted(); Event::assertDispatched(V5ClusterUpdated::class, fn ($event): bool => $event->clusterId === $cluster->id @@ -2589,7 +2806,7 @@ it('fetches v5 server coold logs through flux', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->getJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/coold-logs?tail=200") + ->getJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/coold-logs?tail=200") ->assertSuccessful() ->assertJsonPath('output', 'Jun 22 coold[123]: started') ->assertJsonStructure(['output', 'fetchedAt']); @@ -2630,7 +2847,7 @@ it('fetches v5 server corrosion tables through flux', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->getJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/corrosion-tables?limit=200") + ->getJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/corrosion-tables?limit=200") ->assertSuccessful() ->assertJsonPath('output', '{"limit":200,"tables":[{"name":"service_endpoints","columns":["container_name"],"rows":[["coolify-v5-nginx"]]}]}') ->assertJsonStructure(['output', 'fetchedAt']); @@ -2678,7 +2895,7 @@ it('fetches v5 server firewall rules through flux', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->getJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/firewall-rules") + ->getJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/firewall-rules") ->assertSuccessful() ->assertJsonPath('rules.0.id', 'v5-resource-connection:1:1:2:tcp:5432') ->assertJsonPath('rules.0.port', 5432) @@ -2742,10 +2959,10 @@ it('returns fresh v5 cluster bootstrap state for realtime fallback refreshes', f $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->getJson("/v5/clusters/{$cluster->id}") + ->getJson("/v5/clusters/{$cluster->uuid}") ->assertSuccessful() - ->assertJsonPath('cluster.id', (string) $cluster->id) - ->assertJsonPath('cluster.servers.0.id', (string) $server->id) + ->assertJsonPath('cluster.id', $cluster->uuid) + ->assertJsonPath('cluster.servers.0.id', $server->uuid) ->assertJsonPath('cluster.servers.0.lastBootstrapStatus', 'running') ->assertJsonPath('cluster.servers.0.lastBootstrapOutput', 'Starting Coolify CLI bootstrap for prod-01...'); }); @@ -3035,12 +3252,12 @@ it('adds a v5 server to a cluster for the current team', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->postJson("/v5/clusters/{$cluster->id}/servers", [ + ->postJson("/v5/clusters/{$cluster->uuid}/servers", [ 'name' => 'prod-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, - 'private_key_id' => $privateKey->id, + 'private_key_uuid' => $privateKey->uuid, 'node_address' => '203.0.113.10', 'builder_enabled' => true, 'builder_capacity' => 3, @@ -3102,12 +3319,12 @@ it('adds a v5 server with caddy ingress enabled', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->postJson("/v5/clusters/{$cluster->id}/servers", [ + ->postJson("/v5/clusters/{$cluster->uuid}/servers", [ 'name' => 'edge-01', 'host' => '203.0.113.20', 'ssh_user' => 'root', 'ssh_port' => 22, - 'private_key_id' => $privateKey->id, + 'private_key_uuid' => $privateKey->uuid, 'builder_enabled' => false, 'builder_capacity' => 0, 'ingress_enabled' => true, @@ -3142,12 +3359,12 @@ it('keeps added v5 server builder capacity when builder is disabled', function ( $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->postJson("/v5/clusters/{$cluster->id}/servers", [ + ->postJson("/v5/clusters/{$cluster->uuid}/servers", [ 'name' => 'prod-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, - 'private_key_id' => $privateKey->id, + 'private_key_uuid' => $privateKey->uuid, 'builder_enabled' => false, 'builder_capacity' => 3, ]) @@ -3178,12 +3395,12 @@ it('requires positive v5 server builder capacity when builder is enabled', funct $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->postJson("/v5/clusters/{$cluster->id}/servers", [ + ->postJson("/v5/clusters/{$cluster->uuid}/servers", [ 'name' => 'prod-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, - 'private_key_id' => $privateKey->id, + 'private_key_uuid' => $privateKey->uuid, 'builder_enabled' => true, 'builder_capacity' => 0, ]) @@ -3206,12 +3423,12 @@ it('defaults dev Lima wireguard overrides for host docker internal servers', fun $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->postJson("/v5/clusters/{$cluster->id}/servers", [ + ->postJson("/v5/clusters/{$cluster->uuid}/servers", [ 'name' => 'coolify-naked-test', 'host' => 'host.docker.internal', 'ssh_user' => 'root', 'ssh_port' => 60003, - 'private_key_id' => $privateKey->id, + 'private_key_uuid' => $privateKey->uuid, ]) ->assertCreated() ->assertJsonPath('cluster.servers.0.wireguardListenPortOverride', 51823) @@ -3238,7 +3455,7 @@ it('rejects adding a v5 server to another teams cluster', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->postJson("/v5/clusters/{$cluster->id}/servers", [ + ->postJson("/v5/clusters/{$cluster->uuid}/servers", [ 'name' => 'prod-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', @@ -3261,12 +3478,12 @@ it('validates v5 server creation input', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->postJson("/v5/clusters/{$cluster->id}/servers", [ + ->postJson("/v5/clusters/{$cluster->uuid}/servers", [ 'name' => '', 'host' => '', 'ssh_user' => '', 'ssh_port' => 70000, - 'private_key_id' => null, + 'private_key_uuid' => null, 'wireguard_listen_port_override' => 70000, ]) ->assertUnprocessable() @@ -3275,7 +3492,7 @@ it('validates v5 server creation input', function () { 'host', 'ssh_user', 'ssh_port', - 'private_key_id', + 'private_key_uuid', 'wireguard_listen_port_override', ]); }); @@ -3301,15 +3518,15 @@ it('rejects private keys from another team when adding a v5 server', function () $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->postJson("/v5/clusters/{$cluster->id}/servers", [ + ->postJson("/v5/clusters/{$cluster->uuid}/servers", [ 'name' => 'prod-01', 'host' => '203.0.113.10', 'ssh_user' => 'root', 'ssh_port' => 22, - 'private_key_id' => $otherPrivateKey->id, + 'private_key_uuid' => $otherPrivateKey->uuid, ]) ->assertUnprocessable() - ->assertJsonValidationErrors(['private_key_id']); + ->assertJsonValidationErrors(['private_key_uuid']); }); it('checks v5 server ssh status without storing diagnostic output', function () { @@ -3344,7 +3561,7 @@ it('checks v5 server ssh status without storing diagnostic output', function () $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/check") + ->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/check") ->assertSuccessful() ->assertJsonPath('status', 'reachable') ->assertJsonPath('output', "SSH connection OK\nprod-01\nLinux aarch64\n/usr/bin/docker") @@ -3456,7 +3673,7 @@ it('bootstraps a single v5 server with the Coolify CLI', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/bootstrap") + ->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/bootstrap") ->assertAccepted() ->assertJsonPath('message', 'Bootstrap queued.') ->assertJsonPath('cluster.servers.0.status', 'added') @@ -3538,7 +3755,7 @@ it('does not run a macOS development Coolify CLI binary from Docker during v5 se $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/bootstrap") + ->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/bootstrap") ->assertAccepted(); Process::fake([ @@ -3583,7 +3800,7 @@ it('keeps a v5 server added when Coolify CLI bootstrap fails', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/bootstrap") + ->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/bootstrap") ->assertAccepted() ->assertJsonPath('cluster.servers.0.status', 'added') ->assertJsonPath('cluster.servers.0.lastBootstrappedAt', null) @@ -3653,7 +3870,7 @@ it('extends a v5 cluster when bootstrapping a new server', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->postJson("/v5/clusters/{$cluster->id}/servers/{$newServer->id}/bootstrap") + ->postJson("/v5/clusters/{$cluster->uuid}/servers/{$newServer->uuid}/bootstrap") ->assertAccepted() ->assertJsonPath('cluster.servers.1.lastBootstrapAction', 'extend') ->assertJsonPath('cluster.servers.1.lastBootstrapStatus', 'queued'); @@ -3817,7 +4034,7 @@ it('deletes an unbootstrapped v5 server from a cluster', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->deleteJson("/v5/clusters/{$cluster->id}/servers/{$server->id}") + ->deleteJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}") ->assertSuccessful() ->assertJsonPath('cluster.serversCount', 0) ->assertJsonPath('cluster.servers', []); @@ -3852,7 +4069,7 @@ it('deletes a bootstrapped v5 server from a cluster', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->deleteJson("/v5/clusters/{$cluster->id}/servers/{$server->id}") + ->deleteJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}") ->assertSuccessful() ->assertJsonPath('cluster.serversCount', 0) ->assertJsonPath('cluster.servers', []); @@ -3891,7 +4108,7 @@ it('updates editable v5 server builder details without changing networking', fun $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->patchJson("/v5/clusters/{$cluster->id}/servers/{$server->id}", [ + ->patchJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}", [ 'builder_enabled' => true, 'builder_capacity' => 5, 'builder_cpu_quota' => '350%', @@ -3953,7 +4170,7 @@ it('updates editable v5 server caddy ingress capability independently from build $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->patchJson("/v5/clusters/{$cluster->id}/servers/{$server->id}", [ + ->patchJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}", [ 'builder_enabled' => false, 'builder_capacity' => 2, 'builder_cpu_quota' => '200%', @@ -4021,7 +4238,7 @@ it('rejects application ingress when server ingress is disabled', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->patchJson("/v5/applications/{$application->id}/ingress", [ + ->patchJson("/v5/applications/{$application->uuid}/ingress", [ 'ingress_enabled' => true, 'internal_port' => 3000, 'domains' => ['app.example.com'], @@ -4094,7 +4311,7 @@ it('enables application ingress without publishing domains by default', function $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->patchJson("/v5/applications/{$application->id}/ingress", [ + ->patchJson("/v5/applications/{$application->uuid}/ingress", [ 'ingress_enabled' => false, 'internal_port' => 8080, ]) @@ -4153,7 +4370,7 @@ it('validates application ingress domains', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->patchJson("/v5/applications/{$application->id}/ingress", [ + ->patchJson("/v5/applications/{$application->uuid}/ingress", [ 'ingress_enabled' => true, 'internal_port' => 3000, 'domains' => ['https://bad.example.com'], @@ -4223,7 +4440,7 @@ it('enables application ingress with explicit domains and port', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->patchJson("/v5/applications/{$application->id}/ingress", [ + ->patchJson("/v5/applications/{$application->uuid}/ingress", [ 'ingress_enabled' => true, 'internal_port' => 3000, 'domains' => ['app.example.com'], @@ -4288,7 +4505,7 @@ it('returns flux error details when application ingress sync fails', function () $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->patchJson("/v5/applications/{$application->id}/ingress", [ + ->patchJson("/v5/applications/{$application->uuid}/ingress", [ 'ingress_enabled' => true, 'internal_port' => 3000, 'domains' => ['app.example.com'], @@ -4368,7 +4585,7 @@ it('syncs caddy ingress routes through flux when enabling ingress on an installe $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->patchJson("/v5/clusters/{$cluster->id}/servers/{$server->id}", [ + ->patchJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}", [ 'builder_enabled' => false, 'builder_capacity' => 0, 'builder_cpu_quota' => '200%', @@ -4439,7 +4656,7 @@ it('returns flux error details when server ingress activation fails', function ( $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->patchJson("/v5/clusters/{$cluster->id}/servers/{$server->id}", [ + ->patchJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}", [ 'builder_enabled' => false, 'builder_capacity' => 0, 'builder_cpu_quota' => '200%', @@ -4479,7 +4696,7 @@ it('keeps editable v5 server builder capacity when disabling builder', function $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->patchJson("/v5/clusters/{$cluster->id}/servers/{$server->id}", [ + ->patchJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}", [ 'builder_enabled' => false, 'builder_capacity' => 5, 'builder_cpu_quota' => '350%', @@ -4524,7 +4741,7 @@ it('validates editable v5 server builder details', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->patchJson("/v5/clusters/{$cluster->id}/servers/{$server->id}", [ + ->patchJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}", [ 'builder_enabled' => true, 'builder_capacity' => 1001, 'builder_cpu_quota' => str_repeat('a', 33), @@ -4561,7 +4778,7 @@ it('requires positive editable v5 server builder capacity when builder is enable $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->patchJson("/v5/clusters/{$cluster->id}/servers/{$server->id}", [ + ->patchJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}", [ 'builder_enabled' => true, 'builder_capacity' => 0, 'builder_cpu_quota' => '200%', @@ -4622,7 +4839,7 @@ it('deletes an empty v5 cluster in the current team', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->deleteJson('/v5/clusters/'.$cluster->id) + ->deleteJson('/v5/clusters/'.$cluster->uuid) ->assertNoContent(); expect(Cluster::query()->whereKey($cluster->id)->exists())->toBeFalse(); @@ -4658,7 +4875,7 @@ it('does not delete a v5 cluster that has servers', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->deleteJson('/v5/clusters/'.$cluster->id) + ->deleteJson('/v5/clusters/'.$cluster->uuid) ->assertUnprocessable() ->assertJsonPath('message', 'Only empty clusters can be deleted.'); @@ -4691,7 +4908,7 @@ it('does not delete a v5 cluster outside the current team', function () { $this ->actingAs($user) ->withSession(['currentTeam' => $team]) - ->deleteJson('/v5/clusters/'.$cluster->id) + ->deleteJson('/v5/clusters/'.$cluster->uuid) ->assertNotFound(); expect(Cluster::query()->whereKey($cluster->id)->exists())->toBeTrue(); @@ -4869,7 +5086,7 @@ it('defines the v5 dashboard page as a shadcn styled canvas shell', function () ->toContain('selectedNginxServerId') ->toContain('nginxImage') ->toContain('docker.io/library/nginx:alpine') - ->toContain('server_id: selectedNginxServerId || null') + ->toContain('server_uuid: selectedNginxServerId || null') ->toContain('image: nginxImage.trim() || DEFAULT_NGINX_IMAGE') ->toContain('Center') ->toContain('Delete') @@ -5284,7 +5501,7 @@ it('provides reusable v5 form field primitives with reserved validation error sp ->toContain("import { Input } from '@/components/ui/input';") ->toContain("import { Textarea } from '@/components/ui/textarea';") ->toContain('') - ->toContain('') + ->toContain('') ->not->toContain('{errors.name ? {errors.name[0]} : null}') ->not->toContain('{serverErrors.name ? ('); }); @@ -5675,6 +5892,7 @@ function createSharedUserAndTeamTables(): void Schema::create('v5_clusters', function ($table) { $table->id(); + $table->string('uuid')->nullable()->unique(); $table->foreignId('team_id'); $table->foreignId('created_by_user_id'); $table->string('name'); @@ -5756,6 +5974,7 @@ function createSharedUserAndTeamTables(): void Schema::create('v5_applications', function ($table) { $table->id(); + $table->string('uuid')->nullable()->unique(); $table->foreignId('team_id'); $table->foreignId('project_id'); $table->foreignId('environment_id'); @@ -5786,6 +6005,7 @@ function createSharedUserAndTeamTables(): void Schema::create('v5_resource_connections', function ($table) { $table->id(); + $table->string('uuid')->nullable()->unique(); $table->foreignId('team_id'); $table->foreignId('project_id'); $table->foreignId('environment_id');