fix(database): refresh SSL/status state and harden clone writes

Handle database status updates more reliably by listening for `ServiceChecked`
and using explicit `refresh()` handlers in Livewire database components.

Also switch guarded clone/create paths to `forceFill`/`forceCreate` in helper
flows to avoid missing persisted attributes during app/service cloning.

Update log/terminal font stacks to Geist (with bundled variable fonts) and add
coverage for SSL status refresh, persistent volume UUID cloning, and log font
styling.
This commit is contained in:
Andras Bacsai 2026-03-31 09:29:36 +02:00
parent 1efed14169
commit 2692496726
24 changed files with 333 additions and 33 deletions

View file

@ -57,7 +57,8 @@ public function getListeners()
return [
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
];
}
@ -299,4 +300,10 @@ public function regenerateSslCertificate()
handleError($e, $this);
}
}
public function refresh(): void
{
$this->database->refresh();
$this->syncData();
}
}

View file

@ -59,7 +59,8 @@ public function getListeners()
return [
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
];
}
@ -304,4 +305,10 @@ public function regenerateSslCertificate()
handleError($e, $this);
}
}
public function refresh(): void
{
$this->database->refresh();
$this->syncData();
}
}

View file

@ -61,9 +61,11 @@ class General extends Component
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
];
}

View file

@ -61,9 +61,11 @@ class General extends Component
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
];
}

View file

@ -63,9 +63,11 @@ class General extends Component
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
];
}

View file

@ -71,9 +71,11 @@ class General extends Component
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
'save_init_script',
'delete_init_script',
];
@ -488,4 +490,10 @@ public function submit()
}
}
}
public function refresh(): void
{
$this->database->refresh();
$this->syncData();
}
}

View file

@ -59,9 +59,11 @@ class General extends Component
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
'envsUpdated' => 'refresh',
];
}

View file

@ -237,10 +237,11 @@ function clone_application(Application $source, $destination, array $overrides =
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'application_id' => $newApplication->id,
]);
$newApplicationSettings->save();
$newApplication->setRelation('settings', $newApplicationSettings->fresh());
}
// Clone tags
@ -256,7 +257,7 @@ function clone_application(Application $source, $destination, array $overrides =
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'uuid' => (string) new Cuid2,
'application_id' => $newApplication->id,
'team_id' => currentTeam()->id,
@ -271,7 +272,7 @@ function clone_application(Application $source, $destination, array $overrides =
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'uuid' => (string) new Cuid2,
'application_id' => $newApplication->id,
'status' => 'exited',
@ -303,7 +304,7 @@ function clone_application(Application $source, $destination, array $overrides =
'created_at',
'updated_at',
'uuid',
])->fill([
])->forceFill([
'name' => $newName,
'resource_id' => $newApplication->id,
]);
@ -339,7 +340,7 @@ function clone_application(Application $source, $destination, array $overrides =
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'resource_id' => $newApplication->id,
]);
$newStorage->save();
@ -353,7 +354,7 @@ function clone_application(Application $source, $destination, array $overrides =
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'resourceable_id' => $newApplication->id,
'resourceable_type' => $newApplication->getMorphClass(),
'is_preview' => false,
@ -370,7 +371,7 @@ function clone_application(Application $source, $destination, array $overrides =
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'resourceable_id' => $newApplication->id,
'resourceable_type' => $newApplication->getMorphClass(),
'is_preview' => true,

View file

@ -1919,7 +1919,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
// Create new serviceApplication or serviceDatabase
if ($isDatabase) {
if ($isNew) {
$savedService = ServiceDatabase::create([
$savedService = ServiceDatabase::forceCreate([
'name' => $serviceName,
'image' => $image,
'service_id' => $resource->id,
@ -1930,7 +1930,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
'service_id' => $resource->id,
])->first();
if (is_null($savedService)) {
$savedService = ServiceDatabase::create([
$savedService = ServiceDatabase::forceCreate([
'name' => $serviceName,
'image' => $image,
'service_id' => $resource->id,
@ -1939,7 +1939,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
} else {
if ($isNew) {
$savedService = ServiceApplication::create([
$savedService = ServiceApplication::forceCreate([
'name' => $serviceName,
'image' => $image,
'service_id' => $resource->id,
@ -1950,7 +1950,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
'service_id' => $resource->id,
])->first();
if (is_null($savedService)) {
$savedService = ServiceApplication::create([
$savedService = ServiceApplication::forceCreate([
'name' => $serviceName,
'image' => $image,
'service_id' => $resource->id,

View file

@ -4331,6 +4331,11 @@
"database_backup_retention_max_storage_s3": {
"type": "integer",
"description": "Max storage (MB) for S3 backups"
},
"timeout": {
"type": "integer",
"description": "Backup job timeout in seconds (min: 60, max: 36000)",
"default": 3600
}
},
"type": "object"
@ -4896,6 +4901,11 @@
"database_backup_retention_max_storage_s3": {
"type": "integer",
"description": "Max storage of the backup in S3"
},
"timeout": {
"type": "integer",
"description": "Backup job timeout in seconds (min: 60, max: 36000)",
"default": 3600
}
},
"type": "object"
@ -10451,6 +10461,26 @@
"none"
],
"description": "The proxy type."
},
"concurrent_builds": {
"type": "integer",
"description": "Number of concurrent builds."
},
"dynamic_timeout": {
"type": "integer",
"description": "Deployment timeout in seconds."
},
"deployment_queue_limit": {
"type": "integer",
"description": "Maximum number of queued deployments."
},
"server_disk_usage_notification_threshold": {
"type": "integer",
"description": "Server disk usage notification threshold (%)."
},
"server_disk_usage_check_frequency": {
"type": "string",
"description": "Cron expression for disk usage check frequency."
}
},
"type": "object"

View file

@ -2734,6 +2734,10 @@ paths:
database_backup_retention_max_storage_s3:
type: integer
description: 'Max storage (MB) for S3 backups'
timeout:
type: integer
description: 'Backup job timeout in seconds (min: 60, max: 36000)'
default: 3600
type: object
responses:
'201':
@ -3125,6 +3129,10 @@ paths:
database_backup_retention_max_storage_s3:
type: integer
description: 'Max storage of the backup in S3'
timeout:
type: integer
description: 'Backup job timeout in seconds (min: 60, max: 36000)'
default: 3600
type: object
responses:
'200':
@ -6669,6 +6677,21 @@ paths:
type: string
enum: [traefik, caddy, none]
description: 'The proxy type.'
concurrent_builds:
type: integer
description: 'Number of concurrent builds.'
dynamic_timeout:
type: integer
description: 'Deployment timeout in seconds.'
deployment_queue_limit:
type: integer
description: 'Maximum number of queued deployments.'
server_disk_usage_notification_threshold:
type: integer
description: 'Server disk usage notification threshold (%).'
server_disk_usage_check_frequency:
type: string
description: 'Cron expression for disk usage check frequency.'
type: object
responses:
'201':

View file

@ -14,7 +14,9 @@
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: Inter, sans-serif;
--font-sans: 'Geist Sans', Inter, sans-serif;
--font-geist-sans: 'Geist Sans', Inter, sans-serif;
--font-logs: 'Geist Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
--color-base: #101010;
--color-warning: #fcd452;
@ -96,7 +98,7 @@ body {
}
body {
@apply min-h-screen text-sm antialiased scrollbar overflow-x-hidden;
@apply min-h-screen text-sm font-sans antialiased scrollbar overflow-x-hidden;
}
.coolify-monaco-editor {

View file

@ -70,3 +70,18 @@ @font-face {
src: url('../fonts/inter-v13-cyrillic_cyrillic-ext_greek_greek-ext_latin_latin-ext_vietnamese-regular.woff2') format('woff2');
}
@font-face {
font-display: swap;
font-family: 'Geist Mono';
font-style: normal;
font-weight: 100 900;
src: url('../fonts/geist-mono-variable.woff2') format('woff2');
}
@font-face {
font-display: swap;
font-family: 'Geist Sans';
font-style: normal;
font-weight: 100 900;
src: url('../fonts/geist-sans-variable.woff2') format('woff2');
}

Binary file not shown.

Binary file not shown.

View file

@ -186,7 +186,7 @@ export function initializeTerminalComponent() {
this.term = new Terminal({
cols: 80,
rows: 30,
fontFamily: '"Fira Code", courier-new, courier, monospace, "Powerline Extra Symbols"',
fontFamily: '"Geist Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace, "Powerline Extra Symbols"',
cursorBlink: true,
rendererType: 'canvas',
convertEol: true,

View file

@ -52,7 +52,7 @@
'flex-1 min-h-0' => $fullHeight,
'max-h-96' => !$fullHeight,
])>
<pre class="font-mono whitespace-pre-wrap" @if ($isPollingActive) wire:poll.1000ms="polling" @endif>{{ RunRemoteProcess::decodeOutput($activity) }}</pre>
<pre class="font-logs whitespace-pre-wrap" @if ($isPollingActive) wire:poll.1000ms="polling" @endif>{{ RunRemoteProcess::decodeOutput($activity) }}</pre>
</div>
@else
@if ($showWaiting)

View file

@ -330,7 +330,7 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
<div id="logsContainer"
class="flex flex-col overflow-y-auto p-2 px-4 min-h-4 scrollbar"
:class="fullscreen ? 'flex-1' : 'max-h-[30rem]'">
<div id="logs" class="flex flex-col font-mono">
<div id="logs" class="flex flex-col font-logs">
<div x-show="searchQuery.trim() && matchCount === 0"
class="text-gray-500 dark:text-gray-400 py-2">
No matches found.
@ -356,7 +356,7 @@ class="shrink-0 text-gray-500">{{ $line['timestamp'] }}</span>
])>{{ $lineContent }}</span>
</div>
@empty
<span class="font-mono text-neutral-400 mb-2">No logs yet.</span>
<span class="font-logs text-neutral-400 mb-2">No logs yet.</span>
@endforelse
</div>
</div>

View file

@ -480,7 +480,7 @@ class="flex overflow-y-auto overflow-x-hidden flex-col px-4 py-2 w-full min-w-0
@php
$displayLines = collect(explode("\n", $outputs))->filter(fn($line) => trim($line) !== '');
@endphp
<div id="logs" class="font-mono max-w-full cursor-default">
<div id="logs" class="font-logs max-w-full cursor-default">
<div x-show="searchQuery.trim() && matchCount === 0"
class="text-gray-500 dark:text-gray-400 py-2">
No matches found.
@ -518,7 +518,7 @@ class="text-gray-500 dark:text-gray-400 py-2">
</div>
@else
<pre id="logs"
class="font-mono whitespace-pre-wrap break-all max-w-full text-neutral-400">No logs yet.</pre>
class="font-logs whitespace-pre-wrap break-all max-w-full text-neutral-400">No logs yet.</pre>
@endif
</div>
</div>

View file

@ -100,7 +100,7 @@
<svg class="h-5 w-5 shrink-0 text-gray-500 dark:text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<code class="flex-1 text-sm font-mono text-gray-700 dark:text-gray-300">{{ data_get($result, 'command') }}</code>
<code class="flex-1 text-sm font-logs text-gray-700 dark:text-gray-300">{{ data_get($result, 'command') }}</code>
</div>
@php
$output = data_get($result, 'output');
@ -108,7 +108,7 @@
@endphp
<div class="p-4">
@if($hasOutput)
<pre class="font-mono text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap">{{ $output }}</pre>
<pre class="font-logs text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap">{{ $output }}</pre>
@else
<p class="text-sm text-gray-500 dark:text-gray-400 italic">
No output returned - command completed successfully

View file

@ -1,15 +1,18 @@
<?php
use App\Livewire\Project\Shared\ResourceOperations;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\ApplicationSetting;
use App\Models\Environment;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Livewire\Livewire;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
@ -17,7 +20,7 @@
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]);
$this->destination = $this->server->standaloneDockers()->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
@ -25,8 +28,13 @@
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'redirect' => 'both',
]);
$this->application->settings->forceFill([
'is_container_label_readonly_enabled' => false,
])->save();
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
@ -82,3 +90,71 @@
expect($clonedUuids)->each->not->toBeIn($originalUuids);
expect(array_unique($clonedUuids))->toHaveCount(2);
});
test('cloning application reassigns settings to the cloned application', function () {
$this->application->settings->forceFill([
'is_static' => true,
'is_spa' => true,
'is_build_server_enabled' => true,
])->save();
$newApp = clone_application($this->application, $this->destination, [
'environment_id' => $this->environment->id,
]);
$sourceSettingsCount = ApplicationSetting::query()
->where('application_id', $this->application->id)
->count();
$clonedSettings = ApplicationSetting::query()
->where('application_id', $newApp->id)
->first();
expect($sourceSettingsCount)->toBe(1)
->and($clonedSettings)->not->toBeNull()
->and($clonedSettings?->application_id)->toBe($newApp->id)
->and($clonedSettings?->is_static)->toBeTrue()
->and($clonedSettings?->is_spa)->toBeTrue()
->and($clonedSettings?->is_build_server_enabled)->toBeTrue();
});
test('cloning application reassigns scheduled tasks and previews to the cloned application', function () {
$scheduledTask = ScheduledTask::forceCreate([
'uuid' => 'scheduled-task-original',
'application_id' => $this->application->id,
'team_id' => $this->team->id,
'name' => 'nightly-task',
'command' => 'php artisan schedule:run',
'frequency' => '* * * * *',
'container' => 'app',
'timeout' => 120,
]);
$preview = ApplicationPreview::forceCreate([
'uuid' => 'preview-original',
'application_id' => $this->application->id,
'pull_request_id' => 123,
'pull_request_html_url' => 'https://example.com/pull/123',
'fqdn' => 'https://preview.example.com',
'status' => 'running',
]);
$newApp = clone_application($this->application, $this->destination, [
'environment_id' => $this->environment->id,
]);
$clonedTask = ScheduledTask::query()
->where('application_id', $newApp->id)
->first();
$clonedPreview = ApplicationPreview::query()
->where('application_id', $newApp->id)
->first();
expect($clonedTask)->not->toBeNull()
->and($clonedTask?->uuid)->not->toBe($scheduledTask->uuid)
->and($clonedTask?->application_id)->toBe($newApp->id)
->and($clonedTask?->team_id)->toBe($this->team->id)
->and($clonedPreview)->not->toBeNull()
->and($clonedPreview?->uuid)->not->toBe($preview->uuid)
->and($clonedPreview?->application_id)->toBe($newApp->id)
->and($clonedPreview?->status)->toBe('exited');
});

View file

@ -0,0 +1,77 @@
<?php
use App\Livewire\Project\Database\Dragonfly\General as DragonflyGeneral;
use App\Livewire\Project\Database\Keydb\General as KeydbGeneral;
use App\Livewire\Project\Database\Mariadb\General as MariadbGeneral;
use App\Livewire\Project\Database\Mongodb\General as MongodbGeneral;
use App\Livewire\Project\Database\Mysql\General as MysqlGeneral;
use App\Livewire\Project\Database\Postgresql\General as PostgresqlGeneral;
use App\Livewire\Project\Database\Redis\General as RedisGeneral;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandaloneMysql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
dataset('ssl-aware-database-general-components', [
MysqlGeneral::class,
MariadbGeneral::class,
MongodbGeneral::class,
RedisGeneral::class,
PostgresqlGeneral::class,
KeydbGeneral::class,
DragonflyGeneral::class,
]);
it('maps database status broadcasts to refresh for ssl-aware database general components', function (string $componentClass) {
$component = app($componentClass);
$listeners = $component->getListeners();
expect($listeners["echo-private:user.{$this->user->id},DatabaseStatusChanged"])->toBe('refresh')
->and($listeners["echo-private:team.{$this->team->id},ServiceChecked"])->toBe('refresh');
})->with('ssl-aware-database-general-components');
it('reloads the mysql database model when refreshing so ssl controls follow the latest status', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandaloneMysql::forceCreate([
'name' => 'test-mysql',
'image' => 'mysql:8',
'mysql_root_password' => 'password',
'mysql_user' => 'coolify',
'mysql_password' => 'password',
'mysql_database' => 'coolify',
'status' => 'exited:unhealthy',
'enable_ssl' => true,
'is_log_drain_enabled' => false,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$component = Livewire::test(MysqlGeneral::class, ['database' => $database])
->assertDontSee('Database should be stopped to change this settings.');
$database->forceFill(['status' => 'running:healthy'])->save();
$component->call('refresh')
->assertSee('Database should be stopped to change this settings.');
});

View file

@ -0,0 +1,45 @@
<?php
it('registers geist mono from a local asset for log surfaces', function () {
$fontsCss = file_get_contents(resource_path('css/fonts.css'));
$appCss = file_get_contents(resource_path('css/app.css'));
$fontPath = resource_path('fonts/geist-mono-variable.woff2');
$geistSansPath = resource_path('fonts/geist-sans-variable.woff2');
expect($fontsCss)
->toContain("font-family: 'Geist Mono'")
->toContain("url('../fonts/geist-mono-variable.woff2')")
->toContain("font-family: 'Geist Sans'")
->toContain("url('../fonts/geist-sans-variable.woff2')")
->and($appCss)
->toContain("--font-sans: 'Geist Sans', Inter, sans-serif")
->toContain('@apply min-h-screen text-sm font-sans antialiased scrollbar overflow-x-hidden;')
->toContain("--font-logs: 'Geist Mono'")
->toContain("--font-geist-sans: 'Geist Sans'")
->and($fontPath)
->toBeFile()
->and($geistSansPath)
->toBeFile();
});
it('uses geist mono for shared logs and terminal rendering', function () {
$sharedLogsView = file_get_contents(resource_path('views/livewire/project/shared/get-logs.blade.php'));
$deploymentLogsView = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
$activityMonitorView = file_get_contents(resource_path('views/livewire/activity-monitor.blade.php'));
$dockerCleanupView = file_get_contents(resource_path('views/livewire/server/docker-cleanup-executions.blade.php'));
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($sharedLogsView)
->toContain('class="font-logs max-w-full cursor-default"')
->toContain('class="font-logs whitespace-pre-wrap break-all max-w-full text-neutral-400"')
->and($deploymentLogsView)
->toContain('class="flex flex-col font-logs"')
->toContain('class="font-logs text-neutral-400 mb-2"')
->and($activityMonitorView)
->toContain('<pre class="font-logs whitespace-pre-wrap"')
->and($dockerCleanupView)
->toContain('class="flex-1 text-sm font-logs text-gray-700 dark:text-gray-300"')
->toContain('class="font-logs text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap"')
->and($terminalClient)
->toContain('"Geist Mono"');
});

View file

@ -41,7 +41,8 @@
// The new code checks for null within the else block and creates only if needed
expect($sharedFile)
->toContain('if (is_null($savedService)) {')
->toContain('$savedService = ServiceDatabase::create([');
->toContain('$savedService = ServiceDatabase::forceCreate([')
->toContain('$savedService = ServiceApplication::forceCreate([');
});
it('verifies image update logic is present in parseDockerComposeFile', function () {