From e3ee4a99c5d147cfa671b618aa0751b4969c8c94 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:14:46 +0200 Subject: [PATCH] fix(compose): create one service on repeated submits and reject compose without services Closes #11078 Co-Authored-By: Claude Opus 5.5 --- app/Livewire/Project/New/DockerCompose.php | 89 ++++++++++++------- .../NewDockerComposeServiceSubmitTest.php | 71 +++++++++++++++ 2 files changed, 127 insertions(+), 33 deletions(-) create mode 100644 tests/Feature/NewDockerComposeServiceSubmitTest.php diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 80f9f481b4..d004c3f963 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -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, + ]); + } } diff --git a/tests/Feature/NewDockerComposeServiceSubmitTest.php b/tests/Feature/NewDockerComposeServiceSubmitTest.php new file mode 100644 index 0000000000..8e6fa57525 --- /dev/null +++ b/tests/Feature/NewDockerComposeServiceSubmitTest.php @@ -0,0 +1,71 @@ + 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", +]);