- @if ($server->proxySet() || $server->isSentinelEnabled())
+ @php
+ $showSentinelStatus = $server->isFunctional() && $server->isSentinelEnabled();
+ @endphp
+ @if ($server->proxySet() || $showSentinelStatus)
@if ($server->proxySet())
@@ -38,7 +41,7 @@
type="warning" />
@endif
- @if ($server->isSentinelEnabled())
+ @if ($showSentinelStatus)
@if ($server->isSentinelLive())
@else
diff --git a/tests/Feature/ServerNavbarStatusVisibilityTest.php b/tests/Feature/ServerNavbarStatusVisibilityTest.php
new file mode 100644
index 000000000..e39983552
--- /dev/null
+++ b/tests/Feature/ServerNavbarStatusVisibilityTest.php
@@ -0,0 +1,54 @@
+ 0]);
+});
+
+function makeNavbarServer(bool $isFunctional): array
+{
+ $team = Team::factory()->create();
+ $user = User::factory()->create();
+ $user->teams()->attach($team, ['role' => 'admin']);
+
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'sentinel_updated_at' => now(),
+ ]);
+
+ $server->settings()->update([
+ 'is_reachable' => $isFunctional,
+ 'is_usable' => $isFunctional,
+ 'is_sentinel_enabled' => true,
+ ]);
+
+ test()->actingAs($user);
+ session(['currentTeam' => $team]);
+
+ return [$server->fresh(), $user, $team];
+}
+
+it('does not show sentinel sync status before the server is validated', function () {
+ [$server] = makeNavbarServer(isFunctional: false);
+
+ Livewire::test('server.navbar', ['server' => $server])
+ ->assertDontSee('Sentinel')
+ ->assertDontSee('In sync')
+ ->assertDontSee('Out of sync');
+});
+
+it('shows sentinel sync status after the server is validated', function () {
+ [$server] = makeNavbarServer(isFunctional: true);
+
+ Livewire::test('server.navbar', ['server' => $server])
+ ->assertSee('Sentinel')
+ ->assertSee('In sync');
+});
From 67693acce72a237574a85649022ee604186cf6db Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 3 Jul 2026 10:14:39 +0200
Subject: [PATCH 44/56] fix(parser): preserve file volume state (#10843)
---
bootstrap/helpers/parsers.php | 31 +---
tests/Feature/FileStorageParserStateTest.php | 171 +++++++++++++++++++
2 files changed, 179 insertions(+), 23 deletions(-)
create mode 100644 tests/Feature/FileStorageParserStateTest.php
diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
index 6632e1fd5..ea99e9993 100644
--- a/bootstrap/helpers/parsers.php
+++ b/bootstrap/helpers/parsers.php
@@ -370,8 +370,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$pullRequestId = $pull_request_id;
$isPullRequest = $pullRequestId == 0 ? false : true;
$server = data_get($resource, 'destination.server');
- $fileStorages = $resource->fileStorages();
-
try {
$yaml = Yaml::parse($compose);
} catch (Exception) {
@@ -703,14 +701,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$source = $parsed['source'];
$target = $parsed['target'];
// Mode is available in $parsed['mode'] if needed
- $foundConfig = $fileStorages->whereMountPath($target)->first();
+ $foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
if (sourceIsLocal($source)) {
$type = str('bind');
if ($foundConfig) {
- $contentNotNull_temp = data_get($foundConfig, 'content');
- if ($contentNotNull_temp) {
- $content = $contentNotNull_temp;
- }
+ $content = data_get($foundConfig, 'content');
$isDirectory = data_get($foundConfig, 'is_directory');
} else {
// By default, we cannot determine if the bind is a directory or not, so we set it to directory
@@ -756,12 +751,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
}
- $foundConfig = $fileStorages->whereMountPath($target)->first();
+ $foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
if ($foundConfig) {
- $contentNotNull_temp = data_get($foundConfig, 'content');
- if ($contentNotNull_temp) {
- $content = $contentNotNull_temp;
- }
+ $content = data_get($foundConfig, 'content');
$isDirectory = data_get($foundConfig, 'is_directory');
} else {
// if isDirectory is not set (or false) & content is also not set, we assume it is a directory
@@ -2070,7 +2062,6 @@ function serviceParser(Service $resource): Collection
'service_id' => $resource->id,
]);
}
- $fileStorages = $savedService->fileStorages();
if ($savedService->image !== $image) {
$savedService->image = $image;
$savedService->save();
@@ -2090,14 +2081,11 @@ function serviceParser(Service $resource): Collection
$source = $parsed['source'];
$target = $parsed['target'];
// Mode is available in $parsed['mode'] if needed
- $foundConfig = $fileStorages->whereMountPath($target)->first();
+ $foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
if (sourceIsLocal($source)) {
$type = str('bind');
if ($foundConfig) {
- $contentNotNull_temp = data_get($foundConfig, 'content');
- if ($contentNotNull_temp) {
- $content = $contentNotNull_temp;
- }
+ $content = data_get($foundConfig, 'content');
$isDirectory = data_get($foundConfig, 'is_directory');
} else {
// By default, we cannot determine if the bind is a directory or not, so we set it to directory
@@ -2143,12 +2131,9 @@ function serviceParser(Service $resource): Collection
}
}
- $foundConfig = $fileStorages->whereMountPath($target)->first();
+ $foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
if ($foundConfig) {
- $contentNotNull_temp = data_get($foundConfig, 'content');
- if ($contentNotNull_temp) {
- $content = $contentNotNull_temp;
- }
+ $content = data_get($foundConfig, 'content');
$isDirectory = data_get($foundConfig, 'is_directory');
} else {
// if isDirectory is not set (or false) & content is also not set, we assume it is a directory
diff --git a/tests/Feature/FileStorageParserStateTest.php b/tests/Feature/FileStorageParserStateTest.php
new file mode 100644
index 000000000..bb39ad1d4
--- /dev/null
+++ b/tests/Feature/FileStorageParserStateTest.php
@@ -0,0 +1,171 @@
+create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+ $destination = StandaloneDocker::where('server_id', $server->id)->first();
+ $project = Project::factory()->create(['team_id' => $team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+
+ $this->destination = $destination;
+ $this->environment = $environment;
+});
+
+function makeComposeApplication(string $dockerComposeRaw): Application
+{
+ return Application::factory()->create([
+ 'environment_id' => test()->environment->id,
+ 'destination_id' => test()->destination->id,
+ 'destination_type' => test()->destination->getMorphClass(),
+ 'build_pack' => 'dockercompose',
+ 'docker_compose_raw' => $dockerComposeRaw,
+ ]);
+}
+
+/**
+ * @return array{0: Service, 1: ServiceApplication}
+ */
+function makeComposeService(string $dockerComposeRaw): array
+{
+ $service = Service::factory()->create([
+ 'environment_id' => test()->environment->id,
+ 'server_id' => test()->destination->server_id,
+ 'destination_id' => test()->destination->id,
+ 'destination_type' => test()->destination->getMorphClass(),
+ 'docker_compose_raw' => $dockerComposeRaw,
+ ]);
+
+ $serviceApplication = ServiceApplication::create([
+ 'name' => 'app',
+ 'service_id' => $service->id,
+ ]);
+
+ return [$service, $serviceApplication];
+}
+
+function seedFileVolume($resource, string $baseDir, string $fileName, string $mountPath, string $content): void
+{
+ LocalFileVolume::create([
+ 'fs_path' => "{$baseDir}/{$fileName}",
+ 'mount_path' => $mountPath,
+ 'content' => $content,
+ 'is_directory' => false,
+ 'resource_id' => $resource->id,
+ 'resource_type' => $resource->getMorphClass(),
+ ]);
+}
+
+it('preserves existing application file volume content when reparsing compose bind mounts', function () {
+ $application = makeComposeApplication(TWO_FILE_COMPOSE);
+ $baseDir = application_configuration_dir()."/{$application->uuid}";
+
+ seedFileVolume($application, $baseDir, 'wg_config.conf', '/app/ps/wg0.conf', 'test-conf');
+ seedFileVolume($application, $baseDir, 'override_trays.json', '/app/ps/override_tray.json', '0');
+
+ applicationParser($application);
+
+ $fileVolume = $application->fileStorages()->where('mount_path', '/app/ps/override_tray.json')->first();
+
+ expect($fileVolume->content)->toBe('0')
+ ->and($fileVolume->is_directory)->toBeFalse();
+});
+
+it('keeps existing application file volumes as files when content is empty', function () {
+ $application = makeComposeApplication(TWO_FILE_COMPOSE);
+ $baseDir = application_configuration_dir()."/{$application->uuid}";
+
+ seedFileVolume($application, $baseDir, 'wg_config.conf', '/app/ps/wg0.conf', 'test-conf');
+ seedFileVolume($application, $baseDir, 'override_trays.json', '/app/ps/override_tray.json', '');
+
+ applicationParser($application);
+
+ $fileVolume = $application->fileStorages()->where('mount_path', '/app/ps/override_tray.json')->first();
+
+ expect($fileVolume->content)->toBe('')
+ ->and($fileVolume->is_directory)->toBeFalse();
+});
+
+it('defaults new application bind mounts to directories', function () {
+ $application = makeComposeApplication(DATA_DIR_COMPOSE);
+
+ applicationParser($application);
+
+ $fileVolume = $application->fileStorages()->where('mount_path', '/app/data')->first();
+
+ expect($fileVolume->content)->toBeNull()
+ ->and($fileVolume->is_directory)->toBeTrue();
+});
+
+it('preserves existing service file volume content when reparsing compose bind mounts', function () {
+ [$service, $serviceApplication] = makeComposeService(TWO_FILE_COMPOSE);
+ $baseDir = service_configuration_dir()."/{$service->uuid}";
+
+ seedFileVolume($serviceApplication, $baseDir, 'wg_config.conf', '/app/ps/wg0.conf', 'test-conf');
+ seedFileVolume($serviceApplication, $baseDir, 'override_trays.json', '/app/ps/override_tray.json', '0');
+
+ serviceParser($service);
+
+ $fileVolume = $serviceApplication->fileStorages()->where('mount_path', '/app/ps/override_tray.json')->first();
+
+ expect($fileVolume->content)->toBe('0')
+ ->and($fileVolume->is_directory)->toBeFalse();
+});
+
+it('keeps existing service file volumes as files when content is empty', function () {
+ [$service, $serviceApplication] = makeComposeService(TWO_FILE_COMPOSE);
+ $baseDir = service_configuration_dir()."/{$service->uuid}";
+
+ seedFileVolume($serviceApplication, $baseDir, 'wg_config.conf', '/app/ps/wg0.conf', 'test-conf');
+ seedFileVolume($serviceApplication, $baseDir, 'override_trays.json', '/app/ps/override_tray.json', '');
+
+ serviceParser($service);
+
+ $fileVolume = $serviceApplication->fileStorages()->where('mount_path', '/app/ps/override_tray.json')->first();
+
+ expect($fileVolume->content)->toBe('')
+ ->and($fileVolume->is_directory)->toBeFalse();
+});
+
+it('defaults new service bind mounts to directories', function () {
+ [$service, $serviceApplication] = makeComposeService(DATA_DIR_COMPOSE);
+
+ serviceParser($service);
+
+ $fileVolume = $serviceApplication->fileStorages()->where('mount_path', '/app/data')->first();
+
+ expect($fileVolume->content)->toBeNull()
+ ->and($fileVolume->is_directory)->toBeTrue();
+});
From 29c122b31a981356ce833b669998b6a4354d7008 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 3 Jul 2026 11:31:49 +0200
Subject: [PATCH 45/56] fix(api): return deployment UUID strings directly
---
.../Api/ApplicationsController.php | 8 +-
app/Http/Controllers/Api/DeployController.php | 6 +-
app/Http/Controllers/Webhook/Bitbucket.php | 2 +-
app/Http/Controllers/Webhook/Gitea.php | 2 +-
app/Http/Controllers/Webhook/Gitlab.php | 2 +-
.../ApiApplicationDeploymentActionsTest.php | 108 ++++++++++++++++++
6 files changed, 118 insertions(+), 10 deletions(-)
create mode 100644 tests/Feature/ApiApplicationDeploymentActionsTest.php
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index d125c01ec..790fdf200 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -3609,7 +3609,7 @@ public function action_deploy(Request $request)
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
- 'deployment_uuid' => $deployment_uuid->toString(),
+ 'deployment_uuid' => $deployment_uuid,
'force_rebuild' => $force,
'instant_deploy' => $instant_deploy,
]);
@@ -3617,7 +3617,7 @@ public function action_deploy(Request $request)
return response()->json(
[
'message' => 'Deployment request queued.',
- 'deployment_uuid' => $deployment_uuid->toString(),
+ 'deployment_uuid' => $deployment_uuid,
],
200
);
@@ -3803,13 +3803,13 @@ public function action_restart(Request $request)
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
- 'deployment_uuid' => $deployment_uuid->toString(),
+ 'deployment_uuid' => $deployment_uuid,
]);
return response()->json(
[
'message' => 'Restart request queued.',
- 'deployment_uuid' => $deployment_uuid->toString(),
+ 'deployment_uuid' => $deployment_uuid,
],
);
}
diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php
index f0cf48efa..182080044 100644
--- a/app/Http/Controllers/Api/DeployController.php
+++ b/app/Http/Controllers/Api/DeployController.php
@@ -425,7 +425,7 @@ private function by_uuids(string $uuid, int $teamId, bool $force = false, int $p
}
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
if ($deployment_uuid) {
- $deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
+ $deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid]);
} else {
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid]);
}
@@ -471,7 +471,7 @@ public function by_tags(string $tags, int $team_id, bool $force = false)
}
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
if ($deployment_uuid) {
- $deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
+ $deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid]);
}
$message = $message->merge($return_message);
}
@@ -529,7 +529,7 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
'resource_type' => 'application',
'application_uuid' => $resource->uuid,
'application_name' => $resource->name,
- 'deployment_uuid' => $deployment_uuid?->toString(),
+ 'deployment_uuid' => $deployment_uuid,
'force_rebuild' => $force,
'pull_request_id' => $pr,
]);
diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php
index 435f5efab..fea55586b 100644
--- a/app/Http/Controllers/Webhook/Bitbucket.php
+++ b/app/Http/Controllers/Webhook/Bitbucket.php
@@ -162,7 +162,7 @@ public function manual(Request $request)
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
- 'deployment_uuid' => $deployment_uuid->toString(),
+ 'deployment_uuid' => $deployment_uuid,
'commit' => $commit,
'repository' => $full_name ?? null,
]);
diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php
index 82a8cc8af..a59cb5498 100644
--- a/app/Http/Controllers/Webhook/Gitea.php
+++ b/app/Http/Controllers/Webhook/Gitea.php
@@ -148,7 +148,7 @@ public function manual(Request $request)
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
- 'deployment_uuid' => $deployment_uuid->toString(),
+ 'deployment_uuid' => $deployment_uuid,
'commit' => data_get($payload, 'after'),
'repository' => $full_name ?? null,
]);
diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php
index c90f4ad40..cd68c1b67 100644
--- a/app/Http/Controllers/Webhook/Gitlab.php
+++ b/app/Http/Controllers/Webhook/Gitlab.php
@@ -190,7 +190,7 @@ public function manual(Request $request)
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
- 'deployment_uuid' => $deployment_uuid->toString(),
+ 'deployment_uuid' => $deployment_uuid,
'commit' => data_get($payload, 'after'),
'repository' => $full_name ?? null,
]);
diff --git a/tests/Feature/ApiApplicationDeploymentActionsTest.php b/tests/Feature/ApiApplicationDeploymentActionsTest.php
new file mode 100644
index 000000000..d431b3577
--- /dev/null
+++ b/tests/Feature/ApiApplicationDeploymentActionsTest.php
@@ -0,0 +1,108 @@
+ InstanceSettings::query()->updateOrCreate(
+ ['id' => 0],
+ ['is_api_enabled' => true],
+ ));
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ session(['currentTeam' => $this->team]);
+
+ $this->token = $this->user->createToken('deployment-actions-test', ['deploy'])->plainTextToken;
+
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+ $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+});
+
+function deploymentActionHeaders(string $token): array
+{
+ return [
+ 'Authorization' => 'Bearer '.$token,
+ 'Content-Type' => 'application/json',
+ ];
+}
+
+function makeDeploymentActionApplication(Environment $environment, StandaloneDocker $destination): Application
+{
+ return Application::factory()->create([
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => $destination->getMorphClass(),
+ 'git_repository' => 'https://github.com/coollabsio/coolify',
+ 'git_branch' => 'main',
+ 'git_commit_sha' => 'HEAD',
+ ]);
+}
+
+test('application start API returns queued deployment uuid as a string', function () {
+ $application = makeDeploymentActionApplication($this->environment, $this->destination);
+
+ $response = $this->withHeaders(deploymentActionHeaders($this->token))
+ ->postJson("/api/v1/applications/{$application->uuid}/start");
+
+ $response->assertSuccessful()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', 'Deployment request queued.')
+ ->whereType('deployment_uuid', 'string')
+ );
+
+ expect(ApplicationDeploymentQueue::query()->where('deployment_uuid', $response->json('deployment_uuid'))->exists())->toBeTrue();
+});
+
+test('application restart API returns queued deployment uuid as a string', function () {
+ $application = makeDeploymentActionApplication($this->environment, $this->destination);
+
+ $response = $this->withHeaders(deploymentActionHeaders($this->token))
+ ->postJson("/api/v1/applications/{$application->uuid}/restart");
+
+ $response->assertSuccessful()
+ ->assertJson(fn (AssertableJson $json) => $json
+ ->where('message', 'Restart request queued.')
+ ->whereType('deployment_uuid', 'string')
+ );
+
+ $deployment = ApplicationDeploymentQueue::query()
+ ->where('deployment_uuid', $response->json('deployment_uuid'))
+ ->first();
+
+ expect($deployment)->not->toBeNull()
+ ->and($deployment->restart_only)->toBeTruthy();
+});
+
+test('deployment uuid strings are not converted as objects in API and webhook controllers', function () {
+ $files = [
+ app_path('Http/Controllers/Api/DeployController.php'),
+ app_path('Http/Controllers/Webhook/Bitbucket.php'),
+ app_path('Http/Controllers/Webhook/Gitea.php'),
+ app_path('Http/Controllers/Webhook/Gitlab.php'),
+ ];
+
+ foreach ($files as $file) {
+ expect(file_get_contents($file))
+ ->not->toContain('$deployment_uuid->toString()')
+ ->not->toContain('$deployment_uuid?->toString()');
+ }
+});
From 9b060958aad7a02ef15dc6d8503e411b25ca523f Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 3 Jul 2026 11:40:20 +0200
Subject: [PATCH 46/56] fix(ray): remove Ray debug hooks from runtime (#10847)
---
.env.development.example | 6 -
app/Actions/Database/StartDatabaseProxy.php | 2 -
app/Actions/Server/DeleteServer.php | 21 -
app/Jobs/SendWebhookJob.php | 17 +-
app/Jobs/ServerConnectionCheckJob.php | 1 -
app/Livewire/GlobalSearch.php | 2 -
app/Livewire/Notifications/Webhook.php | 14 -
app/Livewire/Project/Shared/Logs.php | 1 -
app/Models/LocalFileVolume.php | 1 -
app/Models/LocalPersistentVolume.php | 1 -
app/Models/Server.php | 2 -
app/Models/Service.php | 1 -
app/Notifications/Channels/WebhookChannel.php | 12 -
.../Server/HetznerDeletionFailed.php | 2 -
app/Services/HetznerService.php | 11 +-
app/Traits/ClearsGlobalSearchCache.php | 24 +-
app/Traits/SshRetryable.php | 1 -
bootstrap/helpers/github.php | 2 -
bootstrap/helpers/notifications.php | 3 +-
bootstrap/helpers/parsers.php | 4 +-
bootstrap/helpers/shared.php | 4 -
composer.json | 1 -
composer.lock | 792 ++----------------
config/ray.php | 108 ---
.../ConvertContainerEnvsToArrayTest.php | 4 +-
tests/Feature/Security/AuditLogTest.php | 2 +-
.../DockerComposeRawContentRemovalTest.php | 4 +-
tests/Unit/RayRemovalTest.php | 53 ++
28 files changed, 141 insertions(+), 955 deletions(-)
delete mode 100644 config/ray.php
create mode 100644 tests/Unit/RayRemovalTest.php
diff --git a/.env.development.example b/.env.development.example
index d02b8ba59..9f594765d 100644
--- a/.env.development.example
+++ b/.env.development.example
@@ -27,12 +27,6 @@ DB_PORT=5432
# DB_WRITE_PASSWORD=
# DB_STICKY=true
-# Ray Configuration
-# Set to true to enable Ray
-RAY_ENABLED=false
-# Set custom ray port
-# RAY_PORT=
-
# Enable Laravel Telescope for debugging
TELESCOPE_ENABLED=false
diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php
index 1057d1e4d..1061394e6 100644
--- a/app/Actions/Database/StartDatabaseProxy.php
+++ b/app/Actions/Database/StartDatabaseProxy.php
@@ -143,8 +143,6 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
)
);
- ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}");
-
return;
}
diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php
index 45ec68abc..f0e57b2cc 100644
--- a/app/Actions/Server/DeleteServer.php
+++ b/app/Actions/Server/DeleteServer.php
@@ -26,22 +26,14 @@ public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $het
);
}
- ray($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion');
-
// If server is already deleted from Coolify, skip this part
if (! $server) {
return; // Server already force deleted from Coolify
}
- ray('force deleting server from Coolify', ['server_id' => $server->id]);
-
try {
$server->forceDelete();
} catch (\Throwable $e) {
- ray('Failed to force delete server from Coolify', [
- 'error' => $e->getMessage(),
- 'server_id' => $server->id,
- ]);
logger()->error('Failed to force delete server from Coolify', [
'error' => $e->getMessage(),
'server_id' => $server->id,
@@ -66,10 +58,6 @@ private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProvider
}
if (! $token) {
- ray('No Hetzner token found for team, skipping Hetzner deletion', [
- 'team_id' => $teamId,
- 'hetzner_server_id' => $hetznerServerId,
- ]);
return;
}
@@ -77,16 +65,7 @@ private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProvider
$hetznerService = new HetznerService($token->token);
$hetznerService->deleteServer($hetznerServerId);
- ray('Deleted server from Hetzner', [
- 'hetzner_server_id' => $hetznerServerId,
- 'team_id' => $teamId,
- ]);
} catch (\Throwable $e) {
- ray('Failed to delete server from Hetzner', [
- 'error' => $e->getMessage(),
- 'hetzner_server_id' => $hetznerServerId,
- 'team_id' => $teamId,
- ]);
// Log the error but don't prevent the server from being deleted from Coolify
logger()->error('Failed to delete server from Hetzner', [
diff --git a/app/Jobs/SendWebhookJob.php b/app/Jobs/SendWebhookJob.php
index accbf906f..c14d70a77 100644
--- a/app/Jobs/SendWebhookJob.php
+++ b/app/Jobs/SendWebhookJob.php
@@ -57,13 +57,6 @@ public function handle(): void
return;
}
- if (isDev()) {
- ray('Sending webhook notification', [
- 'url' => $this->webhookUrl,
- 'payload' => $this->payload,
- ]);
- }
-
try {
$httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl);
} catch (\RuntimeException $e) {
@@ -75,14 +68,6 @@ public function handle(): void
return;
}
- $response = Http::withOptions($httpOptions)->post($this->webhookUrl, $this->payload);
-
- if (isDev()) {
- ray('Webhook response', [
- 'status' => $response->status(),
- 'body' => $response->body(),
- 'successful' => $response->successful(),
- ]);
- }
+ Http::withOptions($httpOptions)->post($this->webhookUrl, $this->payload);
}
}
diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php
index 98ad60fff..83f474ccc 100644
--- a/app/Jobs/ServerConnectionCheckJob.php
+++ b/app/Jobs/ServerConnectionCheckJob.php
@@ -179,7 +179,6 @@ private function checkHetznerStatus(): void
$this->server->update(['hetzner_server_status' => $status]);
$this->server->hetzner_server_status = $status;
if ($status === 'off') {
- ray('Server is powered off, marking as unreachable');
throw new \Exception('Server is powered off');
}
}
diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php
index 4148764de..4d9b1af35 100644
--- a/app/Livewire/GlobalSearch.php
+++ b/app/Livewire/GlobalSearch.php
@@ -251,7 +251,6 @@ private function loadSearchableItems()
$cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id);
$this->allSearchableItems = Cache::remember($cacheKey, 300, function () {
- ray()->showQueries();
$items = collect();
$team = auth()->user()->currentTeam();
@@ -530,7 +529,6 @@ private function loadSearchableItems()
'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description.' server servers'),
];
});
- ray($servers);
// Get all projects
$projects = Project::ownedByCurrentTeam()
->withCount(['environments', 'applications', 'services'])
diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php
index 4a67180ff..606c3c541 100644
--- a/app/Livewire/Notifications/Webhook.php
+++ b/app/Livewire/Notifications/Webhook.php
@@ -168,13 +168,6 @@ public function saveModel()
$this->syncData(true);
refreshSession();
- if (isDev()) {
- ray('Webhook settings saved', [
- 'webhook_enabled' => $this->settings->webhook_enabled,
- 'webhook_url' => $this->settings->webhook_url,
- ]);
- }
-
$this->dispatch('success', 'Settings saved.');
}
@@ -183,13 +176,6 @@ public function sendTestNotification()
try {
$this->authorize('sendTest', $this->settings);
- if (isDev()) {
- ray('Sending test webhook notification', [
- 'team_id' => $this->team->id,
- 'webhook_url' => $this->settings->webhook_url,
- ]);
- }
-
$this->team->notify(new Test(channel: 'webhook'));
$this->dispatch('success', 'Test notification sent.');
} catch (\Throwable $e) {
diff --git a/app/Livewire/Project/Shared/Logs.php b/app/Livewire/Project/Shared/Logs.php
index a95259c71..1b93ec47e 100644
--- a/app/Livewire/Project/Shared/Logs.php
+++ b/app/Livewire/Project/Shared/Logs.php
@@ -90,7 +90,6 @@ private function getContainersForServer($server)
}
} catch (\Exception $e) {
// Log error but don't fail the entire operation
- ray("Error loading containers for server {$server->name}: ".$e->getMessage());
return [];
}
diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php
index b521ede3d..4d18d4ca2 100644
--- a/app/Models/LocalFileVolume.php
+++ b/app/Models/LocalFileVolume.php
@@ -366,7 +366,6 @@ public function isReadOnlyVolume(): bool
return false;
} catch (\Throwable $e) {
- ray($e->getMessage(), 'Error checking read-only volume');
return false;
}
diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php
index 2f0f482b0..d44c86c0c 100644
--- a/app/Models/LocalPersistentVolume.php
+++ b/app/Models/LocalPersistentVolume.php
@@ -187,7 +187,6 @@ public function isReadOnlyVolume(): bool
return false;
} catch (\Throwable $e) {
- ray($e->getMessage(), 'Error checking read-only persistent volume');
return false;
}
diff --git a/app/Models/Server.php b/app/Models/Server.php
index 0102b327e..f6fc39df9 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -1523,7 +1523,6 @@ private function disableSshMux(): void
public function generateCaCertificate()
{
try {
- ray('Generating CA certificate for server', $this->id);
SslHelper::generateSslCertificate(
commonName: 'Coolify CA Certificate',
serverId: $this->id,
@@ -1531,7 +1530,6 @@ public function generateCaCertificate()
validityDays: 10 * 365
);
$caCertificate = $this->sslCertificates()->where('is_ca_certificate', true)->first();
- ray('CA certificate generated', $caCertificate);
if ($caCertificate) {
$certificateContent = $caCertificate->ssl_certificate;
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
diff --git a/app/Models/Service.php b/app/Models/Service.php
index 72b467657..98af0472f 100644
--- a/app/Models/Service.php
+++ b/app/Models/Service.php
@@ -1583,7 +1583,6 @@ public function saveComposeConfigs()
$envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName);
}
} catch (\Exception $e) {
- ray($e->getMessage());
}
}
diff --git a/app/Notifications/Channels/WebhookChannel.php b/app/Notifications/Channels/WebhookChannel.php
index 8c3e74b17..e735b88bb 100644
--- a/app/Notifications/Channels/WebhookChannel.php
+++ b/app/Notifications/Channels/WebhookChannel.php
@@ -15,23 +15,11 @@ public function send($notifiable, Notification $notification): void
$webhookSettings = $notifiable->webhookNotificationSettings;
if (! $webhookSettings || ! $webhookSettings->isEnabled() || ! $webhookSettings->webhook_url) {
- if (isDev()) {
- ray('Webhook notification skipped - not enabled or no URL configured');
- }
-
return;
}
$payload = $notification->toWebhook();
- if (isDev()) {
- ray('Dispatching webhook notification', [
- 'notification' => get_class($notification),
- 'url' => $webhookSettings->webhook_url,
- 'payload' => $payload,
- ]);
- }
-
SendWebhookJob::dispatch($payload, $webhookSettings->webhook_url);
}
}
diff --git a/app/Notifications/Server/HetznerDeletionFailed.php b/app/Notifications/Server/HetznerDeletionFailed.php
index de894331b..bb452b054 100644
--- a/app/Notifications/Server/HetznerDeletionFailed.php
+++ b/app/Notifications/Server/HetznerDeletionFailed.php
@@ -17,8 +17,6 @@ public function __construct(public int $hetznerServerId, public int $teamId, pub
public function via(object $notifiable): array
{
- ray('hello');
- ray($notifiable);
return $notifiable->getEnabledChannels('hetzner_deletion_failed');
}
diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php
index 1de7eb2b1..099aecf2e 100644
--- a/app/Services/HetznerService.php
+++ b/app/Services/HetznerService.php
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Exceptions\RateLimitException;
+use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
class HetznerService
@@ -24,7 +25,7 @@ private function request(string $method, string $endpoint, array $data = [])
->timeout(30)
->retry(3, function (int $attempt, \Exception $exception) {
// Handle rate limiting (429 Too Many Requests)
- if ($exception instanceof \Illuminate\Http\Client\RequestException) {
+ if ($exception instanceof RequestException) {
$response = $exception->response;
if ($response && $response->status() === 429) {
@@ -129,17 +130,9 @@ public function uploadSshKey(string $name, string $publicKey): array
public function createServer(array $params): array
{
- ray('Hetzner createServer request', [
- 'endpoint' => '/servers',
- 'params' => $params,
- ]);
$response = $this->request('post', '/servers', $params);
- ray('Hetzner createServer response', [
- 'response' => $response,
- ]);
-
return $response['server'] ?? [];
}
diff --git a/app/Traits/ClearsGlobalSearchCache.php b/app/Traits/ClearsGlobalSearchCache.php
index b9af70aba..e935bfb6d 100644
--- a/app/Traits/ClearsGlobalSearchCache.php
+++ b/app/Traits/ClearsGlobalSearchCache.php
@@ -3,6 +3,11 @@
namespace App\Traits;
use App\Livewire\GlobalSearch;
+use App\Models\Application;
+use App\Models\Environment;
+use App\Models\Project;
+use App\Models\Server;
+use App\Models\Service;
use Illuminate\Database\Eloquent\Model;
trait ClearsGlobalSearchCache
@@ -20,7 +25,6 @@ protected static function bootClearsGlobalSearchCache()
}
} catch (\Throwable $e) {
// Silently fail cache clearing - don't break the save operation
- ray('Failed to clear global search cache on saving: '.$e->getMessage());
}
});
@@ -33,7 +37,6 @@ protected static function bootClearsGlobalSearchCache()
}
} catch (\Throwable $e) {
// Silently fail cache clearing - don't break the create operation
- ray('Failed to clear global search cache on creation: '.$e->getMessage());
}
});
@@ -46,7 +49,6 @@ protected static function bootClearsGlobalSearchCache()
}
} catch (\Throwable $e) {
// Silently fail cache clearing - don't break the delete operation
- ray('Failed to clear global search cache on deletion: '.$e->getMessage());
}
});
}
@@ -58,14 +60,14 @@ private function hasSearchableChanges(): bool
$searchableFields = ['name', 'description'];
// Add model-specific searchable fields
- if ($this instanceof \App\Models\Application) {
+ if ($this instanceof Application) {
$searchableFields[] = 'fqdn';
$searchableFields[] = 'docker_compose_domains';
- } elseif ($this instanceof \App\Models\Server) {
+ } elseif ($this instanceof Server) {
$searchableFields[] = 'ip';
- } elseif ($this instanceof \App\Models\Service) {
+ } elseif ($this instanceof Service) {
// Services don't have direct fqdn, but name and description are covered
- } elseif ($this instanceof \App\Models\Project || $this instanceof \App\Models\Environment) {
+ } elseif ($this instanceof Project || $this instanceof Environment) {
// Projects and environments only have name and description as searchable
}
// Database models only have name and description as searchable
@@ -81,7 +83,6 @@ private function hasSearchableChanges(): bool
return false;
} catch (\Throwable $e) {
// If checking changes fails, assume changes exist to be safe
- ray('Failed to check searchable changes: '.$e->getMessage());
return true;
}
@@ -91,18 +92,18 @@ private function getTeamIdForCache()
{
try {
// For Project models (has direct team_id)
- if ($this instanceof \App\Models\Project) {
+ if ($this instanceof Project) {
return $this->team_id ?? null;
}
// For Environment models (get team_id through project)
- if ($this instanceof \App\Models\Environment) {
+ if ($this instanceof Environment) {
return $this->project?->team_id;
}
// For database models, team is accessed through environment.project.team
if (method_exists($this, 'team')) {
- if ($this instanceof \App\Models\Server) {
+ if ($this instanceof Server) {
$team = $this->team;
} else {
$team = $this->team();
@@ -120,7 +121,6 @@ private function getTeamIdForCache()
return null;
} catch (\Throwable $e) {
// If we can't determine team ID, return null
- ray('Failed to get team ID for cache: '.$e->getMessage());
return null;
}
diff --git a/app/Traits/SshRetryable.php b/app/Traits/SshRetryable.php
index 37303c7e6..901e957b2 100644
--- a/app/Traits/SshRetryable.php
+++ b/app/Traits/SshRetryable.php
@@ -82,7 +82,6 @@ protected function executeWithSshRetry(callable $callback, array $context = [],
$lastErrorMessage = '';
// Randomly fail the command with a key exchange error for testing
// if (random_int(1, 10) === 1) { // 10% chance to fail
- // ray('SSH key exchange failed: kex_exchange_identification: read: Connection reset by peer');
// throw new \RuntimeException('SSH key exchange failed: kex_exchange_identification: read: Connection reset by peer');
// }
for ($attempt = 0; $attempt < $maxRetries; $attempt++) {
diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php
index 978e9ddae..f5d7a6e9c 100644
--- a/bootstrap/helpers/github.php
+++ b/bootstrap/helpers/github.php
@@ -276,7 +276,6 @@ function getGithubCommitRangeFiles(?GithubApp $source, string $owner, string $re
return $files->pluck('filename')->filter()->values()->toArray();
} catch (Exception $e) {
- ray('Error fetching GitHub commit range files: '.$e->getMessage());
return [];
}
@@ -302,7 +301,6 @@ function getGithubPullRequestFiles(?GithubApp $source, string $owner, string $re
return $files->pluck('filename')->filter()->values()->toArray();
} catch (Exception $e) {
- ray('Error fetching GitHub PR files: '.$e->getMessage());
return [];
}
diff --git a/bootstrap/helpers/notifications.php b/bootstrap/helpers/notifications.php
index bee39ef01..f64535ea8 100644
--- a/bootstrap/helpers/notifications.php
+++ b/bootstrap/helpers/notifications.php
@@ -18,8 +18,7 @@ function send_internal_notification(string $message): void
try {
$team = Team::find(0);
$team?->notify(new GeneralNotification($message));
- } catch (\Throwable $e) {
- ray($e->getMessage());
+ } catch (Throwable) {
}
}
diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
index ea99e9993..999919991 100644
--- a/bootstrap/helpers/parsers.php
+++ b/bootstrap/helpers/parsers.php
@@ -1480,9 +1480,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
}
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
- } catch (Exception $e) {
+ } catch (Exception) {
// If parsing fails, keep the original docker_compose_raw unchanged
- ray('Failed to update docker_compose_raw in applicationParser: '.$e->getMessage());
}
data_forget($resource, 'environment_variables');
@@ -2732,7 +2731,6 @@ function serviceParser(Service $resource): Collection
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
} catch (Exception $e) {
// If parsing fails, keep the original docker_compose_raw unchanged
- ray('Failed to update docker_compose_raw in serviceParser: '.$e->getMessage());
}
data_forget($resource, 'environment_variables');
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index 39c23d2bb..10de1f86f 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -1764,7 +1764,6 @@ function validateDNSEntry(string $fqdn, Server $server)
$query = new DNSQuery($dns_server);
$results = $query->query($host, $type);
if ($results === false || $query->hasError()) {
- ray('Error: '.$query->getLasterror());
} else {
foreach ($results as $result) {
if ($result->getType() == $type) {
@@ -3787,9 +3786,6 @@ function loggy($message = null, array $context = [])
if (! isDev()) {
return;
}
- if (function_exists('ray') && config('app.debug')) {
- ray($message, $context);
- }
if (is_null($message)) {
return app('log');
}
diff --git a/composer.json b/composer.json
index 9415aa624..dd6af3132 100644
--- a/composer.json
+++ b/composer.json
@@ -50,7 +50,6 @@
"spatie/laravel-activitylog": "^4.11.0",
"spatie/laravel-data": "^4.19.1",
"spatie/laravel-markdown": "^2.7.1",
- "spatie/laravel-ray": "^1.43.5",
"spatie/laravel-schemaless-attributes": "^2.5.1",
"spatie/url": "^2.4",
"stevebauman/purify": "^6.3.1",
diff --git a/composer.lock b/composer.lock
index ef67627bc..174a3931b 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "64b77285a7140ce68e83db2659e9a21d",
+ "content-hash": "dff5cce0f3fe7ba422d9d121cce7c082",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -4753,134 +4753,6 @@
},
"time": "2020-10-15T08:29:30+00:00"
},
- {
- "name": "php-di/invoker",
- "version": "2.3.7",
- "source": {
- "type": "git",
- "url": "https://github.com/PHP-DI/Invoker.git",
- "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/PHP-DI/Invoker/zipball/3c1ddfdef181431fbc4be83378f6d036d59e81e1",
- "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1",
- "shasum": ""
- },
- "require": {
- "php": ">=7.3",
- "psr/container": "^1.0|^2.0"
- },
- "require-dev": {
- "athletic/athletic": "~0.1.8",
- "mnapoli/hard-mode": "~0.3.0",
- "phpunit/phpunit": "^9.0 || ^10 || ^11 || ^12"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Invoker\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "description": "Generic and extensible callable invoker",
- "homepage": "https://github.com/PHP-DI/Invoker",
- "keywords": [
- "callable",
- "dependency",
- "dependency-injection",
- "injection",
- "invoke",
- "invoker"
- ],
- "support": {
- "issues": "https://github.com/PHP-DI/Invoker/issues",
- "source": "https://github.com/PHP-DI/Invoker/tree/2.3.7"
- },
- "funding": [
- {
- "url": "https://github.com/mnapoli",
- "type": "github"
- }
- ],
- "time": "2025-08-30T10:22:22+00:00"
- },
- {
- "name": "php-di/php-di",
- "version": "7.1.1",
- "source": {
- "type": "git",
- "url": "https://github.com/PHP-DI/PHP-DI.git",
- "reference": "f88054cc052e40dbe7b383c8817c19442d480352"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/f88054cc052e40dbe7b383c8817c19442d480352",
- "reference": "f88054cc052e40dbe7b383c8817c19442d480352",
- "shasum": ""
- },
- "require": {
- "laravel/serializable-closure": "^1.0 || ^2.0",
- "php": ">=8.0",
- "php-di/invoker": "^2.0",
- "psr/container": "^1.1 || ^2.0"
- },
- "provide": {
- "psr/container-implementation": "^1.0"
- },
- "require-dev": {
- "friendsofphp/php-cs-fixer": "^3",
- "friendsofphp/proxy-manager-lts": "^1",
- "mnapoli/phpunit-easymock": "^1.3",
- "phpunit/phpunit": "^9.6 || ^10 || ^11",
- "vimeo/psalm": "^5|^6"
- },
- "suggest": {
- "friendsofphp/proxy-manager-lts": "Install it if you want to use lazy injection (version ^1)"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/functions.php"
- ],
- "psr-4": {
- "DI\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "description": "The dependency injection container for humans",
- "homepage": "https://php-di.org/",
- "keywords": [
- "PSR-11",
- "container",
- "container-interop",
- "dependency injection",
- "di",
- "ioc",
- "psr11"
- ],
- "support": {
- "issues": "https://github.com/PHP-DI/PHP-DI/issues",
- "source": "https://github.com/PHP-DI/PHP-DI/tree/7.1.1"
- },
- "funding": [
- {
- "url": "https://github.com/mnapoli",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/php-di/php-di",
- "type": "tidelift"
- }
- ],
- "time": "2025-08-16T11:10:48+00:00"
- },
{
"name": "phpdocumentor/reflection-common",
"version": "2.2.0",
@@ -7026,70 +6898,6 @@
},
"time": "2024-11-07T21:57:40+00:00"
},
- {
- "name": "spatie/backtrace",
- "version": "1.8.2",
- "source": {
- "type": "git",
- "url": "https://github.com/spatie/backtrace.git",
- "reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/spatie/backtrace/zipball/8ffe78be5ed355b5009e3dd989d183433e9a5adc",
- "reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc",
- "shasum": ""
- },
- "require": {
- "php": "^7.3 || ^8.0"
- },
- "require-dev": {
- "ext-json": "*",
- "laravel/serializable-closure": "^1.3 || ^2.0",
- "phpunit/phpunit": "^9.3 || ^11.4.3",
- "spatie/phpunit-snapshot-assertions": "^4.2 || ^5.1.6",
- "symfony/var-dumper": "^5.1|^6.0|^7.0|^8.0"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Spatie\\Backtrace\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Freek Van de Herten",
- "email": "freek@spatie.be",
- "homepage": "https://spatie.be",
- "role": "Developer"
- }
- ],
- "description": "A better backtrace",
- "homepage": "https://github.com/spatie/backtrace",
- "keywords": [
- "Backtrace",
- "spatie"
- ],
- "support": {
- "issues": "https://github.com/spatie/backtrace/issues",
- "source": "https://github.com/spatie/backtrace/tree/1.8.2"
- },
- "funding": [
- {
- "url": "https://github.com/sponsors/spatie",
- "type": "github"
- },
- {
- "url": "https://spatie.be/open-source/support-us",
- "type": "other"
- }
- ],
- "time": "2026-03-11T13:48:28+00:00"
- },
{
"name": "spatie/commonmark-shiki-highlighter",
"version": "2.5.2",
@@ -7462,95 +7270,6 @@
],
"time": "2026-05-19T14:06:37+00:00"
},
- {
- "name": "spatie/laravel-ray",
- "version": "1.43.9",
- "source": {
- "type": "git",
- "url": "https://github.com/spatie/laravel-ray.git",
- "reference": "85137a6ea1d3ecd5ad3adcb43512fff9a5529e72"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/85137a6ea1d3ecd5ad3adcb43512fff9a5529e72",
- "reference": "85137a6ea1d3ecd5ad3adcb43512fff9a5529e72",
- "shasum": ""
- },
- "require": {
- "composer-runtime-api": "^2.2",
- "ext-json": "*",
- "illuminate/contracts": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0",
- "illuminate/database": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0",
- "illuminate/queue": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0",
- "illuminate/support": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0",
- "php": "^7.4|^8.0",
- "spatie/backtrace": "^1.7.1",
- "spatie/ray": "^1.45.0",
- "symfony/stopwatch": "4.2|^5.1|^6.0|^7.0|^8.0",
- "zbateson/mail-mime-parser": "^1.3.1|^2.0|^3.0|^4.0"
- },
- "require-dev": {
- "guzzlehttp/guzzle": "^7.3",
- "laravel/framework": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0",
- "laravel/pint": "^1.29",
- "orchestra/testbench-core": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
- "pestphp/pest": "^1.22|^2.0|^3.0|^4.0",
- "phpstan/phpstan": "^1.10.57|^2.0.2",
- "phpunit/phpunit": "^9.3|^10.1|^11.0.10|^12.4",
- "rector/rector": "^0.19.2|^1.0.1|^2.0.0",
- "spatie/pest-plugin-snapshots": "^1.1|^2.0",
- "symfony/var-dumper": "^4.2|^5.1|^6.0|^7.0.3|^8.0"
- },
- "type": "library",
- "extra": {
- "laravel": {
- "providers": [
- "Spatie\\LaravelRay\\RayServiceProvider"
- ]
- },
- "branch-alias": {
- "dev-main": "1.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Spatie\\LaravelRay\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Freek Van der Herten",
- "email": "freek@spatie.be",
- "homepage": "https://spatie.be",
- "role": "Developer"
- }
- ],
- "description": "Easily debug Laravel apps",
- "homepage": "https://github.com/spatie/laravel-ray",
- "keywords": [
- "laravel-ray",
- "spatie"
- ],
- "support": {
- "issues": "https://github.com/spatie/laravel-ray/issues",
- "source": "https://github.com/spatie/laravel-ray/tree/1.43.9"
- },
- "funding": [
- {
- "url": "https://github.com/sponsors/spatie",
- "type": "github"
- },
- {
- "url": "https://spatie.be/open-source/support-us",
- "type": "other"
- }
- ],
- "time": "2026-04-28T06:07:04+00:00"
- },
{
"name": "spatie/laravel-schemaless-attributes",
"version": "2.6.0",
@@ -7757,91 +7476,6 @@
],
"time": "2026-04-28T06:26:02+00:00"
},
- {
- "name": "spatie/ray",
- "version": "1.48.0",
- "source": {
- "type": "git",
- "url": "https://github.com/spatie/ray.git",
- "reference": "974ac9c6e315033ab8ace883d60e094522f88ede"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/spatie/ray/zipball/974ac9c6e315033ab8ace883d60e094522f88ede",
- "reference": "974ac9c6e315033ab8ace883d60e094522f88ede",
- "shasum": ""
- },
- "require": {
- "ext-curl": "*",
- "ext-json": "*",
- "php": "^7.4|^8.0",
- "ramsey/uuid": "^3.0|^4.1",
- "spatie/backtrace": "^1.7.1",
- "spatie/macroable": "^1.0|^2.0",
- "symfony/stopwatch": "^4.2|^5.1|^6.0|^7.0|^8.0",
- "symfony/var-dumper": "^4.2|^5.1|^6.0|^7.0.3|^8.0"
- },
- "require-dev": {
- "illuminate/support": "^7.20|^8.18|^9.0|^10.0|^11.0|^12.0|^13.0",
- "nesbot/carbon": "^2.63|^3.8.4",
- "pestphp/pest": "^1.22",
- "phpstan/phpstan": "^1.10.57|^2.0.3",
- "phpunit/phpunit": "^9.5",
- "rector/rector": "^0.19.2|^1.0.1|^2.0.0",
- "spatie/phpunit-snapshot-assertions": "^4.2",
- "spatie/test-time": "^1.2"
- },
- "bin": [
- "bin/remove-ray.sh"
- ],
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "1.x-dev"
- }
- },
- "autoload": {
- "files": [
- "src/helpers.php"
- ],
- "psr-4": {
- "Spatie\\Ray\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Freek Van der Herten",
- "email": "freek@spatie.be",
- "homepage": "https://spatie.be",
- "role": "Developer"
- }
- ],
- "description": "Debug with Ray to fix problems faster",
- "homepage": "https://github.com/spatie/ray",
- "keywords": [
- "ray",
- "spatie"
- ],
- "support": {
- "issues": "https://github.com/spatie/ray/issues",
- "source": "https://github.com/spatie/ray/tree/1.48.0"
- },
- "funding": [
- {
- "url": "https://github.com/sponsors/spatie",
- "type": "github"
- },
- {
- "url": "https://spatie.be/open-source/support-us",
- "type": "other"
- }
- ],
- "time": "2026-03-31T12:44:31+00:00"
- },
{
"name": "spatie/shiki-php",
"version": "2.4.0",
@@ -9503,90 +9137,6 @@
],
"time": "2026-04-10T16:19:22+00:00"
},
- {
- "name": "symfony/polyfill-iconv",
- "version": "v1.37.0",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/polyfill-iconv.git",
- "reference": "2c5729fd241b4b22f6e4b436bc3354a4f262df57"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/2c5729fd241b4b22f6e4b436bc3354a4f262df57",
- "reference": "2c5729fd241b4b22f6e4b436bc3354a4f262df57",
- "shasum": ""
- },
- "require": {
- "php": ">=7.2"
- },
- "provide": {
- "ext-iconv": "*"
- },
- "suggest": {
- "ext-iconv": "For best performance"
- },
- "type": "library",
- "extra": {
- "thanks": {
- "url": "https://github.com/symfony/polyfill",
- "name": "symfony/polyfill"
- }
- },
- "autoload": {
- "files": [
- "bootstrap.php"
- ],
- "psr-4": {
- "Symfony\\Polyfill\\Iconv\\": ""
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Symfony polyfill for the Iconv extension",
- "homepage": "https://symfony.com",
- "keywords": [
- "compatibility",
- "iconv",
- "polyfill",
- "portable",
- "shim"
- ],
- "support": {
- "source": "https://github.com/symfony/polyfill-iconv/tree/v1.37.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://github.com/nicolas-grekas",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2026-04-10T16:19:22+00:00"
- },
{
"name": "symfony/polyfill-intl-grapheme",
"version": "v1.38.1",
@@ -10922,72 +10472,6 @@
],
"time": "2026-03-28T09:44:51+00:00"
},
- {
- "name": "symfony/stopwatch",
- "version": "v8.0.8",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/stopwatch.git",
- "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/stopwatch/zipball/85954ed72d5440ea4dc9a10b7e49e01df766ffa3",
- "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3",
- "shasum": ""
- },
- "require": {
- "php": ">=8.4",
- "symfony/service-contracts": "^2.5|^3"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Symfony\\Component\\Stopwatch\\": ""
- },
- "exclude-from-classmap": [
- "/Tests/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Fabien Potencier",
- "email": "fabien@symfony.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Provides a way to profile code",
- "homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/stopwatch/tree/v8.0.8"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://github.com/nicolas-grekas",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2026-03-30T15:14:47+00:00"
- },
{
"name": "symfony/string",
"version": "v8.0.13",
@@ -12192,214 +11676,6 @@
},
"time": "2018-08-08T15:08:14+00:00"
},
- {
- "name": "zbateson/mail-mime-parser",
- "version": "4.0.1",
- "source": {
- "type": "git",
- "url": "https://github.com/zbateson/mail-mime-parser.git",
- "reference": "3db681988a48fdffdba551dcc6b2f4c2da574540"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/zbateson/mail-mime-parser/zipball/3db681988a48fdffdba551dcc6b2f4c2da574540",
- "reference": "3db681988a48fdffdba551dcc6b2f4c2da574540",
- "shasum": ""
- },
- "require": {
- "guzzlehttp/psr7": "^2.5",
- "php": ">=8.1",
- "php-di/php-di": "^6.0|^7.0",
- "psr/log": "^1|^2|^3",
- "zbateson/mb-wrapper": "^2.0 || ^3.0",
- "zbateson/stream-decorators": "^2.1 || ^3.0"
- },
- "require-dev": {
- "friendsofphp/php-cs-fixer": "^3.0",
- "monolog/monolog": "^2|^3",
- "phpstan/phpstan": "^2.0",
- "phpunit/phpunit": "^10.5"
- },
- "suggest": {
- "ext-iconv": "For best support/performance",
- "ext-mbstring": "For best support/performance"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "ZBateson\\MailMimeParser\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-2-Clause"
- ],
- "authors": [
- {
- "name": "Zaahid Bateson"
- },
- {
- "name": "Contributors",
- "homepage": "https://github.com/zbateson/mail-mime-parser/graphs/contributors"
- }
- ],
- "description": "MIME email message parser",
- "homepage": "https://mail-mime-parser.org",
- "keywords": [
- "MimeMailParser",
- "email",
- "mail",
- "mailparse",
- "mime",
- "mimeparse",
- "parser",
- "php-imap"
- ],
- "support": {
- "docs": "https://mail-mime-parser.org/#usage-guide",
- "issues": "https://github.com/zbateson/mail-mime-parser/issues",
- "source": "https://github.com/zbateson/mail-mime-parser"
- },
- "funding": [
- {
- "url": "https://github.com/zbateson",
- "type": "github"
- }
- ],
- "time": "2026-03-11T18:03:41+00:00"
- },
- {
- "name": "zbateson/mb-wrapper",
- "version": "3.0.0",
- "source": {
- "type": "git",
- "url": "https://github.com/zbateson/mb-wrapper.git",
- "reference": "f0ee6af2712e92e52ee2552588cd69d21ab3363f"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/zbateson/mb-wrapper/zipball/f0ee6af2712e92e52ee2552588cd69d21ab3363f",
- "reference": "f0ee6af2712e92e52ee2552588cd69d21ab3363f",
- "shasum": ""
- },
- "require": {
- "php": ">=8.1",
- "symfony/polyfill-iconv": "^1.9",
- "symfony/polyfill-mbstring": "^1.9"
- },
- "require-dev": {
- "friendsofphp/php-cs-fixer": "*",
- "phpstan/phpstan": "*",
- "phpunit/phpunit": "^10.0|^11.0"
- },
- "suggest": {
- "ext-iconv": "For best support/performance",
- "ext-mbstring": "For best support/performance"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "ZBateson\\MbWrapper\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-2-Clause"
- ],
- "authors": [
- {
- "name": "Zaahid Bateson"
- }
- ],
- "description": "Wrapper for mbstring with fallback to iconv for encoding conversion and string manipulation",
- "keywords": [
- "charset",
- "encoding",
- "http",
- "iconv",
- "mail",
- "mb",
- "mb_convert_encoding",
- "mbstring",
- "mime",
- "multibyte",
- "string"
- ],
- "support": {
- "issues": "https://github.com/zbateson/mb-wrapper/issues",
- "source": "https://github.com/zbateson/mb-wrapper/tree/3.0.0"
- },
- "funding": [
- {
- "url": "https://github.com/zbateson",
- "type": "github"
- }
- ],
- "time": "2026-02-13T19:33:26+00:00"
- },
- {
- "name": "zbateson/stream-decorators",
- "version": "3.0.0",
- "source": {
- "type": "git",
- "url": "https://github.com/zbateson/stream-decorators.git",
- "reference": "0c0e79a8c960055c0e2710357098eedc07e6697a"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/zbateson/stream-decorators/zipball/0c0e79a8c960055c0e2710357098eedc07e6697a",
- "reference": "0c0e79a8c960055c0e2710357098eedc07e6697a",
- "shasum": ""
- },
- "require": {
- "guzzlehttp/psr7": "^2.5",
- "php": ">=8.1",
- "zbateson/mb-wrapper": "^2.0 || ^3.0"
- },
- "require-dev": {
- "friendsofphp/php-cs-fixer": "*",
- "phpstan/phpstan": "*",
- "phpunit/phpunit": "^10.0 || ^11.0"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "ZBateson\\StreamDecorators\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-2-Clause"
- ],
- "authors": [
- {
- "name": "Zaahid Bateson"
- }
- ],
- "description": "PHP psr7 stream decorators for mime message part streams",
- "keywords": [
- "base64",
- "charset",
- "decorators",
- "mail",
- "mime",
- "psr7",
- "quoted-printable",
- "stream",
- "uuencode"
- ],
- "support": {
- "issues": "https://github.com/zbateson/stream-decorators/issues",
- "source": "https://github.com/zbateson/stream-decorators/tree/3.0.0"
- },
- "funding": [
- {
- "url": "https://github.com/zbateson",
- "type": "github"
- }
- ],
- "time": "2026-02-13T19:45:34+00:00"
- },
{
"name": "zircote/swagger-php",
"version": "5.8.3",
@@ -17401,6 +16677,70 @@
],
"time": "2026-04-16T21:33:58+00:00"
},
+ {
+ "name": "spatie/backtrace",
+ "version": "1.8.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/spatie/backtrace.git",
+ "reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/spatie/backtrace/zipball/8ffe78be5ed355b5009e3dd989d183433e9a5adc",
+ "reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.3 || ^8.0"
+ },
+ "require-dev": {
+ "ext-json": "*",
+ "laravel/serializable-closure": "^1.3 || ^2.0",
+ "phpunit/phpunit": "^9.3 || ^11.4.3",
+ "spatie/phpunit-snapshot-assertions": "^4.2 || ^5.1.6",
+ "symfony/var-dumper": "^5.1|^6.0|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Spatie\\Backtrace\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Freek Van de Herten",
+ "email": "freek@spatie.be",
+ "homepage": "https://spatie.be",
+ "role": "Developer"
+ }
+ ],
+ "description": "A better backtrace",
+ "homepage": "https://github.com/spatie/backtrace",
+ "keywords": [
+ "Backtrace",
+ "spatie"
+ ],
+ "support": {
+ "issues": "https://github.com/spatie/backtrace/issues",
+ "source": "https://github.com/spatie/backtrace/tree/1.8.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sponsors/spatie",
+ "type": "github"
+ },
+ {
+ "url": "https://spatie.be/open-source/support-us",
+ "type": "other"
+ }
+ ],
+ "time": "2026-03-11T13:48:28+00:00"
+ },
{
"name": "spatie/error-solutions",
"version": "1.1.3",
@@ -18076,5 +17416,5 @@
"php": "^8.4"
},
"platform-dev": {},
- "plugin-api-version": "2.9.0"
+ "plugin-api-version": "2.6.0"
}
diff --git a/config/ray.php b/config/ray.php
deleted file mode 100644
index 08598c4e8..000000000
--- a/config/ray.php
+++ /dev/null
@@ -1,108 +0,0 @@
- env('RAY_ENABLED', true),
-
- /*
- * When enabled, all cache events will automatically be sent to Ray.
- */
- 'send_cache_to_ray' => env('SEND_CACHE_TO_RAY', false),
-
- /*
- * When enabled, all things passed to `dump` or `dd`
- * will be sent to Ray as well.
- */
- 'send_dumps_to_ray' => env('SEND_DUMPS_TO_RAY', true),
-
- /*
- * When enabled all job events will automatically be sent to Ray.
- */
- 'send_jobs_to_ray' => env('SEND_JOBS_TO_RAY', false),
-
- /*
- * When enabled, all things logged to the application log
- * will be sent to Ray as well.
- */
- 'send_log_calls_to_ray' => env('SEND_LOG_CALLS_TO_RAY', true),
-
- /*
- * When enabled, all queries will automatically be sent to Ray.
- */
- 'send_queries_to_ray' => env('SEND_QUERIES_TO_RAY', false),
-
- /**
- * When enabled, all duplicate queries will automatically be sent to Ray.
- */
- 'send_duplicate_queries_to_ray' => env('SEND_DUPLICATE_QUERIES_TO_RAY', false),
-
- /*
- * When enabled, slow queries will automatically be sent to Ray.
- */
- 'send_slow_queries_to_ray' => env('SEND_SLOW_QUERIES_TO_RAY', false),
-
- /**
- * Queries that are longer than this number of milliseconds will be regarded as slow.
- */
- 'slow_query_threshold_in_ms' => env('RAY_SLOW_QUERY_THRESHOLD_IN_MS', 500),
-
- /*
- * When enabled, all requests made to this app will automatically be sent to Ray.
- */
- 'send_requests_to_ray' => env('SEND_REQUESTS_TO_RAY', false),
-
- /**
- * When enabled, all Http Client requests made by this app will be automatically sent to Ray.
- */
- 'send_http_client_requests_to_ray' => env('SEND_HTTP_CLIENT_REQUESTS_TO_RAY', false),
-
- /*
- * When enabled, all views that are rendered automatically be sent to Ray.
- */
- 'send_views_to_ray' => env('SEND_VIEWS_TO_RAY', false),
-
- /*
- * When enabled, all exceptions will be automatically sent to Ray.
- */
- 'send_exceptions_to_ray' => env('SEND_EXCEPTIONS_TO_RAY', true),
-
- /*
- * When enabled, all deprecation notices will be automatically sent to Ray.
- */
- 'send_deprecated_notices_to_ray' => env('SEND_DEPRECATED_NOTICES_TO_RAY', false),
-
- /*
- * The host used to communicate with the Ray app.
- * When using Docker on Mac or Windows, you can replace localhost with 'host.docker.internal'
- * When using Docker on Linux, you can replace localhost with '172.17.0.1'
- * When using Homestead with the VirtualBox provider, you can replace localhost with '10.0.2.2'
- * When using Homestead with the Parallels provider, you can replace localhost with '10.211.55.2'
- */
- 'host' => env('RAY_HOST', 'host.docker.internal'),
-
- /*
- * The port number used to communicate with the Ray app.
- */
- 'port' => env('RAY_PORT', 23517),
-
- /*
- * Absolute base path for your sites or projects in Homestead,
- * Vagrant, Docker, or another remote development server.
- */
- 'remote_path' => env('RAY_REMOTE_PATH', null),
-
- /*
- * Absolute base path for your sites or projects on your local
- * computer where your IDE or code editor is running on.
- */
- 'local_path' => env('RAY_LOCAL_PATH', null),
-
- /*
- * When this setting is enabled, the package will not try to format values sent to Ray.
- */
- 'always_send_raw_values' => false,
-];
diff --git a/tests/Feature/EnvironmentVariable/ConvertContainerEnvsToArrayTest.php b/tests/Feature/EnvironmentVariable/ConvertContainerEnvsToArrayTest.php
index c8e71bd81..533387f77 100644
--- a/tests/Feature/EnvironmentVariable/ConvertContainerEnvsToArrayTest.php
+++ b/tests/Feature/EnvironmentVariable/ConvertContainerEnvsToArrayTest.php
@@ -180,7 +180,7 @@
"OpenStdin": false,
"StdinOnce": false,
"Env": [
- "RAY_ENABLED=true=123",
+ "FEATURE_FLAG_WITH_EQUALS=true=123",
"REGISTRY_URL=docker.io",
"SUBSCRIPTION_PROVIDER=stripe",
"TELESCOPE_ENABLED=false",
@@ -295,5 +295,5 @@
}
]';
$envs = format_docker_envs_to_json($data);
- $this->assertEquals('true=123', $envs->get('RAY_ENABLED'));
+ $this->assertEquals('true=123', $envs->get('FEATURE_FLAG_WITH_EQUALS'));
});
diff --git a/tests/Feature/Security/AuditLogTest.php b/tests/Feature/Security/AuditLogTest.php
index ca1b7bd08..b923508e7 100644
--- a/tests/Feature/Security/AuditLogTest.php
+++ b/tests/Feature/Security/AuditLogTest.php
@@ -218,7 +218,7 @@ function makeAuditApplication(string $repo = 'test-org/test-repo'): Application
test('cloud provider token form does not contain debug ray calls', function () {
expect(file_get_contents(app_path('Livewire/Security/CloudProviderTokenForm.php')))
- ->not->toContain('ray(');
+ ->not->toContain('ray'.'(');
});
});
diff --git a/tests/Unit/DockerComposeRawContentRemovalTest.php b/tests/Unit/DockerComposeRawContentRemovalTest.php
index 159acb366..81255dd59 100644
--- a/tests/Unit/DockerComposeRawContentRemovalTest.php
+++ b/tests/Unit/DockerComposeRawContentRemovalTest.php
@@ -95,6 +95,6 @@
->toContain('// This keeps the original user input clean while preventing content reapplication')
->toContain('try {')
->toContain('$originalYaml = Yaml::parse($originalCompose);')
- ->toContain('} catch (\Exception $e) {')
- ->toContain("ray('Failed to update docker_compose_raw");
+ ->toContain('} catch (Exception) {')
+ ->toContain('// If parsing fails, keep the original docker_compose_raw unchanged');
});
diff --git a/tests/Unit/RayRemovalTest.php b/tests/Unit/RayRemovalTest.php
new file mode 100644
index 000000000..e8b7b9646
--- /dev/null
+++ b/tests/Unit/RayRemovalTest.php
@@ -0,0 +1,53 @@
+isFile() && $file->getExtension() === 'php') {
+ $files[] = $file->getPathname();
+ }
+ }
+
+ sort($files);
+
+ return $files;
+}
+
+function rayRemovalBasePath(string $path = ''): string
+{
+ return dirname(__DIR__, 2).($path === '' ? '' : DIRECTORY_SEPARATOR.$path);
+}
+
+it('does not include Ray as a dependency', function () {
+ $composer = json_decode(file_get_contents(rayRemovalBasePath('composer.json')), true, flags: JSON_THROW_ON_ERROR);
+ $lock = json_decode(file_get_contents(rayRemovalBasePath('composer.lock')), true, flags: JSON_THROW_ON_ERROR);
+ $lockedPackageNames = collect([
+ ...($lock['packages'] ?? []),
+ ...($lock['packages-dev'] ?? []),
+ ])->pluck('name');
+
+ expect($composer['require'] ?? [])->not->toHaveKey('spatie/laravel-ray')
+ ->and($composer['require-dev'] ?? [])->not->toHaveKey('spatie/laravel-ray')
+ ->and($lockedPackageNames)->not->toContain('spatie/laravel-ray', 'spatie/ray');
+});
+
+it('does not ship Ray configuration', function () {
+ expect(file_exists(rayRemovalBasePath('config/ray.php')))->toBeFalse();
+});
+
+it('does not call ray from application code', function () {
+ $files = [
+ ...rayRemovalFiles(rayRemovalBasePath('app')),
+ ...rayRemovalFiles(rayRemovalBasePath('bootstrap/helpers')),
+ ];
+
+ $rayCalls = collect($files)
+ ->filter(fn (string $file): bool => preg_match('/\bray\s*\(/', file_get_contents($file)) === 1)
+ ->map(fn (string $file): string => str_replace(rayRemovalBasePath().'/', '', $file))
+ ->values();
+
+ expect($rayCalls)->toBeEmpty();
+});
From eddcbe819b0b962e3c2d07d8c080c3dbfe357434 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 3 Jul 2026 11:57:02 +0200
Subject: [PATCH 47/56] fix(parser): populate compose domains from service env
keys
---
bootstrap/helpers/parsers.php | 48 +++---
...licationParserDockerComposeDomainsTest.php | 145 ++++++++++++++++++
2 files changed, 175 insertions(+), 18 deletions(-)
diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
index e7acc3c61..62301d2aa 100644
--- a/bootstrap/helpers/parsers.php
+++ b/bootstrap/helpers/parsers.php
@@ -502,25 +502,37 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
]);
}
- // Also populate docker_compose_domains for dockercompose apps (mirrors Path 1 at lines 601-627)
- if ($resource->build_pack === 'dockercompose') {
- $normalizedFqdnFor = str($fqdnFor)->replace('-', '_')->replace('.', '_')->value();
- $serviceExists = false;
- foreach (array_keys($services) as $serviceNameKey) {
- if (str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value() === $normalizedFqdnFor) {
- $serviceExists = true;
- break;
- }
+ }
+
+ // Also populate docker_compose_domains for dockercompose apps from direct SERVICE_* declarations.
+ if ($resource->build_pack === 'dockercompose' && ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_'))) {
+ $parsed = parseServiceEnvironmentVariable($key->value());
+ $normalizedServiceName = str($parsed['service_name'])->replace('-', '_')->replace('.', '_')->value();
+ $serviceExists = false;
+ foreach (array_keys($services) as $serviceNameKey) {
+ if (str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value() === $normalizedServiceName) {
+ $serviceExists = true;
+ break;
}
- if ($serviceExists) {
- $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
- $domainExists = data_get($domains->get($normalizedFqdnFor), 'domain');
- if (is_null($domainExists)) {
- $domainValue = $fqdn;
- $domains->put($normalizedFqdnFor, ['domain' => $domainValue]);
- $resource->docker_compose_domains = $domains->toJson();
- $resource->save();
+ }
+ if ($serviceExists) {
+ $domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]'));
+ $domainExists = data_get($domains->get($normalizedServiceName), 'domain');
+ if (is_null($domainExists)) {
+ $serviceNameForDomain = str($parsed['service_name'])->replace('_', '-')->value();
+ $domainValue = generateUrl(server: $server, random: "$serviceNameForDomain-$uuid");
+ if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
+ $path = $value->value();
+ if ($path !== '/') {
+ $domainValue = "$domainValue$path";
+ }
}
+ if ($parsed['port'] && is_numeric($parsed['port'])) {
+ $domainValue = "$domainValue:{$parsed['port']}";
+ }
+ $domains->put($normalizedServiceName, ['domain' => $domainValue]);
+ $resource->docker_compose_domains = $domains->toJson();
+ $resource->save();
}
}
}
@@ -630,7 +642,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
// Only add domain if the service exists
if ($serviceExists) {
- $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
+ $domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]'));
$domainExists = data_get($domains->get($serviceName), 'domain');
// Update domain using URL with port if applicable
diff --git a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php
index 3d8ab15fb..00ae2d2ff 100644
--- a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php
+++ b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php
@@ -82,6 +82,7 @@
->and($domains)->toBeArray()
->and($domains)->toHaveKey('backend')
->and($domains['backend'])->toHaveKey('domain')
+ ->and($domains['backend']['domain'])->toStartWith('http://')
->and($domains['backend']['domain'])->not->toBeEmpty();
});
@@ -142,3 +143,147 @@
$domains = json_decode($application->docker_compose_domains, true);
expect($domains)->toBeNull();
});
+
+test('applicationParser populates docker_compose_domains for KEY-based SERVICE_URL variables', function () {
+ $dockerCompose = <<<'YAML'
+services:
+ frontend:
+ image: myapp/frontend:latest
+ environment:
+ - SERVICE_URL_FRONTEND=/ui
+YAML;
+
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => StandaloneDocker::class,
+ 'build_pack' => 'dockercompose',
+ 'docker_compose_raw' => $dockerCompose,
+ 'fqdn' => null,
+ 'docker_compose_domains' => null,
+ ]);
+
+ applicationParser($application);
+
+ $application->refresh();
+
+ $domains = json_decode($application->docker_compose_domains, true);
+
+ expect($domains)->not->toBeNull()
+ ->and($domains)->toHaveKey('frontend')
+ ->and($domains['frontend']['domain'])->toStartWith('http://')
+ ->and($domains['frontend']['domain'])->toEndWith('/ui');
+});
+
+test('applicationParser preserves existing docker_compose_domains entries', function () {
+ $dockerCompose = <<<'YAML'
+services:
+ backend:
+ image: myapp/backend:latest
+ environment:
+ - SERVICE_FQDN_BACKEND_8000=${BACKEND_URL}
+ frontend:
+ image: myapp/frontend:latest
+ environment:
+ - SERVICE_URL_FRONTEND=/ui
+YAML;
+
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => StandaloneDocker::class,
+ 'build_pack' => 'dockercompose',
+ 'docker_compose_raw' => $dockerCompose,
+ 'fqdn' => null,
+ 'docker_compose_domains' => json_encode([
+ 'frontend' => ['domain' => 'https://existing.example.com'],
+ ]),
+ ]);
+
+ applicationParser($application);
+
+ $application->refresh();
+
+ $domains = json_decode($application->docker_compose_domains, true);
+
+ expect($domains['frontend']['domain'])->toBe('https://existing.example.com')
+ ->and($domains)->toHaveKey('backend')
+ ->and($domains['backend']['domain'])->toStartWith('http://');
+});
+
+test('applicationParser handles other docker compose domain shapes without regressions', function () {
+ $createApplication = function (string $dockerCompose, ?string $dockerComposeDomains = null): Application {
+ return Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => StandaloneDocker::class,
+ 'build_pack' => 'dockercompose',
+ 'docker_compose_raw' => $dockerCompose,
+ 'fqdn' => null,
+ 'docker_compose_domains' => $dockerComposeDomains,
+ ]);
+ };
+
+ $valueBasedCompose = <<<'YAML'
+services:
+ backend:
+ image: myapp/backend:latest
+ frontend:
+ image: myapp/frontend:latest
+ environment:
+ API_URL: ${SERVICE_URL_BACKEND}
+YAML;
+
+ $valueBasedApplication = $createApplication($valueBasedCompose);
+ applicationParser($valueBasedApplication);
+ $valueBasedApplication->refresh();
+ $valueBasedDomains = json_decode($valueBasedApplication->docker_compose_domains, true);
+
+ expect($valueBasedDomains)->toHaveKey('backend')
+ ->and($valueBasedDomains['backend']['domain'])->toStartWith('http://')
+ ->and($valueBasedDomains)->not->toHaveKey('frontend');
+
+ $mapStyleCompose = <<<'YAML'
+services:
+ frontend:
+ image: myapp/frontend:latest
+ environment:
+ SERVICE_URL_FRONTEND: /ui
+YAML;
+
+ $mapStyleApplication = $createApplication($mapStyleCompose);
+ applicationParser($mapStyleApplication);
+ $mapStyleApplication->refresh();
+ $mapStyleDomains = json_decode($mapStyleApplication->docker_compose_domains, true);
+
+ expect($mapStyleDomains)->toHaveKey('frontend')
+ ->and($mapStyleDomains['frontend']['domain'])->toEndWith('/ui');
+
+ $missingServiceCompose = <<<'YAML'
+services:
+ worker:
+ image: myapp/worker:latest
+ environment:
+ SERVICE_URL_API: /api
+YAML;
+
+ $missingServiceApplication = $createApplication($missingServiceCompose);
+ applicationParser($missingServiceApplication);
+ $missingServiceApplication->refresh();
+
+ expect(json_decode($missingServiceApplication->docker_compose_domains, true))->toBeNull();
+
+ $plainCompose = <<<'YAML'
+services:
+ worker:
+ image: myapp/worker:latest
+ environment:
+ FOO: bar
+YAML;
+
+ $plainApplication = $createApplication($plainCompose);
+ applicationParser($plainApplication);
+ $plainApplication->refresh();
+
+ expect(json_decode($plainApplication->docker_compose_domains, true))->toBeNull();
+});
From 1adeed2a48798ec5c41a4260f9d543fa25856a11 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 3 Jul 2026 12:01:16 +0200
Subject: [PATCH 48/56] fix(github): preserve custom app API URLs
---
app/Http/Controllers/Api/GithubController.php | 6 ++-
app/Livewire/Source/Github/Change.php | 20 ++++++--
app/Livewire/Source/Github/Create.php | 19 ++++++-
tests/Feature/Api/GithubAppsListApiTest.php | 51 +++++++++++++++----
.../Application/GithubSourceChangeTest.php | 6 +--
.../Application/GithubSourceCreateTest.php | 24 +++++++--
6 files changed, 101 insertions(+), 25 deletions(-)
diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php
index d71593048..16dfc8136 100644
--- a/app/Http/Controllers/Api/GithubController.php
+++ b/app/Http/Controllers/Api/GithubController.php
@@ -256,7 +256,9 @@ public function create_github_app(Request $request)
'uuid' => Str::uuid(),
'name' => $request->input('name'),
'organization' => $request->input('organization'),
- 'api_url' => githubApiUrlFromHtmlUrl($request->input('html_url')),
+ 'api_url' => filled($request->input('api_url'))
+ ? $request->input('api_url')
+ : githubApiUrlFromHtmlUrl($request->input('html_url')),
'html_url' => $request->input('html_url'),
'custom_user' => $request->input('custom_user', 'git'),
'custom_port' => $request->input('custom_port', 22),
@@ -650,7 +652,7 @@ public function update_github_app(Request $request, $github_app_id)
if (array_key_exists('organization', $payload)) {
$payload['organization'] = normalizeGithubOrganization($payload['organization']);
}
- if (isset($payload['html_url'])) {
+ if (isset($payload['html_url']) && ! filled($payload['api_url'] ?? null)) {
$payload['api_url'] = githubApiUrlFromHtmlUrl($payload['html_url']);
}
diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php
index 9c9f87469..a24ed9ce3 100644
--- a/app/Livewire/Source/Github/Change.php
+++ b/app/Livewire/Source/Github/Change.php
@@ -79,6 +79,8 @@ class Change extends Component
public string $activeTab = 'general';
+ private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false;
+
protected function rules(): array
{
return [
@@ -104,9 +106,17 @@ protected function rules(): array
];
}
+ public function updatingHtmlUrl(): void
+ {
+ $this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->apiUrl)
+ || $this->apiUrl === githubApiUrlFromHtmlUrl($this->htmlUrl);
+ }
+
public function updatedHtmlUrl(): void
{
- $this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl);
+ if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) {
+ $this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl);
+ }
}
public function boot()
@@ -126,7 +136,9 @@ private function syncData(bool $toModel = false): void
if ($toModel) {
// Sync TO model (before save)
$this->organization = normalizeGithubOrganization($this->organization);
- $this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl);
+ $this->apiUrl = filled($this->apiUrl)
+ ? $this->apiUrl
+ : githubApiUrlFromHtmlUrl($this->htmlUrl);
$this->github_app->name = $this->name;
$this->github_app->organization = $this->organization;
@@ -354,7 +366,9 @@ public function submit()
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
$this->organization = normalizeGithubOrganization($this->organization);
- $this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl);
+ $this->apiUrl = filled($this->apiUrl)
+ ? $this->apiUrl
+ : githubApiUrlFromHtmlUrl($this->htmlUrl);
$this->validate();
$this->syncData(true);
diff --git a/app/Livewire/Source/Github/Create.php b/app/Livewire/Source/Github/Create.php
index 9965fe4b7..6a5bf6e60 100644
--- a/app/Livewire/Source/Github/Create.php
+++ b/app/Livewire/Source/Github/Create.php
@@ -5,6 +5,7 @@
use App\Models\GithubApp;
use App\Rules\SafeExternalUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Validation\ValidationException;
use Livewire\Component;
class Create extends Component
@@ -25,14 +26,24 @@ class Create extends Component
public bool $is_system_wide = false;
+ private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false;
+
public function mount()
{
$this->name = substr(generate_random_name(), 0, 30);
}
+ public function updatingHtmlUrl(): void
+ {
+ $this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->api_url)
+ || $this->api_url === githubApiUrlFromHtmlUrl($this->html_url);
+ }
+
public function updatedHtmlUrl(): void
{
- $this->api_url = githubApiUrlFromHtmlUrl($this->html_url);
+ if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) {
+ $this->api_url = githubApiUrlFromHtmlUrl($this->html_url);
+ }
}
public function createGitHubApp()
@@ -41,7 +52,9 @@ public function createGitHubApp()
$this->authorize('createAnyResource');
$this->organization = normalizeGithubOrganization($this->organization);
- $this->api_url = githubApiUrlFromHtmlUrl($this->html_url);
+ $this->api_url = filled($this->api_url)
+ ? $this->api_url
+ : githubApiUrlFromHtmlUrl($this->html_url);
$this->validate([
'name' => 'required|string',
@@ -68,6 +81,8 @@ public function createGitHubApp()
}
return redirectRoute($this, 'source.github.show', ['github_app_uuid' => $github_app->uuid]);
+ } catch (ValidationException $e) {
+ throw $e;
} catch (\Throwable $e) {
return handleError($e, $this);
}
diff --git a/tests/Feature/Api/GithubAppsListApiTest.php b/tests/Feature/Api/GithubAppsListApiTest.php
index 56deae733..6357c25ba 100644
--- a/tests/Feature/Api/GithubAppsListApiTest.php
+++ b/tests/Feature/Api/GithubAppsListApiTest.php
@@ -1,6 +1,7 @@
0, 'is_api_enabled' => true]);
+
// Create a team with owner
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ session(['currentTeam' => $this->team]);
// Create an API token for the user
- $this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
+ $this->token = $this->user->createToken('test-token');
$this->bearerToken = $this->token->plainTextToken;
// Create a private key for the team
$this->privateKey = PrivateKey::create([
'name' => 'Test Key',
- 'private_key' => 'test-private-key-content',
+ 'private_key' => validGithubAppsApiPrivateKey(),
'team_id' => $this->team->id,
]);
});
@@ -139,7 +143,8 @@ function validGithubAppsApiPrivateKey(): string
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
- $otherToken = $otherUser->createToken('other-token', ['*'], $otherTeam->id);
+ session(['currentTeam' => $otherTeam]);
+ $otherToken = $otherUser->createToken('other-token');
// System-wide apps should be visible to other teams
$response = $this->withHeaders([
@@ -173,7 +178,7 @@ function validGithubAppsApiPrivateKey(): string
$otherTeam = Team::factory()->create();
$otherPrivateKey = PrivateKey::create([
'name' => 'Other Key',
- 'private_key' => 'other-key',
+ 'private_key' => validGithubAppsApiPrivateKey(),
'team_id' => $otherTeam->id,
]);
GithubApp::create([
@@ -249,7 +254,7 @@ function validGithubAppsApiPrivateKey(): string
])->postJson('/api/v1/github-apps', [
'name' => 'GHE App',
'organization' => '/octocorp/',
- 'html_url' => 'https://octocorp.ghe.com',
+ 'html_url' => 'https://github.ghe.com',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
@@ -261,12 +266,36 @@ function validGithubAppsApiPrivateKey(): string
$response->assertCreated()
->assertJsonFragment([
'organization' => 'octocorp',
- 'api_url' => 'https://api.octocorp.ghe.com',
- 'html_url' => 'https://octocorp.ghe.com',
+ 'api_url' => 'https://api.github.ghe.com',
+ 'html_url' => 'https://github.ghe.com',
]);
});
- test('normalizes ghe dot com api url when updating github apps', function () {
+ test('preserves provided api url when creating github apps', function () {
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->postJson('/api/v1/github-apps', [
+ 'name' => 'GHE App',
+ 'organization' => '/octocorp/',
+ 'api_url' => 'https://github.com/api/v3',
+ 'html_url' => 'https://github.com',
+ 'app_id' => 12345,
+ 'installation_id' => 67890,
+ 'client_id' => 'test-client-id',
+ 'client_secret' => 'test-client-secret',
+ 'webhook_secret' => 'test-webhook-secret',
+ 'private_key_uuid' => $this->privateKey->uuid,
+ ]);
+
+ $response->assertCreated()
+ ->assertJsonFragment([
+ 'organization' => 'octocorp',
+ 'api_url' => 'https://github.com/api/v3',
+ 'html_url' => 'https://github.com',
+ ]);
+ });
+
+ test('preserves provided api url when updating github apps', function () {
$githubApp = GithubApp::create([
'name' => 'GHE App',
'api_url' => 'https://github.company.internal/api/v3',
@@ -285,12 +314,12 @@ function validGithubAppsApiPrivateKey(): string
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/github-apps/{$githubApp->id}", [
- 'html_url' => 'https://octocorp.ghe.com',
- 'api_url' => 'https://octocorp.ghe.com/api/v3',
+ 'html_url' => 'https://github.com',
+ 'api_url' => 'https://github.com/api/v3',
]);
$response->assertSuccessful()
- ->assertJsonPath('data.api_url', 'https://api.octocorp.ghe.com');
+ ->assertJsonPath('data.api_url', 'https://github.com/api/v3');
});
test('rejects invalid organization when creating github apps', function () {
diff --git a/tests/Feature/Application/GithubSourceChangeTest.php b/tests/Feature/Application/GithubSourceChangeTest.php
index f3ee7bedf..40a4e7314 100644
--- a/tests/Feature/Application/GithubSourceChangeTest.php
+++ b/tests/Feature/Application/GithubSourceChangeTest.php
@@ -419,7 +419,7 @@ function validPrivateKey(): string
expect($githubApp->private_key_id)->toBe($privateKey->id);
});
- test('normalizes resolvable ghe dot com api url when saving github app settings', function () {
+ test('preserves custom ghe api url when saving github app settings', function () {
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://github.ghe.com/api/v3',
@@ -437,10 +437,10 @@ function validPrivateKey(): string
->set('apiUrl', 'https://github.ghe.com/api/v3')
->call('submit')
->assertDispatched('success')
- ->assertSet('apiUrl', 'https://api.github.ghe.com');
+ ->assertSet('apiUrl', 'https://github.ghe.com/api/v3');
$githubApp->refresh();
- expect($githubApp->api_url)->toBe('https://api.github.ghe.com');
+ expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
});
test('rejects invalid github organization values', function () {
diff --git a/tests/Feature/Application/GithubSourceCreateTest.php b/tests/Feature/Application/GithubSourceCreateTest.php
index 82343092c..a3eb13d54 100644
--- a/tests/Feature/Application/GithubSourceCreateTest.php
+++ b/tests/Feature/Application/GithubSourceCreateTest.php
@@ -72,8 +72,8 @@
Livewire::test(Create::class)
->assertSuccessful()
->set('name', 'enterprise-app')
- ->set('api_url', 'https://github.enterprise.com/api/v3')
- ->set('html_url', 'https://github.enterprise.com')
+ ->set('api_url', 'https://github.ghe.com/api/v3')
+ ->set('html_url', 'https://github.ghe.com')
->set('custom_user', 'git-custom')
->set('custom_port', 2222)
->call('createGitHubApp')
@@ -82,12 +82,28 @@
$githubApp = GithubApp::where('name', 'enterprise-app')->first();
expect($githubApp)->not->toBeNull();
- expect($githubApp->api_url)->toBe('https://github.enterprise.com/api/v3');
- expect($githubApp->html_url)->toBe('https://github.enterprise.com');
+ expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
+ expect($githubApp->html_url)->toBe('https://github.ghe.com');
expect($githubApp->custom_user)->toBe('git-custom');
expect($githubApp->custom_port)->toBe(2222);
});
+ test('preserves custom github enterprise api url when creating github app', function () {
+ Livewire::test(Create::class)
+ ->assertSuccessful()
+ ->set('name', 'ghe-custom-api-app')
+ ->set('api_url', 'https://github.ghe.com/api/v3')
+ ->set('html_url', 'https://github.ghe.com')
+ ->call('createGitHubApp')
+ ->assertRedirect();
+
+ $githubApp = GithubApp::where('name', 'ghe-custom-api-app')->first();
+
+ expect($githubApp)->not->toBeNull();
+ expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
+ expect($githubApp->html_url)->toBe('https://github.ghe.com');
+ });
+
test('validates required fields', function () {
Livewire::test(Create::class)
->assertSuccessful()
From b6a4c7383a1510a144de89ce39c87656d199e296 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 3 Jul 2026 20:47:18 +0200
Subject: [PATCH 49/56] fix(env): preserve empty service variable values
(#10850)
---
app/Models/EnvironmentVariable.php | 2 +-
.../EnvironmentVariableValueCastingTest.php | 45 +++++++++++++++++++
2 files changed, 46 insertions(+), 1 deletion(-)
create mode 100644 tests/Feature/EnvironmentVariableValueCastingTest.php
diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php
index bfb02a470..e346e59d1 100644
--- a/app/Models/EnvironmentVariable.php
+++ b/app/Models/EnvironmentVariable.php
@@ -356,7 +356,7 @@ private function get_environment_variables(?string $environment_variable = null)
private function set_environment_variables(?string $environment_variable = null): ?string
{
- if (is_null($environment_variable) && $environment_variable === '') {
+ if (is_null($environment_variable)) {
return null;
}
$environment_variable = trim($environment_variable);
diff --git a/tests/Feature/EnvironmentVariableValueCastingTest.php b/tests/Feature/EnvironmentVariableValueCastingTest.php
new file mode 100644
index 000000000..6df78c134
--- /dev/null
+++ b/tests/Feature/EnvironmentVariableValueCastingTest.php
@@ -0,0 +1,45 @@
+ 'NULL_VALUE',
+ 'value' => null,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => 1,
+ ]);
+
+ $rawValue = DB::table('environment_variables')
+ ->where('id', $env->id)
+ ->value('value');
+
+ expect($rawValue)->toBeNull();
+
+ $env->refresh();
+ expect($env->value)->toBeNull();
+});
+
+it('preserves intentional empty string environment variable values', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'EMPTY_STRING_VALUE',
+ 'value' => '',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => 1,
+ ]);
+
+ $rawValue = DB::table('environment_variables')
+ ->where('id', $env->id)
+ ->value('value');
+
+ expect($rawValue)->not->toBeNull()
+ ->and(decrypt($rawValue))->toBe('');
+
+ $env->refresh();
+ expect($env->value)->toBe('');
+});
From ff5cfd4253b34f463564943673a09ee9cdcab5f1 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 6 Jul 2026 23:58:12 +0200
Subject: [PATCH 50/56] fix(api): normalize log endpoint query handling
Clamp log line counts, parse timestamp flags consistently, and filter
service subcontainers by Coolify labels. Document log endpoint timestamp
parameters and database/service log routes in OpenAPI.
---
.../Api/ApplicationsController.php | 4 +-
.../Controllers/Api/DatabasesController.php | 7 +-
.../Controllers/Api/ServicesController.php | 10 +-
bootstrap/helpers/docker.php | 42 ++++-
openapi.json | 168 ++++++++++++++++++
openapi.yaml | 118 ++++++++++++
tests/Unit/Api/LogEndpointHelpersTest.php | 87 +++++++++
7 files changed, 417 insertions(+), 19 deletions(-)
create mode 100644 tests/Unit/Api/LogEndpointHelpersTest.php
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index ddfd15ef7..127130530 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -2087,8 +2087,8 @@ public function logs_by_uuid(Request $request)
], 400);
}
- $lines = $request->query->get('lines', 100);
- $showTimestamps = $request->query->get('show_timestamps', false);
+ $lines = normalizeLogLines($request->query('lines'));
+ $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
$logs = getContainerLogs($application->destination->server, $container['ID'], $lines, $showTimestamps);
return response()->json([
diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php
index e68b18fbe..a9f12006f 100644
--- a/app/Http/Controllers/Api/DatabasesController.php
+++ b/app/Http/Controllers/Api/DatabasesController.php
@@ -2246,6 +2246,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(['message' => 'Invalid database type requested.'], 400);
}
+
#[OA\Get(
summary: 'Get database logs.',
description: 'Get database logs by UUID.',
@@ -2331,7 +2332,7 @@ public function logs_by_uuid(Request $request)
}
$containers = getCurrentDatabaseContainerStatus($database->destination->server, $database->id);
-
+
if ($containers->count() == 0) {
return response()->json([
'message' => 'Database is not running.',
@@ -2347,8 +2348,8 @@ public function logs_by_uuid(Request $request)
], 400);
}
- $lines = $request->query->get('lines', 100);
- $showTimestamps = $request->query->get('show_timestamps', false);
+ $lines = normalizeLogLines($request->query('lines'));
+ $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
$logs = getContainerLogs($database->destination->server, $container['ID'], $lines, $showTimestamps);
return response()->json([
diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php
index 19867384d..3b37e73b9 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -733,7 +733,7 @@ public function service_by_uuid(Request $request)
#[OA\Get(
summary: 'Get service logs.',
- description: 'Get service logs by UUID.',
+ description: 'Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET /services/{uuid}`.',
path: '/services/{uuid}/logs',
operationId: 'get-service-logs-by-uuid',
security: [
@@ -754,9 +754,9 @@ public function service_by_uuid(Request $request)
new OA\Parameter(
name: 'sub_service_name',
in: 'query',
- description: 'Sub service name.',
+ description: 'Sub-service name from `GET /services/{uuid}` under `applications[].name` or `databases[].name`. Do not use `human_name` or the Docker container name with the service UUID suffix.',
required: true,
- schema: new OA\Schema(type: 'string'),
+ schema: new OA\Schema(type: 'string', example: 'appwrite-console'),
),
new OA\Parameter(
name: 'lines',
@@ -841,8 +841,8 @@ public function logs_by_uuid(Request $request)
], 400);
}
- $lines = $request->query->get('lines', 100);
- $showTimestamps = $request->query->get('show_timestamps', false);
+ $lines = normalizeLogLines($request->query('lines'));
+ $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
$logs = getContainerLogs($service->destination->server, $container['ID'], $lines, $showTimestamps);
return response()->json([
diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php
index 105bcbacb..e6643a337 100644
--- a/bootstrap/helpers/docker.php
+++ b/bootstrap/helpers/docker.php
@@ -87,15 +87,19 @@ function getCurrentDatabaseContainerStatus(Server $server, int $id): Collection
function getCurrentServiceSubContainerStatus(Server $server, int $id, string $name): Collection
{
- $containers = collect([]);
- if (! $server->isSwarm()) {
- $containers = instant_remote_process(["docker ps -a --filter='label=coolify.serviceId={$id}' --filter='label=coolify.name={$name}' --format '{{json .}}' "], $server);
- $containers = format_docker_command_output_to_json($containers);
+ return filterServiceSubContainersByName(getCurrentServiceContainerStatus($server, $id), $name);
+}
- return $containers->filter();
- }
+function filterServiceSubContainersByName(Collection $containers, string $name): Collection
+{
+ return $containers->filter(function ($container) use ($name) {
+ $labels = data_get($container, 'Labels', []);
+ if (is_string($labels)) {
+ $labels = format_docker_labels_to_json($labels);
+ }
- return $containers;
+ return collect($labels)->get('coolify.name') === $name;
+ })->values();
}
function format_docker_command_output_to_json($rawOutput): Collection
@@ -1273,7 +1277,22 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
}
}
-function getContainerLogs(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string
+function normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int
+{
+ $lines = filter_var($lines, FILTER_VALIDATE_INT);
+ if ($lines === false || $lines <= 0) {
+ return $default;
+ }
+
+ return min($lines, $max);
+}
+
+function parseLogTimestampFlag(mixed $showTimestamps): bool
+{
+ return filter_var($showTimestamps, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
+}
+
+function buildContainerLogsCommand(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string
{
$command = "docker logs -n {$lines}";
if ($server->isSwarm()) {
@@ -1284,7 +1303,12 @@ function getContainerLogs(Server $server, string $container_id, int $lines = 100
$command .= ' --timestamps';
}
- $output = instant_remote_process(["{$command} {$container_id} 2>&1"], $server);
+ return "{$command} ".escapeshellarg($container_id).' 2>&1';
+}
+
+function getContainerLogs(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string
+{
+ $output = instant_remote_process([buildContainerLogsCommand($server, $container_id, $lines, $showTimestamps)], $server);
$output = removeAnsiColors($output);
return $output;
diff --git a/openapi.json b/openapi.json
index ca445ade0..816cdb535 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2675,6 +2675,16 @@
"format": "int32",
"default": 100
}
+ },
+ {
+ "name": "show_timestamps",
+ "in": "query",
+ "description": "Show timestamps in the logs.",
+ "required": false,
+ "schema": {
+ "type": "boolean",
+ "default": false
+ }
}
],
"responses": {
@@ -5952,6 +5962,80 @@
]
}
},
+ "\/databases\/{uuid}\/logs": {
+ "get": {
+ "tags": [
+ "Databases"
+ ],
+ "summary": "Get database logs.",
+ "description": "Get database logs by UUID.",
+ "operationId": "get-database-logs-by-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the database.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ }
+ },
+ {
+ "name": "lines",
+ "in": "query",
+ "description": "Number of lines to show from the end of the logs.",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "int32",
+ "default": 100
+ }
+ },
+ {
+ "name": "show_timestamps",
+ "in": "query",
+ "description": "Show timestamps in the logs.",
+ "required": false,
+ "schema": {
+ "type": "boolean",
+ "default": false
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Get database logs by UUID.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "logs": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/databases\/{uuid}\/backups\/{scheduled_backup_uuid}\/executions\/{execution_uuid}": {
"delete": {
"tags": [
@@ -11293,6 +11377,90 @@
]
}
},
+ "\/services\/{uuid}\/logs": {
+ "get": {
+ "tags": [
+ "Services"
+ ],
+ "summary": "Get service logs.",
+ "description": "Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET \/services\/{uuid}`.",
+ "operationId": "get-service-logs-by-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the service.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ }
+ },
+ {
+ "name": "sub_service_name",
+ "in": "query",
+ "description": "Sub-service name from `GET \/services\/{uuid}` under `applications[].name` or `databases[].name`. Do not use `human_name` or the Docker container name with the service UUID suffix.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "example": "appwrite-console"
+ }
+ },
+ {
+ "name": "lines",
+ "in": "query",
+ "description": "Number of lines to show from the end of the logs.",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "int32",
+ "default": 100
+ }
+ },
+ {
+ "name": "show_timestamps",
+ "in": "query",
+ "description": "Show timestamps in the logs.",
+ "required": false,
+ "schema": {
+ "type": "boolean",
+ "default": false
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Get service logs by UUID.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "logs": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/services\/{uuid}\/envs": {
"get": {
"tags": [
diff --git a/openapi.yaml b/openapi.yaml
index 6182cacd3..dacacece1 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -1716,6 +1716,14 @@ paths:
type: integer
format: int32
default: 100
+ -
+ name: show_timestamps
+ in: query
+ description: 'Show timestamps in the logs.'
+ required: false
+ schema:
+ type: boolean
+ default: false
responses:
'200':
description: 'Get application logs by UUID.'
@@ -3908,6 +3916,57 @@ paths:
security:
-
bearerAuth: []
+ '/databases/{uuid}/logs':
+ get:
+ tags:
+ - Databases
+ summary: 'Get database logs.'
+ description: 'Get database logs by UUID.'
+ operationId: get-database-logs-by-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the database.'
+ required: true
+ schema:
+ type: string
+ format: uuid
+ -
+ name: lines
+ in: query
+ description: 'Number of lines to show from the end of the logs.'
+ required: false
+ schema:
+ type: integer
+ format: int32
+ default: 100
+ -
+ name: show_timestamps
+ in: query
+ description: 'Show timestamps in the logs.'
+ required: false
+ schema:
+ type: boolean
+ default: false
+ responses:
+ '200':
+ description: 'Get database logs by UUID.'
+ content:
+ application/json:
+ schema:
+ properties:
+ logs: { type: string }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
'/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}':
delete:
tags:
@@ -7150,6 +7209,65 @@ paths:
security:
-
bearerAuth: []
+ '/services/{uuid}/logs':
+ get:
+ tags:
+ - Services
+ summary: 'Get service logs.'
+ description: 'Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET /services/{uuid}`.'
+ operationId: get-service-logs-by-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the service.'
+ required: true
+ schema:
+ type: string
+ format: uuid
+ -
+ name: sub_service_name
+ in: query
+ description: 'Sub-service name from `GET /services/{uuid}` under `applications[].name` or `databases[].name`. Do not use `human_name` or the Docker container name with the service UUID suffix.'
+ required: true
+ schema:
+ type: string
+ example: appwrite-console
+ -
+ name: lines
+ in: query
+ description: 'Number of lines to show from the end of the logs.'
+ required: false
+ schema:
+ type: integer
+ format: int32
+ default: 100
+ -
+ name: show_timestamps
+ in: query
+ description: 'Show timestamps in the logs.'
+ required: false
+ schema:
+ type: boolean
+ default: false
+ responses:
+ '200':
+ description: 'Get service logs by UUID.'
+ content:
+ application/json:
+ schema:
+ properties:
+ logs: { type: string }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
'/services/{uuid}/envs':
get:
tags:
diff --git a/tests/Unit/Api/LogEndpointHelpersTest.php b/tests/Unit/Api/LogEndpointHelpersTest.php
new file mode 100644
index 000000000..0b0107e73
--- /dev/null
+++ b/tests/Unit/Api/LogEndpointHelpersTest.php
@@ -0,0 +1,87 @@
+toBeTrue();
+
+ return;
+ }
+
+ expect(normalizeLogLines(null))->toBe(100)
+ ->and(normalizeLogLines(''))->toBe(100)
+ ->and(normalizeLogLines('abc'))->toBe(100)
+ ->and(normalizeLogLines('0'))->toBe(100)
+ ->and(normalizeLogLines('-5'))->toBe(100)
+ ->and(normalizeLogLines('50'))->toBe(50)
+ ->and(normalizeLogLines('50000'))->toBe(10000);
+});
+
+it('parses show_timestamps query values as booleans', function () {
+ if (! function_exists('parseLogTimestampFlag')) {
+ expect(function_exists('parseLogTimestampFlag'))->toBeTrue();
+
+ return;
+ }
+
+ expect(parseLogTimestampFlag('true'))->toBeTrue()
+ ->and(parseLogTimestampFlag('1'))->toBeTrue()
+ ->and(parseLogTimestampFlag(true))->toBeTrue()
+ ->and(parseLogTimestampFlag('false'))->toBeFalse()
+ ->and(parseLogTimestampFlag('0'))->toBeFalse()
+ ->and(parseLogTimestampFlag(false))->toBeFalse()
+ ->and(parseLogTimestampFlag('not-a-bool'))->toBeFalse()
+ ->and(parseLogTimestampFlag(null))->toBeFalse();
+});
+
+it('builds docker log commands with options before an escaped container id', function () {
+ if (! function_exists('buildContainerLogsCommand')) {
+ expect(function_exists('buildContainerLogsCommand'))->toBeTrue();
+
+ return;
+ }
+
+ $server = new Server;
+ $server->settings = ['is_swarm_manager' => false];
+
+ expect(buildContainerLogsCommand($server, 'container-1', 25, true))
+ ->toBe("docker logs -n 25 --timestamps 'container-1' 2>&1");
+});
+
+it('builds swarm service log commands with options before an escaped service id', function () {
+ if (! function_exists('buildContainerLogsCommand')) {
+ expect(function_exists('buildContainerLogsCommand'))->toBeTrue();
+
+ return;
+ }
+
+ $server = new Server;
+ $server->settings = ['is_swarm_manager' => true];
+
+ expect(buildContainerLogsCommand($server, "service'name", 25, true))
+ ->toBe("docker service logs -n 25 --timestamps 'service'\\''name' 2>&1");
+});
+
+it('filters service sub containers in PHP instead of using user input in shell filters', function () {
+ if (! function_exists('filterServiceSubContainersByName')) {
+ expect(function_exists('filterServiceSubContainersByName'))->toBeTrue();
+
+ return;
+ }
+
+ $containers = collect([
+ ['ID' => 'first', 'Labels' => 'coolify.serviceId=10,coolify.name=app-service-uuid,coolify.type=service'],
+ ['ID' => 'second', 'Labels' => 'coolify.serviceId=10,coolify.name=db-service-uuid,coolify.type=service'],
+ ['ID' => 'third', 'Labels' => ['coolify.name' => 'app-service-uuid']],
+ ]);
+
+ expect(filterServiceSubContainersByName($containers, 'app-service-uuid')->pluck('ID')->all())
+ ->toBe(['first', 'third']);
+});
+
+it('does not interpolate the requested service name into the docker ps shell command', function () {
+ $source = file_get_contents(__DIR__.'/../../../bootstrap/helpers/docker.php');
+
+ expect($source)->not->toContain('coolify.name={$name}');
+});
From 6baabf9eda5c9beca8e481265672950867f61ba8 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 7 Jul 2026 12:08:07 +0200
Subject: [PATCH 51/56] fix(api): document source commit build option
---
app/Http/Controllers/Api/ApplicationsController.php | 1 +
1 file changed, 1 insertion(+)
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index ad8d2ed97..cd2d7fce5 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -2280,6 +2280,7 @@ public function delete_by_uuid(Request $request)
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
'is_preserve_repository_enabled' => ['type' => 'boolean', 'description' => 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.'],
+ 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include source commit information in the build. Default is false.'],
],
)
),
From 2741bc1d0d08fdb88771c4197948039fcc8a08ea Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 7 Jul 2026 12:10:38 +0200
Subject: [PATCH 52/56] test(api): cover public git repository URL storage
---
.../ApplicationPublicApiGitRepositoryTest.php | 88 +++++++++++++++++++
1 file changed, 88 insertions(+)
create mode 100644 tests/Feature/ApplicationPublicApiGitRepositoryTest.php
diff --git a/tests/Feature/ApplicationPublicApiGitRepositoryTest.php b/tests/Feature/ApplicationPublicApiGitRepositoryTest.php
new file mode 100644
index 000000000..eb105e676
--- /dev/null
+++ b/tests/Feature/ApplicationPublicApiGitRepositoryTest.php
@@ -0,0 +1,88 @@
+ InstanceSettings::firstOrCreate(['id' => 0]));
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ session(['currentTeam' => $this->team]);
+
+ $plainTextToken = Str::random(40);
+ $token = $this->user->tokens()->create([
+ 'name' => 'public-git-api-test-'.Str::random(6),
+ 'token' => hash('sha256', $plainTextToken),
+ 'abilities' => ['*'],
+ 'team_id' => $this->team->id,
+ ]);
+ $this->bearerToken = $token->getKey().'|'.$plainTextToken;
+
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+ $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+});
+
+function createPublicGitApplication(array $overrides = []): Application
+{
+ $response = test()->withHeaders([
+ 'Authorization' => 'Bearer '.test()->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson('/api/v1/applications/public', array_merge([
+ 'project_uuid' => test()->project->uuid,
+ 'environment_uuid' => test()->environment->uuid,
+ 'server_uuid' => test()->server->uuid,
+ 'git_repository' => 'https://gitlab.com/coolify/test-static-app',
+ 'git_branch' => 'main',
+ 'build_pack' => 'static',
+ 'ports_exposes' => '80',
+ 'autogenerate_domain' => false,
+ ], $overrides));
+
+ $response->assertCreated();
+
+ return Application::where('uuid', $response->json('uuid'))->firstOrFail();
+}
+
+test('public non github repositories keep their full git repository url', function () {
+ $application = createPublicGitApplication([
+ 'git_repository' => 'https://cnb.cool/matridx/frp_webui',
+ ]);
+
+ expect($application->git_repository)->toBe('https://cnb.cool/matridx/frp_webui')
+ ->and($application->source_type)->toBeNull()
+ ->and($application->source_id)->toBeNull();
+});
+
+test('public github repositories are stored as owner and repository with the public github source', function () {
+ GithubApp::unguarded(fn () => GithubApp::create([
+ 'id' => 0,
+ 'name' => 'Public GitHub',
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.com',
+ 'is_public' => true,
+ 'team_id' => 0,
+ ]));
+
+ $application = createPublicGitApplication([
+ 'git_repository' => 'https://github.com/coollabsio/coolify-examples',
+ ]);
+
+ expect($application->git_repository)->toBe('coollabsio/coolify-examples')
+ ->and($application->source_type)->toBe(GithubApp::class)
+ ->and($application->source_id)->toBe(0);
+});
From 59b158381c3036ae01b1ea2ac0ed2b0ba94b2464 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 7 Jul 2026 12:12:20 +0200
Subject: [PATCH 53/56] fix(api): preserve source commit flag until cleanup
---
app/Http/Controllers/Api/ApplicationsController.php | 1 -
bootstrap/helpers/api.php | 1 +
2 files changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index cd2d7fce5..9dc3c7661 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -2660,7 +2660,6 @@ public function update_by_uuid(Request $request)
if ($request->has('include_source_commit_in_build')) {
$application->settings->include_source_commit_in_build = $includeSourceCommitInBuild;
$application->settings->save();
- $request->offsetUnset('include_source_commit_in_build');
}
removeUnnecessaryFieldsFromRequest($request);
diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php
index 6a288a064..ab3b8f9d6 100644
--- a/bootstrap/helpers/api.php
+++ b/bootstrap/helpers/api.php
@@ -203,5 +203,6 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('autogenerate_domain');
$request->offsetUnset('is_container_label_escape_enabled');
$request->offsetUnset('is_preserve_repository_enabled');
+ $request->offsetUnset('include_source_commit_in_build');
$request->offsetUnset('docker_compose_raw');
}
From bce871d9912b10ef2509241c7d0e119da0305f3e Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 7 Jul 2026 12:20:26 +0200
Subject: [PATCH 54/56] fix(domains): reject non-HTTP URL schemes
---
bootstrap/helpers/domains.php | 50 +++++++++++++++++++++++++----
tests/Unit/IsValidDomainUrlTest.php | 7 ++++
2 files changed, 51 insertions(+), 6 deletions(-)
diff --git a/bootstrap/helpers/domains.php b/bootstrap/helpers/domains.php
index 1aa182510..f3c5359f7 100644
--- a/bootstrap/helpers/domains.php
+++ b/bootstrap/helpers/domains.php
@@ -6,12 +6,50 @@
function isValidDomainUrl(string $url): bool
{
- // PHP's FILTER_VALIDATE_URL rejects underscores in the host, but they are
- // accepted by browsers, Let's Encrypt, and common Docker service naming
- // (e.g. https://myapp_service.example.com). Validate against a copy with
- // underscores replaced by hyphens (a valid host character) so such domains
- // are not wrongly rejected; URLs without underscores are unaffected.
- return filter_var(str_replace('_', '-', $url), FILTER_VALIDATE_URL) !== false;
+ $components = parse_url($url);
+
+ if ($components === false) {
+ return false;
+ }
+
+ $scheme = $components['scheme'] ?? '';
+ $host = $components['host'] ?? '';
+
+ if (! in_array(strtolower($scheme), ['http', 'https'], true) || $host === '') {
+ return false;
+ }
+
+ $urlToValidate = $scheme.'://';
+
+ if (isset($components['user'])) {
+ $urlToValidate .= $components['user'];
+
+ if (isset($components['pass'])) {
+ $urlToValidate .= ':'.$components['pass'];
+ }
+
+ $urlToValidate .= '@';
+ }
+
+ $urlToValidate .= str_replace('_', '-', $host);
+
+ if (isset($components['port'])) {
+ $urlToValidate .= ':'.$components['port'];
+ }
+
+ if (isset($components['path'])) {
+ $urlToValidate .= $components['path'];
+ }
+
+ if (isset($components['query'])) {
+ $urlToValidate .= '?'.$components['query'];
+ }
+
+ if (isset($components['fragment'])) {
+ $urlToValidate .= '#'.$components['fragment'];
+ }
+
+ return filter_var($urlToValidate, FILTER_VALIDATE_URL) !== false;
}
function checkDomainUsage(ServiceApplication|Application|null $resource = null, ?string $domain = null)
diff --git a/tests/Unit/IsValidDomainUrlTest.php b/tests/Unit/IsValidDomainUrlTest.php
index 940eaecea..3f40646f7 100644
--- a/tests/Unit/IsValidDomainUrlTest.php
+++ b/tests/Unit/IsValidDomainUrlTest.php
@@ -18,5 +18,12 @@
it('rejects strings that are not valid URLs', function () {
expect(isValidDomainUrl('not a url'))->toBeFalse();
expect(isValidDomainUrl('example.com'))->toBeFalse();
+ expect(isValidDomainUrl('ht_tp://example.com'))->toBeFalse();
expect(isValidDomainUrl(''))->toBeFalse();
});
+
+it('rejects URLs that do not use HTTP or HTTPS schemes', function () {
+ expect(isValidDomainUrl('ftp://example.com'))->toBeFalse();
+ expect(isValidDomainUrl('javascript://example.com'))->toBeFalse();
+ expect(isValidDomainUrl('data://example.com'))->toBeFalse();
+});
From 21bd8fa2bca215ab47a6062ae02ac84abb7f4b35 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 7 Jul 2026 12:24:52 +0200
Subject: [PATCH 55/56] fix(github): reject malformed app URL origins
---
bootstrap/helpers/github.php | 17 +++++++-------
tests/Feature/Api/GithubAppsListApiTest.php | 26 +++++++++++++++++++++
tests/Unit/GithubUrlHelpersTest.php | 8 +++++++
3 files changed, 43 insertions(+), 8 deletions(-)
diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php
index 69991f52e..acb93c93a 100644
--- a/bootstrap/helpers/github.php
+++ b/bootstrap/helpers/github.php
@@ -38,22 +38,23 @@ function githubUrlHost(?string $url): ?string
/**
* Build the scheme://host[:port] origin for a GitHub URL.
*
- * When the host cannot be parsed (e.g. a scheme-less or malformed URL such as
- * "not-a-url"), the input is returned verbatim with any trailing slashes
- * trimmed. Callers should pass already-validated URLs (see SafeExternalUrl),
- * so this fallback only guards against unexpected input.
+ * This helper fails explicitly for blank, scheme-less, or malformed input when
+ * githubUrlHost() cannot parse a host, because returning the original input
+ * would not be a valid origin. Callers should pass already-validated URLs.
*
* @param string $url The URL to derive the origin from
- * @return string The normalized origin, or the trimmed input when the host is unparseable
+ * @return string The normalized origin
+ *
+ * @throws InvalidArgumentException When the URL does not contain a parseable scheme and host
*/
function githubUrlOrigin(string $url): string
{
- $scheme = parse_url($url, PHP_URL_SCHEME) ?: 'https';
+ $scheme = parse_url($url, PHP_URL_SCHEME);
$host = githubUrlHost($url);
$port = parse_url($url, PHP_URL_PORT);
- if (! $host) {
- return rtrim($url, '/');
+ if (! is_string($scheme) || blank($scheme) || ! $host) {
+ throw new InvalidArgumentException('GitHub URL must include a valid scheme and host.');
}
return $scheme.'://'.$host.($port ? ":{$port}" : '');
diff --git a/tests/Feature/Api/GithubAppsListApiTest.php b/tests/Feature/Api/GithubAppsListApiTest.php
index 6357c25ba..8f3e6dac5 100644
--- a/tests/Feature/Api/GithubAppsListApiTest.php
+++ b/tests/Feature/Api/GithubAppsListApiTest.php
@@ -322,6 +322,32 @@ function validGithubAppsApiPrivateKey(): string
->assertJsonPath('data.api_url', 'https://github.com/api/v3');
});
+ test('preserves provided api url when updating api url only', function () {
+ $githubApp = GithubApp::create([
+ 'name' => 'GHE App',
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.com',
+ 'app_id' => 12345,
+ 'installation_id' => 67890,
+ 'client_id' => 'test-client-id',
+ 'client_secret' => 'test-client-secret',
+ 'webhook_secret' => 'test-webhook-secret',
+ 'private_key_id' => $this->privateKey->id,
+ 'team_id' => $this->team->id,
+ 'is_system_wide' => false,
+ 'is_public' => false,
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ ])->patchJson("/api/v1/github-apps/{$githubApp->id}", [
+ 'api_url' => 'https://github.com/api/v3',
+ ]);
+
+ $response->assertSuccessful()
+ ->assertJsonPath('data.api_url', 'https://github.com/api/v3');
+ });
+
test('rejects invalid organization when creating github apps', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
diff --git a/tests/Unit/GithubUrlHelpersTest.php b/tests/Unit/GithubUrlHelpersTest.php
index 7c4ee6ff5..dcace7ebe 100644
--- a/tests/Unit/GithubUrlHelpersTest.php
+++ b/tests/Unit/GithubUrlHelpersTest.php
@@ -23,6 +23,14 @@
'github enterprise server' => ['https://github.company.internal', 'https://github.company.internal/api/v3'],
]);
+it('rejects malformed urls when deriving an origin', function (string $url) {
+ expect(fn () => githubUrlOrigin($url))->toThrow(InvalidArgumentException::class);
+})->with([
+ 'blank' => [''],
+ 'scheme-less' => ['github.company.internal'],
+ 'malformed' => ['not-a-url'],
+]);
+
it('generates correct install paths for github cloud ghe cloud and ghes', function (array $attributes, string $expectedPrefix) {
$githubApp = new GithubApp;
$githubApp->forceFill(array_merge([
From cf12e1d7ef4f7cb11dddc2652c7a7f66d84785d9 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 7 Jul 2026 12:36:35 +0200
Subject: [PATCH 56/56] feat(api): allow preview deployments on app create
Expose is_preview_deployments_enabled in the application create API
schema and validation, and skip deployment configuration column casts on
sqlite migrations.
---
bootstrap/helpers/api.php | 2 +
...ation_deployment_configuration_columns.php | 8 ++++
openapi.json | 24 ++++++++++
openapi.yaml | 18 ++++++++
.../ApplicationPreviewDeploymentsApiTest.php | 45 ++++++++++++++++++-
5 files changed, 96 insertions(+), 1 deletion(-)
diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php
index 6a288a064..757343f4d 100644
--- a/bootstrap/helpers/api.php
+++ b/bootstrap/helpers/api.php
@@ -97,6 +97,7 @@ function sharedDataApplications()
'is_spa' => 'boolean',
'is_auto_deploy_enabled' => 'boolean',
'is_force_https_enabled' => 'boolean',
+ 'is_preview_deployments_enabled' => 'boolean',
'static_image' => Rule::enum(StaticImageTypes::class),
'domains' => 'string|nullable',
'redirect' => Rule::enum(RedirectTypes::class),
@@ -198,6 +199,7 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('is_spa');
$request->offsetUnset('is_auto_deploy_enabled');
$request->offsetUnset('is_force_https_enabled');
+ $request->offsetUnset('is_preview_deployments_enabled');
$request->offsetUnset('connect_to_docker_network');
$request->offsetUnset('force_domain_override');
$request->offsetUnset('autogenerate_domain');
diff --git a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php
index 123fd226d..95500cfef 100644
--- a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php
+++ b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php
@@ -11,12 +11,20 @@
*/
public function up(): void
{
+ if (DB::getDriverName() === 'sqlite') {
+ return;
+ }
+
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE text USING configuration_snapshot::text');
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE text USING configuration_diff::text');
}
public function down(): void
{
+ if (DB::getDriverName() === 'sqlite') {
+ return;
+ }
+
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE json USING configuration_snapshot::json');
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE json USING configuration_diff::json');
}
diff --git a/openapi.json b/openapi.json
index ca445ade0..71a7f9c1f 100644
--- a/openapi.json
+++ b/openapi.json
@@ -165,6 +165,10 @@
"type": "boolean",
"description": "The flag to indicate if HTTPS is forced. Defaults to true."
},
+ "is_preview_deployments_enabled": {
+ "type": "boolean",
+ "description": "Enable preview deployments for pull requests."
+ },
"static_image": {
"type": "string",
"enum": [
@@ -615,6 +619,10 @@
"type": "boolean",
"description": "The flag to indicate if HTTPS is forced. Defaults to true."
},
+ "is_preview_deployments_enabled": {
+ "type": "boolean",
+ "description": "Enable preview deployments for pull requests."
+ },
"static_image": {
"type": "string",
"enum": [
@@ -1065,6 +1073,10 @@
"type": "boolean",
"description": "The flag to indicate if HTTPS is forced. Defaults to true."
},
+ "is_preview_deployments_enabled": {
+ "type": "boolean",
+ "description": "Enable preview deployments for pull requests."
+ },
"static_image": {
"type": "string",
"enum": [
@@ -1626,6 +1638,10 @@
"type": "boolean",
"description": "The flag to indicate if HTTPS is forced. Defaults to true."
},
+ "is_preview_deployments_enabled": {
+ "type": "boolean",
+ "description": "Enable preview deployments for pull requests."
+ },
"use_build_server": {
"type": "boolean",
"nullable": true,
@@ -1961,6 +1977,10 @@
"type": "boolean",
"description": "The flag to indicate if HTTPS is forced. Defaults to true."
},
+ "is_preview_deployments_enabled": {
+ "type": "boolean",
+ "description": "Enable preview deployments for pull requests."
+ },
"use_build_server": {
"type": "boolean",
"nullable": true,
@@ -2333,6 +2353,10 @@
"type": "boolean",
"description": "The flag to indicate if HTTPS is forced. Defaults to true."
},
+ "is_preview_deployments_enabled": {
+ "type": "boolean",
+ "description": "Enable preview deployments for pull requests."
+ },
"install_command": {
"type": "string",
"description": "The install command."
diff --git a/openapi.yaml b/openapi.yaml
index 6182cacd3..e8dba0a54 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -118,6 +118,9 @@ paths:
is_force_https_enabled:
type: boolean
description: 'The flag to indicate if HTTPS is forced. Defaults to true.'
+ is_preview_deployments_enabled:
+ type: boolean
+ description: 'Enable preview deployments for pull requests.'
static_image:
type: string
enum: ['nginx:alpine']
@@ -405,6 +408,9 @@ paths:
is_force_https_enabled:
type: boolean
description: 'The flag to indicate if HTTPS is forced. Defaults to true.'
+ is_preview_deployments_enabled:
+ type: boolean
+ description: 'Enable preview deployments for pull requests.'
static_image:
type: string
enum: ['nginx:alpine']
@@ -692,6 +698,9 @@ paths:
is_force_https_enabled:
type: boolean
description: 'The flag to indicate if HTTPS is forced. Defaults to true.'
+ is_preview_deployments_enabled:
+ type: boolean
+ description: 'Enable preview deployments for pull requests.'
static_image:
type: string
enum: ['nginx:alpine']
@@ -1063,6 +1072,9 @@ paths:
is_force_https_enabled:
type: boolean
description: 'The flag to indicate if HTTPS is forced. Defaults to true.'
+ is_preview_deployments_enabled:
+ type: boolean
+ description: 'Enable preview deployments for pull requests.'
use_build_server:
type: boolean
nullable: true
@@ -1277,6 +1289,9 @@ paths:
is_force_https_enabled:
type: boolean
description: 'The flag to indicate if HTTPS is forced. Defaults to true.'
+ is_preview_deployments_enabled:
+ type: boolean
+ description: 'Enable preview deployments for pull requests.'
use_build_server:
type: boolean
nullable: true
@@ -1507,6 +1522,9 @@ paths:
is_force_https_enabled:
type: boolean
description: 'The flag to indicate if HTTPS is forced. Defaults to true.'
+ is_preview_deployments_enabled:
+ type: boolean
+ description: 'Enable preview deployments for pull requests.'
install_command:
type: string
description: 'The install command.'
diff --git a/tests/Feature/ApplicationPreviewDeploymentsApiTest.php b/tests/Feature/ApplicationPreviewDeploymentsApiTest.php
index 8752d81ef..34b670690 100644
--- a/tests/Feature/ApplicationPreviewDeploymentsApiTest.php
+++ b/tests/Feature/ApplicationPreviewDeploymentsApiTest.php
@@ -1,7 +1,7 @@
'file']);
+
+ InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
+
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
@@ -106,3 +110,42 @@ function previewDeploymentsAuthHeaders($bearerToken): array
$response->assertStatus(422);
});
});
+
+describe('POST /api/v1/applications/public is_preview_deployments_enabled', function () {
+ test('can enable preview deployments on create', function () {
+ $response = $this->withHeaders(previewDeploymentsAuthHeaders($this->bearerToken))
+ ->postJson('/api/v1/applications/public', [
+ 'project_uuid' => $this->project->uuid,
+ 'environment_uuid' => $this->environment->uuid,
+ 'server_uuid' => $this->server->uuid,
+ 'git_repository' => 'https://gitlab.com/coolify/test-preview-app',
+ 'git_branch' => 'main',
+ 'build_pack' => 'nixpacks',
+ 'ports_exposes' => '3000',
+ 'is_preview_deployments_enabled' => true,
+ 'autogenerate_domain' => false,
+ ]);
+
+ $response->assertCreated();
+
+ $application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
+ expect($application->settings->is_preview_deployments_enabled)->toBeTrue();
+ });
+
+ test('rejects non-boolean value on create', function () {
+ $response = $this->withHeaders(previewDeploymentsAuthHeaders($this->bearerToken))
+ ->postJson('/api/v1/applications/public', [
+ 'project_uuid' => $this->project->uuid,
+ 'environment_uuid' => $this->environment->uuid,
+ 'server_uuid' => $this->server->uuid,
+ 'git_repository' => 'https://gitlab.com/coolify/test-preview-app',
+ 'git_branch' => 'main',
+ 'build_pack' => 'nixpacks',
+ 'ports_exposes' => '3000',
+ 'is_preview_deployments_enabled' => 'yes',
+ 'autogenerate_domain' => false,
+ ]);
+
+ $response->assertUnprocessable();
+ });
+});