mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(compose): create one service on repeated submits and reject compose without services
Closes #11078 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a836756e9b
commit
e3ee4a99c5
@@ -2,10 +2,13 @@
|
||||
|
||||
namespace App\Livewire\Project\New;
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class DockerCompose extends Component
|
||||
@@ -20,6 +23,9 @@ class DockerCompose extends Component
|
||||
|
||||
public array $query;
|
||||
|
||||
#[Locked]
|
||||
public ?string $createdServiceUuid = null;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->parameters = get_route_parameters();
|
||||
@@ -37,7 +43,10 @@ class DockerCompose extends Component
|
||||
$this->validate([
|
||||
'dockerComposeRaw' => 'required',
|
||||
]);
|
||||
parseDockerComposeYaml($this->dockerComposeRaw);
|
||||
$services = data_get(parseDockerComposeYaml($this->dockerComposeRaw), 'services');
|
||||
if (! is_array($services) || $services === [] || array_is_list($services)) {
|
||||
throw new \Exception('The Docker Compose file must define at least one service under "services".');
|
||||
}
|
||||
|
||||
// Validate for command injection BEFORE saving to database
|
||||
validateDockerComposeForInjection($this->dockerComposeRaw);
|
||||
@@ -52,43 +61,57 @@ class DockerCompose extends Component
|
||||
}
|
||||
$destination_class = $destination->getMorphClass();
|
||||
|
||||
$service = new Service([
|
||||
'docker_compose_raw' => $this->dockerComposeRaw,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $destination->server_id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination_class,
|
||||
]);
|
||||
$service->save();
|
||||
|
||||
$variables = parseEnvFormatToArray($this->envFile);
|
||||
foreach ($variables as $key => $data) {
|
||||
// Extract value and comment from parsed data
|
||||
// Handle both array format ['value' => ..., 'comment' => ...] and plain string values
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
$comment = is_array($data) ? ($data['comment'] ?? null) : null;
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'comment' => $comment,
|
||||
'is_preview' => false,
|
||||
'resourceable_id' => $service->id,
|
||||
'resourceable_type' => $service->getMorphClass(),
|
||||
]);
|
||||
if ($this->createdServiceUuid) {
|
||||
return $this->redirectToService($this->createdServiceUuid, $project, $environment);
|
||||
}
|
||||
$service->parse(isNew: true);
|
||||
|
||||
// Apply service-specific application prerequisites
|
||||
applyServiceApplicationPrerequisites($service);
|
||||
$service = DB::transaction(function () use ($environment, $destination, $destination_class) {
|
||||
$service = new Service([
|
||||
'docker_compose_raw' => $this->dockerComposeRaw,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $destination->server_id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination_class,
|
||||
]);
|
||||
$service->save();
|
||||
|
||||
return redirect()->route('project.service.configuration', [
|
||||
'service_uuid' => $service->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'project_uuid' => $project->uuid,
|
||||
]);
|
||||
$variables = parseEnvFormatToArray($this->envFile);
|
||||
foreach ($variables as $key => $data) {
|
||||
// Extract value and comment from parsed data
|
||||
// Handle both array format ['value' => ..., 'comment' => ...] and plain string values
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
$comment = is_array($data) ? ($data['comment'] ?? null) : null;
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'comment' => $comment,
|
||||
'is_preview' => false,
|
||||
'resourceable_id' => $service->id,
|
||||
'resourceable_type' => $service->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
$service->parse(isNew: true);
|
||||
|
||||
// Apply service-specific application prerequisites
|
||||
applyServiceApplicationPrerequisites($service);
|
||||
|
||||
return $service;
|
||||
});
|
||||
$this->createdServiceUuid = $service->uuid;
|
||||
|
||||
return $this->redirectToService($service->uuid, $project, $environment);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
private function redirectToService(string $serviceUuid, Project $project, Environment $environment): mixed
|
||||
{
|
||||
return redirect()->route('project.service.configuration', [
|
||||
'service_uuid' => $serviceUuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'project_uuid' => $project->uuid,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\New\DockerCompose;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
|
||||
$user = User::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$this->routeParams = [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
];
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => $team]);
|
||||
});
|
||||
|
||||
function newDockerComposeComponent(): mixed
|
||||
{
|
||||
return Livewire::withUrlParams(['destination' => test()->destination->uuid])
|
||||
->test(DockerCompose::class, test()->routeParams)
|
||||
->set('parameters', test()->routeParams)
|
||||
->set('query', ['destination' => test()->destination->uuid]);
|
||||
}
|
||||
|
||||
test('repeated submits create only one service', function () {
|
||||
$component = newDockerComposeComponent()
|
||||
->set('dockerComposeRaw', "services:\n nginx:\n image: nginx:alpine\n");
|
||||
|
||||
$component->call('submit')->assertHasNoErrors()->assertRedirect();
|
||||
$component->call('submit')->assertHasNoErrors()->assertRedirect();
|
||||
$component->call('submit')->assertHasNoErrors()->assertRedirect();
|
||||
|
||||
$services = Service::where('environment_id', $this->environment->id)->get();
|
||||
expect($services)->toHaveCount(1)
|
||||
->and($services->first()->applications()->pluck('name')->all())->toBe(['nginx']);
|
||||
});
|
||||
|
||||
test('compose without services creates no service', function (string $compose) {
|
||||
newDockerComposeComponent()
|
||||
->set('dockerComposeRaw', $compose)
|
||||
->call('submit')
|
||||
->assertNoRedirect();
|
||||
|
||||
expect(Service::count())->toBe(0);
|
||||
})->with([
|
||||
'no services key' => "volumes:\n data: {}\n",
|
||||
'empty services' => "services: {}\n",
|
||||
'services is a list' => "services:\n - nginx\n",
|
||||
]);
|
||||
Reference in New Issue
Block a user