diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 53abc72cce..a0859f0929 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -696,8 +696,7 @@ class ServicesController extends Controller ], ], 422); } - $dockerCompose = base64_decode($request->docker_compose_raw); - $dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + Yaml::parse($dockerComposeRaw); // Validate for command injection BEFORE saving to database try { @@ -1243,8 +1242,7 @@ class ServicesController extends Controller ], ], 422); } - $dockerCompose = base64_decode($request->docker_compose_raw); - $dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + Yaml::parse($dockerComposeRaw); // Validate for command injection BEFORE saving to database try { diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 2f9264730f..5a5385582b 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -38,7 +38,7 @@ class DockerCompose extends Component $this->validate([ 'dockerComposeRaw' => 'required', ]); - $this->dockerComposeRaw = Yaml::dump(Yaml::parse($this->dockerComposeRaw), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + Yaml::parse($this->dockerComposeRaw); // Validate for command injection BEFORE saving to database validateDockerComposeForInjection($this->dockerComposeRaw); diff --git a/app/Livewire/Project/Service/EditCompose.php b/app/Livewire/Project/Service/EditCompose.php index 2feafe41a2..7a95354357 100644 --- a/app/Livewire/Project/Service/EditCompose.php +++ b/app/Livewire/Project/Service/EditCompose.php @@ -91,8 +91,9 @@ class EditCompose extends Component $this->validate([ 'isContainerLabelEscapeEnabled' => 'required', ]); - $this->syncData(true); - $this->service->save(['is_container_label_escape_enabled' => $this->isContainerLabelEscapeEnabled]); + $this->service->refresh()->update([ + 'is_container_label_escape_enabled' => $this->isContainerLabelEscapeEnabled, + ]); $this->dispatch('success', 'Service updated successfully'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php index de79fcd245..0572932dae 100644 --- a/app/Livewire/Project/Service/StackForm.php +++ b/app/Livewire/Project/Service/StackForm.php @@ -149,8 +149,9 @@ class StackForm extends Component { try { $this->authorize('update', $this->service); - $this->syncData(true); - $this->service->save(); + $this->service->refresh()->update([ + 'connect_to_docker_network' => $this->connectToDockerNetwork, + ]); $this->dispatch('success', 'Service settings saved.'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 91411c61f7..31eae9e4c9 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -408,6 +408,89 @@ function addTraefikDockerNetworkLabel(Collection $labels, string $network): Coll return $labels; } +/** + * Remove one-time fields from long-form volume entries without reformatting the rest of the source. + * Fall back to a YAML dump when the source uses a form that the line edit cannot handle safely. + * + * @param array $cleanedYaml + * @param array $fields + */ +function removeComposeVolumeFieldsPreservingComments(string $source, array $cleanedYaml, array $fields): string +{ + $context = []; + $removeIndent = null; + $blockIndent = null; + $result = []; + + foreach (preg_split('/(?<=\n)/', $source) as $line) { + $text = rtrim($line, "\r\n"); + $indent = strspn($text, ' '); + + if ($removeIndent !== null) { + if (trim($text) === '' || $indent > $removeIndent) { + continue; + } + $removeIndent = null; + } + + if ($blockIndent !== null) { + if (trim($text) === '' || $indent > $blockIndent) { + $result[] = $line; + + continue; + } + $blockIndent = null; + } + + if (trim($text) === '' || str_starts_with(ltrim($text), '#')) { + $result[] = $line; + + continue; + } + + while ($context && end($context)['indent'] >= $indent) { + array_pop($context); + } + + $body = substr($text, $indent); + $isListItem = preg_match('/^-\s+/', $body) === 1; + if ($isListItem) { + $context[] = ['indent' => $indent, 'key' => '[]']; + $body = preg_replace('/^-\s+/', '', $body); + } + + if (preg_match('/^([\w.-]+|"[^"]+"|\x27[^\x27]+\x27)\s*:(.*)$/', $body, $matches)) { + $key = trim($matches[1], "\"'"); + $path = array_column($context, 'key'); + if (! $isListItem && count($path) === 4 && $path[0] === 'services' && $path[2] === 'volumes' && $path[3] === '[]' && in_array($key, $fields, true)) { + $removeIndent = $indent; + + continue; + } + + $value = trim($matches[2]); + if ($value === '' || str_starts_with($value, '#')) { + $context[] = ['indent' => $indent, 'key' => $key]; + } elseif (preg_match('/^[|>][+-]?(?:\s+#.*)?$/', $value)) { + $blockIndent = $indent; + } + } + + $result[] = $line; + } + + $candidate = implode('', $result); + try { + if (Yaml::parse($candidate) === $cleanedYaml) { + return $candidate; + } + } catch (Exception) { + // Use the validated parsed result if the line edit is not valid YAML. + } + + return Yaml::dump($cleanedYaml, 10, 2); +} + function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null, ?string $commit = null): Collection { $uuid = data_get($resource, 'uuid'); @@ -1558,6 +1641,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int // Parse the original compose again to create a clean version without Coolify additions try { $originalYaml = Yaml::parse($originalCompose); + $originalYamlBeforeCleanup = $originalYaml; // Remove content, isDirectory, and is_directory from all volume definitions if (isset($originalYaml['services'])) { foreach ($originalYaml['services'] as $serviceName => &$service) { @@ -1572,7 +1656,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } } } - $resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2); + if ($originalYaml !== $originalYamlBeforeCleanup) { + $resource->docker_compose_raw = removeComposeVolumeFieldsPreservingComments($originalCompose, $originalYaml, ['content', 'isDirectory', 'is_directory']); + } } catch (Exception) { // If parsing fails, keep the original docker_compose_raw unchanged } @@ -2797,6 +2883,7 @@ function serviceParser(Service $resource): Collection // Parse the original compose again to create a clean version without Coolify additions try { $originalYaml = Yaml::parse($originalCompose); + $originalYamlBeforeCleanup = $originalYaml; // Remove content, isDirectory, and is_directory from all volume definitions if (isset($originalYaml['services'])) { foreach ($originalYaml['services'] as $serviceName => &$service) { @@ -2811,7 +2898,9 @@ function serviceParser(Service $resource): Collection } } } - $resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2); + if ($originalYaml !== $originalYamlBeforeCleanup) { + $resource->docker_compose_raw = removeComposeVolumeFieldsPreservingComments($originalCompose, $originalYaml, ['content', 'isDirectory', 'is_directory']); + } } catch (Exception $e) { // If parsing fails, keep the original docker_compose_raw unchanged } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 009166e5ab..9eee10d6ec 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1617,7 +1617,7 @@ function sanitizeLogsForExport(string $text): string return remove_iip($text); } -function getTopLevelNetworks(Service|Application $resource) +function getTopLevelNetworks(Service|Application $resource): Collection { if ($resource->getMorphClass() === Service::class) { if ($resource->docker_compose_raw) { @@ -1743,6 +1743,8 @@ function getTopLevelNetworks(Service|Application $resource) return $topLevelNetworks->keys(); } + + return collect(); } function sourceIsLocal(Stringable $source) { @@ -3312,8 +3314,11 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'configs' => $topLevelConfigs->toArray(), 'secrets' => $topLevelSecrets->toArray(), ]; + $originalYaml = $yaml; $yaml = data_forget($yaml, 'services.*.volumes.*.content'); - $resource->docker_compose_raw = Yaml::dump($yaml, 10, 2); + if ($yaml !== $originalYaml) { + $resource->docker_compose_raw = removeComposeVolumeFieldsPreservingComments($resource->docker_compose_raw, $yaml, ['content']); + } $resource->docker_compose = Yaml::dump($finalServices, 10, 2); $resource->save(); @@ -4088,7 +4093,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'configs' => $topLevelConfigs->toArray(), 'secrets' => $topLevelSecrets->toArray(), ]; - $resource->docker_compose_raw = Yaml::dump($yaml, 10, 2); $resource->docker_compose = Yaml::dump($finalServices, 10, 2); data_forget($resource, 'environment_variables'); data_forget($resource, 'environment_variables_preview'); diff --git a/tests/Feature/FileStorageParserStateTest.php b/tests/Feature/FileStorageParserStateTest.php index 8673fe802b..7244c9683b 100644 --- a/tests/Feature/FileStorageParserStateTest.php +++ b/tests/Feature/FileStorageParserStateTest.php @@ -12,6 +12,7 @@ use App\Models\StandaloneDocker; use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus; +use Symfony\Component\Yaml\Yaml; uses(RefreshDatabase::class); @@ -89,6 +90,55 @@ function seedFileVolume($resource, string $baseDir, string $fileName, string $mo ]); } +it('preserves comments in a service source Compose when parsing', function () { + $source = "# Service notes\nservices:\n app:\n # Keep this image note\n image: nginx:latest # pinned by operator\n"; + [$service] = makeComposeService($source); + + serviceParser($service); + + expect($service->fresh()->docker_compose_raw)->toBe($source) + ->and($service->fresh()->docker_compose)->toContain('services:'); +}); + +it('preserves comments in an application source Compose when parsing', function () { + $source = "# Application notes\nservices:\n app:\n # Keep this image note\n image: nginx:latest # pinned by operator\n"; + $application = makeComposeApplication($source); + + applicationParser($application); + + expect($application->fresh()->docker_compose_raw)->toBe($source) + ->and($application->fresh()->docker_compose)->toContain('services:'); +}); + +it('removes one-time volume fields without losing service source comments', function () { + $source = "# Service note\nservices:\n app:\n image: nginx:latest # Image note\n command: |\n volumes:\n - type: bind\n content: keep-this-command\n volumes:\n # Volume note\n - type: bind\n source: ./config.txt\n target: /app/config.txt\n content: |\n first line\n second line\n isDirectory: false\n # After content\n"; + [$service] = makeComposeService($source); + + serviceParser($service); + + expect($service->fresh()->docker_compose_raw)->toBe("# Service note\nservices:\n app:\n image: nginx:latest # Image note\n command: |\n volumes:\n - type: bind\n content: keep-this-command\n volumes:\n # Volume note\n - type: bind\n source: ./config.txt\n target: /app/config.txt\n # After content\n"); +}); + +it('removes one-time volume fields without losing application source comments', function () { + $source = "# Application note\nservices:\n app:\n image: nginx:latest # Image note\n volumes:\n - type: bind\n source: ./config.txt\n target: /app/config.txt\n content: initial\n is_directory: false\n # After content\n"; + $application = makeComposeApplication($source); + + applicationParser($application); + + expect($application->fresh()->docker_compose_raw)->toBe("# Application note\nservices:\n app:\n image: nginx:latest # Image note\n volumes:\n - type: bind\n source: ./config.txt\n target: /app/config.txt\n # After content\n"); +}); + +it('keeps valid Compose when one-time fields use flow syntax', function () { + $source = "# Flow-style volume\nservices:\n app:\n image: nginx:latest\n volumes: [{type: bind, source: ./config.txt, target: /app/config.txt, content: initial}]\n"; + $cleanedYaml = Yaml::parse($source); + unset($cleanedYaml['services']['app']['volumes'][0]['content']); + + $cleanedSource = removeComposeVolumeFieldsPreservingComments($source, $cleanedYaml, ['content']); + + expect(Yaml::parse($cleanedSource))->toBe($cleanedYaml) + ->and($cleanedSource)->not->toContain('content: initial'); +}); + it('preserves existing application file volume content when reparsing compose bind mounts', function () { $application = makeComposeApplication(TWO_FILE_COMPOSE); $baseDir = application_configuration_dir()."/{$application->uuid}"; diff --git a/tests/Feature/Proxy/EmptyServiceComposeNetworksTest.php b/tests/Feature/Proxy/EmptyServiceComposeNetworksTest.php new file mode 100644 index 0000000000..fbd0d37fed --- /dev/null +++ b/tests/Feature/Proxy/EmptyServiceComposeNetworksTest.php @@ -0,0 +1,34 @@ +create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = $server->standaloneDockers()->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + + $service = Service::factory()->create([ + 'docker_compose_raw' => '', + 'docker_compose' => "services:\n app:\n image: nginx:alpine\n", + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'environment_id' => $project->environments()->firstOrFail()->id, + ]); + + expect($service->networks())->toBeInstanceOf(Collection::class)->toBeEmpty(); + + $commands = ensureProxyNetworksExist($server)->implode("\n"); + + expect($commands) + ->toContain("docker network inspect 'coolify'") + ->not->toContain("docker network inspect ''"); +}); diff --git a/tests/Feature/ServiceContainerLabelEscapeApiTest.php b/tests/Feature/ServiceContainerLabelEscapeApiTest.php index 895d776f09..e6b36ebff6 100644 --- a/tests/Feature/ServiceContainerLabelEscapeApiTest.php +++ b/tests/Feature/ServiceContainerLabelEscapeApiTest.php @@ -12,7 +12,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]); + InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0, 'is_api_enabled' => true])); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); @@ -37,7 +37,41 @@ function serviceContainerLabelAuthHeaders($bearerToken): array ]; } +test('service API creation preserves source Compose comments', function () { + $source = "# Operator note\nservices:\n app:\n image: nginx:alpine # Keep this note\n"; + + $response = $this->withHeaders(serviceContainerLabelAuthHeaders($this->bearerToken)) + ->postJson('/api/v1/services', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'docker_compose_raw' => base64_encode($source), + ]); + + $response->assertSuccessful(); + + expect(Service::whereUuid($response->json('uuid'))->firstOrFail()->docker_compose_raw)->toBe($source); +}); + describe('PATCH /api/v1/services/{uuid}', function () { + test('preserves source Compose comments when updating a service', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + $source = "# Operator note\nservices:\n app:\n image: nginx:alpine # Keep this note\n"; + + $response = $this->withHeaders(serviceContainerLabelAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/services/{$service->uuid}", [ + 'docker_compose_raw' => base64_encode($source), + ]); + $response->assertSuccessful(); + + expect($service->fresh()->docker_compose_raw)->toBe($source); + }); + test('accepts is_container_label_escape_enabled field', function () { $service = Service::factory()->create([ 'server_id' => $this->server->id, @@ -54,7 +88,7 @@ describe('PATCH /api/v1/services/{uuid}', function () { $response->assertStatus(200); $service->refresh(); - expect($service->is_container_label_escape_enabled)->toBeFalse(); + expect($service->is_container_label_escape_enabled)->toBeFalsy(); }); test('rejects invalid is_container_label_escape_enabled value', function () { diff --git a/tests/Feature/ServiceInstantSaveScopeTest.php b/tests/Feature/ServiceInstantSaveScopeTest.php new file mode 100644 index 0000000000..ad67bbfa69 --- /dev/null +++ b/tests/Feature/ServiceInstantSaveScopeTest.php @@ -0,0 +1,99 @@ +withoutVite(); + InstanceSettings::create(['id' => 0]); + + $team = Team::factory()->create(); + $this->team = $team; + $user = User::factory()->create(); + $team->members()->attach($user, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = $server->standaloneDockers()->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + + $this->service = Service::factory()->create([ + 'name' => 'original-service', + 'description' => 'Original description', + 'docker_compose_raw' => "services:\n app:\n image: nginx:alpine\n", + 'docker_compose' => "services:\n app:\n image: nginx:alpine\n", + 'connect_to_docker_network' => false, + 'is_container_label_escape_enabled' => false, + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'environment_id' => $project->environments()->firstOrFail()->id, + ]); +}); + +test('network instant save does not persist pending compose or service details', function () { + Livewire::test(StackForm::class, ['service' => $this->service]) + ->set('dockerComposeRaw', 'services: [invalid') + ->set('dockerCompose', 'invalid generated compose') + ->set('name', 'pending-name') + ->set('description', 'Pending description') + ->set('connectToDockerNetwork', true) + ->call('instantSave') + ->assertHasNoErrors() + ->assertDispatched('success'); + + $stored = $this->service->fresh(); + expect($stored->connect_to_docker_network)->toBeTruthy() + ->and($stored->docker_compose_raw)->toBe("services:\n app:\n image: nginx:alpine\n") + ->and($stored->docker_compose)->toBe("services:\n app:\n image: nginx:alpine\n") + ->and($stored->name)->toBe('original-service') + ->and($stored->description)->toBe('Original description'); +}); + +test('label escape instant save does not persist pending compose', function () { + Livewire::test(EditCompose::class, ['serviceId' => $this->service->id]) + ->set('dockerComposeRaw', 'services: [invalid') + ->set('dockerCompose', 'invalid generated compose') + ->set('isContainerLabelEscapeEnabled', true) + ->call('instantSave') + ->assertHasNoErrors() + ->assertDispatched('success'); + + $stored = $this->service->fresh(); + expect($stored->is_container_label_escape_enabled)->toBeTruthy() + ->and($stored->docker_compose_raw)->toBe("services:\n app:\n image: nginx:alpine\n") + ->and($stored->docker_compose)->toBe("services:\n app:\n image: nginx:alpine\n"); +}); + +test('members cannot instant save service switches', function (string $component, string $property) { + $member = User::factory()->create(); + $this->team->members()->attach($member, ['role' => 'member']); + $this->actingAs($member); + + $parameters = $component === StackForm::class + ? ['service' => $this->service] + : ['serviceId' => $this->service->id]; + + Livewire::test($component, $parameters) + ->set($property, true) + ->call('instantSave') + ->assertNotDispatched('success'); + + $stored = $this->service->fresh(); + expect($stored->connect_to_docker_network)->toBeFalsy() + ->and($stored->is_container_label_escape_enabled)->toBeFalsy(); +})->with([ + 'network attachment' => [StackForm::class, 'connectToDockerNetwork'], + 'label escaping' => [EditCompose::class, 'isContainerLabelEscapeEnabled'], +]); diff --git a/tests/Feature/TeamScopedDestinationTest.php b/tests/Feature/TeamScopedDestinationTest.php index be4ee0e4c8..1b43ffd039 100644 --- a/tests/Feature/TeamScopedDestinationTest.php +++ b/tests/Feature/TeamScopedDestinationTest.php @@ -142,6 +142,27 @@ describe('DockerImage destination team scope', function () { }); describe('DockerCompose destination + server_id team scope', function () { + test('service creation preserves source Compose comments', function () { + $source = "# Operator note\nservices:\n app:\n image: nginx:alpine # Keep this note\n"; + $routeParams = [ + 'project_uuid' => $this->projectA->uuid, + 'environment_uuid' => $this->environmentA->uuid, + ]; + + Livewire::withUrlParams([ + 'destination' => $this->destinationA->uuid, + 'server_id' => $this->serverA->id, + ]) + ->test(DockerCompose::class, $routeParams) + ->set('parameters', $routeParams) + ->set('query', ['destination' => $this->destinationA->uuid]) + ->set('dockerComposeRaw', $source) + ->call('submit') + ->assertHasNoErrors(); + + expect(Service::where('environment_id', $this->environmentA->id)->latest('id')->firstOrFail()->docker_compose_raw)->toBe($source); + }); + test('submit with other team destination throws and creates no service', function () { $routeParams = [ 'project_uuid' => $this->projectA->uuid, diff --git a/tests/Unit/DockerComposeRawContentRemovalTest.php b/tests/Unit/DockerComposeRawContentRemovalTest.php index 81255dd59e..cb5be6b6e0 100644 --- a/tests/Unit/DockerComposeRawContentRemovalTest.php +++ b/tests/Unit/DockerComposeRawContentRemovalTest.php @@ -38,7 +38,7 @@ it('ensures applicationParser updates docker_compose_raw from original compose, // Check that docker_compose_raw is set from originalCompose, not cleanedCompose expect($parsersFile) ->toContain('$originalYaml = Yaml::parse($originalCompose);') - ->toContain('$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);') + ->toContain("\$resource->docker_compose_raw = removeComposeVolumeFieldsPreservingComments(\$originalCompose, \$originalYaml, ['content', 'isDirectory', 'is_directory']);") ->not->toContain('$resource->docker_compose_raw = $cleanedCompose;'); }); @@ -53,7 +53,7 @@ it('ensures serviceParser updates docker_compose_raw from original compose, not // Check that docker_compose_raw is set from originalCompose within serviceParser expect($serviceParserContent) ->toContain('$originalYaml = Yaml::parse($originalCompose);') - ->toContain('$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);') + ->toContain("\$resource->docker_compose_raw = removeComposeVolumeFieldsPreservingComments(\$originalCompose, \$originalYaml, ['content', 'isDirectory', 'is_directory']);") ->not->toContain('$resource->docker_compose_raw = $cleanedCompose;'); }); @@ -98,3 +98,13 @@ it('ensures docker_compose_raw update is wrapped in try-catch for error handling ->toContain('} catch (Exception) {') ->toContain('// If parsing fails, keep the original docker_compose_raw unchanged'); }); + +it('does not reformat legacy raw Compose when no content is removed', function () { + $source = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php'); + $parser = substr($source, strpos($source, 'function parseDockerComposeFile('), strpos($source, 'function generate_fluentd_configuration(') - strpos($source, 'function parseDockerComposeFile(')); + + expect($parser) + ->toContain('if ($yaml !== $originalYaml) {') + ->toContain("data_forget(\$yaml, 'services.*.volumes.*.content')"); + expect(substr_count($parser, "\$resource->docker_compose_raw = removeComposeVolumeFieldsPreservingComments(\$resource->docker_compose_raw, \$yaml, ['content']);"))->toBe(1); +});