Refine service resource routing

This commit is contained in:
Andras Bacsai 2026-05-22 13:38:28 +02:00
parent 49656aa1ed
commit beaad0a722
5 changed files with 226 additions and 28 deletions

View file

@ -5,6 +5,15 @@
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
@ -219,7 +228,7 @@ public function updatedDumpAll($value)
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === \App\Models\ServiceDatabase::class) {
if ($morphClass === ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
@ -231,7 +240,7 @@ public function updatedDumpAll($value)
}
switch ($morphClass) {
case \App\Models\StandaloneMariadb::class:
case StandaloneMariadb::class:
case 'mariadb':
if ($value === true) {
$this->mariadbRestoreCommand = <<<'EOD'
@ -247,7 +256,7 @@ public function updatedDumpAll($value)
$this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
}
break;
case \App\Models\StandaloneMysql::class:
case StandaloneMysql::class:
case 'mysql':
if ($value === true) {
$this->mysqlRestoreCommand = <<<'EOD'
@ -263,7 +272,7 @@ public function updatedDumpAll($value)
$this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
}
break;
case \App\Models\StandalonePostgresql::class:
case StandalonePostgresql::class:
case 'postgresql':
if ($value === true) {
$this->postgresqlRestoreCommand = <<<'EOD'
@ -299,10 +308,16 @@ public function getContainers()
} elseif ($stackServiceUuid) {
// ServiceDatabase route - look up the service database
$serviceUuid = data_get($this->parameters, 'service_uuid');
$service = Service::whereUuid($serviceUuid)->first();
if (! $service) {
abort(404);
}
$project = currentTeam()
->projects()
->select('id', 'uuid', 'team_id')
->where('uuid', data_get($this->parameters, 'project_uuid'))
->firstOrFail();
$environment = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->where('uuid', data_get($this->parameters, 'environment_uuid'))
->firstOrFail();
$service = $environment->services()->whereUuid($serviceUuid)->firstOrFail();
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
if (is_null($resource)) {
abort(404);
@ -321,7 +336,7 @@ public function getContainers()
$this->resourceStatus = $resource->status ?? '';
// Handle ServiceDatabase server access differently
if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
if ($resource->getMorphClass() === ServiceDatabase::class) {
$server = $resource->service?->server;
if (! $server) {
abort(404, 'Server not found for this service database.');
@ -359,16 +374,16 @@ public function getContainers()
}
if (
$resource->getMorphClass() === \App\Models\StandaloneRedis::class ||
$resource->getMorphClass() === \App\Models\StandaloneKeydb::class ||
$resource->getMorphClass() === \App\Models\StandaloneDragonfly::class ||
$resource->getMorphClass() === \App\Models\StandaloneClickhouse::class
$resource->getMorphClass() === StandaloneRedis::class ||
$resource->getMorphClass() === StandaloneKeydb::class ||
$resource->getMorphClass() === StandaloneDragonfly::class ||
$resource->getMorphClass() === StandaloneClickhouse::class
) {
$this->unsupported = true;
}
// Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.)
if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
if ($resource->getMorphClass() === ServiceDatabase::class) {
$dbType = $resource->databaseType();
if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') ||
str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) {
@ -664,7 +679,7 @@ public function restoreFromS3(string $password = ''): bool|string
$fullImageName = "{$helperImage}:{$latestVersion}";
// Get the database destination network
if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
if ($this->resource->getMorphClass() === ServiceDatabase::class) {
$destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
} else {
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
@ -756,7 +771,7 @@ public function buildRestoreCommand(string $tmpPath): string
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === \App\Models\ServiceDatabase::class) {
if ($morphClass === ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
@ -770,7 +785,7 @@ public function buildRestoreCommand(string $tmpPath): string
}
switch ($morphClass) {
case \App\Models\StandaloneMariadb::class:
case StandaloneMariadb::class:
case 'mariadb':
$restoreCommand = $this->mariadbRestoreCommand;
if ($this->dumpAll) {
@ -779,7 +794,7 @@ public function buildRestoreCommand(string $tmpPath): string
$restoreCommand .= " < {$tmpPath}";
}
break;
case \App\Models\StandaloneMysql::class:
case StandaloneMysql::class:
case 'mysql':
$restoreCommand = $this->mysqlRestoreCommand;
if ($this->dumpAll) {
@ -788,7 +803,7 @@ public function buildRestoreCommand(string $tmpPath): string
$restoreCommand .= " < {$tmpPath}";
}
break;
case \App\Models\StandalonePostgresql::class:
case StandalonePostgresql::class:
case 'postgresql':
$restoreCommand = $this->postgresqlRestoreCommand;
if ($this->dumpAll) {
@ -797,7 +812,7 @@ public function buildRestoreCommand(string $tmpPath): string
$restoreCommand .= " {$tmpPath}";
}
break;
case \App\Models\StandaloneMongodb::class:
case StandaloneMongodb::class:
case 'mongodb':
$restoreCommand = $this->mongodbRestoreCommand;
if ($this->dumpAll === false) {

View file

@ -28,10 +28,16 @@ public function mount()
try {
$this->parameters = get_route_parameters();
$this->query = request()->query();
$this->service = Service::whereUuid($this->parameters['service_uuid'])->first();
if (! $this->service) {
return redirect()->route('dashboard');
}
$project = currentTeam()
->projects()
->select('id', 'uuid', 'team_id')
->where('uuid', $this->parameters['project_uuid'])
->firstOrFail();
$environment = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->where('uuid', $this->parameters['environment_uuid'])
->firstOrFail();
$this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
$this->authorize('view', $this->service);
$this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first();

View file

@ -7,12 +7,15 @@
use App\Actions\Service\StopService;
use App\Enums\ProcessStatus;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
class Heading extends Component
{
use AuthorizesRequests;
public Service $service;
public array $parameters;
@ -27,6 +30,8 @@ class Heading extends Component
public function mount()
{
$this->authorizeService('view');
if (str($this->service->status)->contains('running') && is_null($this->service->config_hash)) {
$this->service->isConfigurationChanged(true);
$this->dispatch('configurationChanged');
@ -47,6 +52,8 @@ public function getListeners()
public function checkStatus()
{
$this->authorizeService('view');
if ($this->service->server->isFunctional()) {
GetContainersStatus::dispatch($this->service->server);
} else {
@ -61,6 +68,8 @@ public function manualCheckStatus()
public function serviceChecked()
{
$this->authorizeService('view');
try {
$this->service->applications->each(function ($application) {
$application->refresh();
@ -82,6 +91,8 @@ public function serviceChecked()
public function checkDeployments()
{
$this->authorizeService('view');
try {
$activity = Activity::where('properties->type_uuid', $this->service->uuid)->latest()->first();
$status = data_get($activity, 'properties.status');
@ -99,12 +110,16 @@ public function checkDeployments()
public function start()
{
$this->authorizeService('deploy');
$activity = StartService::run($this->service, pullLatestImages: true);
$this->dispatch('activityMonitor', $activity->id);
}
public function forceDeploy()
{
$this->authorizeService('deploy');
try {
$activities = Activity::where('properties->type_uuid', $this->service->uuid)
->where(function ($q) {
@ -124,6 +139,8 @@ public function forceDeploy()
public function stop()
{
$this->authorizeService('stop');
try {
StopService::dispatch($this->service, false, $this->docker_cleanup);
} catch (\Exception $e) {
@ -133,6 +150,8 @@ public function stop()
public function restart()
{
$this->authorizeService('deploy');
$this->checkDeployments();
if ($this->isDeploymentProgress) {
$this->dispatch('error', 'There is a deployment in progress.');
@ -145,6 +164,8 @@ public function restart()
public function pullAndRestartEvent()
{
$this->authorizeService('deploy');
$this->checkDeployments();
if ($this->isDeploymentProgress) {
$this->dispatch('error', 'There is a deployment in progress.');
@ -155,6 +176,15 @@ public function pullAndRestartEvent()
$this->dispatch('activityMonitor', $activity->id);
}
private function authorizeService(string $ability): void
{
$this->service = Service::ownedByCurrentTeam()
->whereKey($this->service->getKey())
->firstOrFail();
$this->authorize($ability, $this->service);
}
public function render()
{
return view('livewire.project.service.heading', [

View file

@ -108,10 +108,16 @@ public function mount()
$this->parameters = get_route_parameters();
$this->query = request()->query();
$this->currentRoute = request()->route()->getName();
$this->service = Service::whereUuid($this->parameters['service_uuid'])->first();
if (! $this->service) {
return redirect()->route('dashboard');
}
$project = currentTeam()
->projects()
->select('id', 'uuid', 'team_id')
->where('uuid', $this->parameters['project_uuid'])
->firstOrFail();
$environment = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->where('uuid', $this->parameters['environment_uuid'])
->firstOrFail();
$this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
$this->authorize('view', $this->service);
$service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first();
if ($service) {

View file

@ -0,0 +1,141 @@
<?php
use App\Livewire\Project\Database\Import as DatabaseImport;
use App\Livewire\Project\Service\Heading;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Once;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
uses(RefreshDatabase::class);
beforeEach(function () {
Config::set('cache.default', 'array');
Config::set('app.maintenance.store', 'array');
Config::set('queue.default', 'sync');
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
Once::flush();
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->destinationA = StandaloneDocker::factory()->create([
'server_id' => $this->serverA->id,
'network' => 'team-a-network',
]);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
$this->userB = User::factory()->create();
$this->teamB = Team::factory()->create();
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->destinationB = StandaloneDocker::factory()->create([
'server_id' => $this->serverB->id,
'network' => 'team-b-network',
]);
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
$this->otherService = Service::factory()->create([
'server_id' => $this->serverB->id,
'destination_id' => $this->destinationB->id,
'destination_type' => $this->destinationB->getMorphClass(),
'environment_id' => $this->environmentB->id,
]);
$this->otherServiceApplication = ServiceApplication::create([
'service_id' => $this->otherService->id,
'name' => 'other-app',
'image' => 'nginx:alpine',
]);
$this->otherServiceDatabase = ServiceDatabase::create([
'service_id' => $this->otherService->id,
'name' => 'other-db',
'image' => 'postgres:16-alpine',
'custom_type' => 'postgresql',
]);
$this->ownService = Service::factory()->create([
'server_id' => $this->serverA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => $this->destinationA->getMorphClass(),
'environment_id' => $this->environmentA->id,
]);
$this->ownServiceDatabase = ServiceDatabase::create([
'service_id' => $this->ownService->id,
'name' => 'own-db',
'image' => 'postgres:16-alpine',
'custom_type' => 'postgresql',
]);
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
test('does not open service application detail route from another team', function () {
$this->withoutExceptionHandling();
$this->get(route('project.service.index', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->otherService->uuid,
'stack_service_uuid' => $this->otherServiceApplication->uuid,
]));
})->throws(NotFoundHttpException::class);
test('does not open service database backups route from another team', function () {
$this->withoutExceptionHandling();
$this->get(route('project.service.database.backups', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->otherService->uuid,
'stack_service_uuid' => $this->otherServiceDatabase->uuid,
]));
})->throws(NotFoundHttpException::class);
test('does not resolve service database import component from another team', function () {
$component = app(DatabaseImport::class);
$component->parameters = [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->otherService->uuid,
'stack_service_uuid' => $this->otherServiceDatabase->uuid,
];
$component->getContainers();
})->throws(ModelNotFoundException::class);
test('service heading does not hydrate with another team service', function () {
Livewire::test(Heading::class, ['service' => $this->otherService]);
})->throws(ModelNotFoundException::class);
test('owner can still hydrate service heading with own service', function () {
Livewire::test(Heading::class, [
'service' => $this->ownService,
'parameters' => [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
],
])
->assertOk();
});