Skip to content

Commit a219fec

Browse files
committed
[DockerComposeConfigurator] Fix duplicate top-level key on reconfigure
`composer recipes:install --reset --force` re-runs configure on an already-marked compose.yaml. For any recipe that updates a non-last top-level section (e.g. a recipe contributing only `services:` on a file that also has `volumes:`), this produced a duplicate `volumes:` line, yielding invalid YAML. Two interacting bugs: 1. The parser accumulated the transitioning key line under the OLD `$node` (it's added to `$nodesLines[$node][$i]` before the node-transition logic runs). When that section's content was later spliced back in, the next section's header traveled with the replacement, while the original header in `$lines` wasn't removed by the splice range. 2. The position-adjustment math after `array_splice` used `$at - $length - 1` for `startAt` and `$at - $length` for `endAt`. Since splice removes `$length` items and inserts 1 (the replacement string cast to a one-element array), the correct shift is `-($length - 1)`. The two bugs masked each other in normal flows, which is why existing tests pass on either side individually but the regression only appears with both fixed together. Fixed together, with a regression test that fails on `2.x` without the source change.
1 parent 2929328 commit a219fec

2 files changed

Lines changed: 56 additions & 17 deletions

File tree

src/Configurator/DockerComposeConfigurator.php

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ public function __construct(Composer $composer, IOInterface $io, Options $option
4040
$this->filesystem = new Filesystem();
4141
}
4242

43-
public function configure(Recipe $recipe, $config, Lock $lock, array $options = [])
43+
public function configure(Recipe $recipe, $config, Lock $lock, array $options = []): void
4444
{
4545
if (!self::shouldConfigureDockerRecipe($this->composer, $this->io, $recipe)) {
4646
return;
@@ -51,7 +51,7 @@ public function configure(Recipe $recipe, $config, Lock $lock, array $options =
5151
$this->write('Docker Compose definitions have been modified. Please run "docker compose up --build" again to apply the changes.');
5252
}
5353

54-
public function unconfigure(Recipe $recipe, $config, Lock $lock)
54+
public function unconfigure(Recipe $recipe, $config, Lock $lock): void
5555
{
5656
$rootDir = $this->options->get('root-dir');
5757
foreach ($this->normalizeConfig($config) as $file => $extra) {
@@ -276,6 +276,13 @@ private function configureDockerCompose(Recipe $recipe, array $config, bool $upd
276276

277277
// Keep end in memory (check break line on previous line)
278278
$endAt[$node] = !$i || '' !== trim($lines[$i - 1]) ? $i : $i - 1;
279+
// Prune nodesLines[old node] to match the range the splice will replace.
280+
if (isset($nodesLines[$node][$i])) {
281+
unset($nodesLines[$node][$i]);
282+
if ('' === trim($lines[$i - 1])) {
283+
unset($nodesLines[$node][$i - 1]);
284+
}
285+
}
279286
$node = $matches[1];
280287
if (!isset($nodesLines[$node])) {
281288
$nodesLines[$node] = [];
@@ -303,14 +310,15 @@ private function configureDockerCompose(Recipe $recipe, array $config, bool $upd
303310
array_splice($lines, $startAt[$key], $length, ltrim($updatedContents, "\n"));
304311

305312
// reset any start/end positions after this to the new positions
313+
$shift = $length - 1;
306314
foreach ($startAt as $sectionKey => $at) {
307315
if ($at > $originalEndAt) {
308-
$startAt[$sectionKey] = $at - $length - 1;
316+
$startAt[$sectionKey] = $at - $shift;
309317
}
310318
}
311319
foreach ($endAt as $sectionKey => $at) {
312320
if ($at > $originalEndAt) {
313-
$endAt[$sectionKey] = $at - $length;
321+
$endAt[$sectionKey] = $at - $shift;
314322
}
315323
}
316324

@@ -386,7 +394,7 @@ private static function askDockerSupport(IOInterface $io, Recipe $recipe): strin
386394

387395
return $io->askAndValidate(
388396
$question,
389-
function ($value) {
397+
static function ($value) {
390398
if (null === $value) {
391399
return 'y';
392400
}

tests/Configurator/DockerComposeConfiguratorTest.php

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ public static function dockerComposerFileProvider(): iterable
188188
/**
189189
* @dataProvider dockerComposerFileProvider
190190
*/
191-
public function testConfigure(string $fileName)
191+
public function testConfigure(string $fileName): void
192192
{
193193
$dockerComposeFile = FLEX_TEST_DIR."/$fileName";
194194
file_put_contents($dockerComposeFile, self::ORIGINAL_CONTENT);
@@ -224,7 +224,38 @@ public function testConfigure(string $fileName)
224224
$this->assertEquals(self::ORIGINAL_CONTENT, file_get_contents($dockerComposeFile));
225225
}
226226

227-
public function testNotConfiguredIfConfigSet()
227+
/**
228+
* Re-configuring a recipe that only touches a non-last top-level key (e.g. `services`)
229+
* on a file that also has a `volumes:` key must not duplicate the `volumes:` line.
230+
* This is what happens during `composer recipes:install --reset --force`.
231+
*/
232+
public function testReconfigureDoesNotDuplicateLaterTopLevelKey(): void
233+
{
234+
// Recipe that contributes BOTH services and volumes (sets up the file shape).
235+
$this->configurator->configure($this->recipeDb, self::CONFIG_DB, $this->lock);
236+
237+
// Recipe that only contributes `services` (e.g. shopware/docker-dev, symfony/mailer).
238+
$servicesOnlyRecipe = $this->getMockBuilder(Recipe::class)->disableOriginalConstructor()->getMock();
239+
$servicesOnlyRecipe->method('getName')->willReturn('acme/services-only');
240+
$servicesOnlyConfig = [
241+
'services' => [
242+
'cache:',
243+
' image: redis:alpine',
244+
],
245+
];
246+
247+
$this->configurator->configure($servicesOnlyRecipe, $servicesOnlyConfig, $this->lock);
248+
$afterFirstConfigure = file_get_contents(FLEX_TEST_DIR.'/compose.yaml');
249+
250+
// Simulate `recipes:install --reset --force` re-running configure on the marked file.
251+
$this->configurator->configure($servicesOnlyRecipe, $servicesOnlyConfig, $this->lock, ['force' => true]);
252+
$afterSecondConfigure = file_get_contents(FLEX_TEST_DIR.'/compose.yaml');
253+
254+
$this->assertSame(1, substr_count($afterSecondConfigure, "\nvolumes:\n"), 'compose.yaml must contain exactly one top-level "volumes:" key');
255+
$this->assertSame($afterFirstConfigure, $afterSecondConfigure, 'configure must be idempotent when re-run');
256+
}
257+
258+
public function testNotConfiguredIfConfigSet(): void
228259
{
229260
$this->package->setExtra(['symfony' => ['docker' => false]]);
230261
$this->configurator->configure($this->recipeDb, self::CONFIG_DB, $this->lock);
@@ -235,7 +266,7 @@ public function testNotConfiguredIfConfigSet()
235266
/**
236267
* @dataProvider getInteractiveDockerPreferenceTests
237268
*/
238-
public function testPreferenceAskedInteractively(string $userInput, bool $expectedIsConfigured, bool $expectedIsComposerJsonUpdated)
269+
public function testPreferenceAskedInteractively(string $userInput, bool $expectedIsConfigured, bool $expectedIsComposerJsonUpdated): void
239270
{
240271
$composerJsonPath = FLEX_TEST_DIR.'/composer.json';
241272
file_put_contents($composerJsonPath, json_encode(['name' => 'test/app']));
@@ -270,7 +301,7 @@ public function getInteractiveDockerPreferenceTests()
270301
yield 'no_forever' => ['x', false, true];
271302
}
272303

273-
public function testEnvVarUsedForDockerConfirmation()
304+
public function testEnvVarUsedForDockerConfirmation(): void
274305
{
275306
$composerJsonPath = FLEX_TEST_DIR.'/composer.json';
276307
file_put_contents($composerJsonPath, json_encode(['name' => 'test/app']));
@@ -290,7 +321,7 @@ public function testEnvVarUsedForDockerConfirmation()
290321
$this->assertTrue($composerJsonData['extra']['symfony']['docker']);
291322
}
292323

293-
public function testConfigureFileWithExistingVolumes()
324+
public function testConfigureFileWithExistingVolumes(): void
294325
{
295326
$originalContent = self::ORIGINAL_CONTENT.<<<'YAML'
296327
@@ -341,7 +372,7 @@ public function testConfigureFileWithExistingVolumes()
341372
);
342373
}
343374

344-
public function testConfigureFileWithExistingMarks()
375+
public function testConfigureFileWithExistingMarks(): void
345376
{
346377
$originalContent = self::ORIGINAL_CONTENT.<<<'YAML'
347378
@@ -435,7 +466,7 @@ public function testConfigureFileWithExistingMarks()
435466
$this->assertEquals(self::ORIGINAL_CONTENT, file_get_contents($dockerComposeFile));
436467
}
437468

438-
public function testUnconfigureFileWithManyMarks()
469+
public function testUnconfigureFileWithManyMarks(): void
439470
{
440471
$originalContent = self::ORIGINAL_CONTENT.<<<'YAML'
441472
@@ -507,7 +538,7 @@ public function testUnconfigureFileWithManyMarks()
507538
$this->assertStringEqualsFile($dockerComposeFile, $contentWithoutDoctrine);
508539
}
509540

510-
public function testConfigureMultipleFiles()
541+
public function testConfigureMultipleFiles(): void
511542
{
512543
$dockerComposeFile = FLEX_TEST_DIR.'/docker-compose.yml';
513544
file_put_contents($dockerComposeFile, self::ORIGINAL_CONTENT);
@@ -548,7 +579,7 @@ public function testConfigureMultipleFiles()
548579
$this->assertStringEqualsFile($dockerComposeOverrideFile, self::ORIGINAL_CONTENT);
549580
}
550581

551-
public function testConfigureEnvVar()
582+
public function testConfigureEnvVar(): void
552583
{
553584
@mkdir(FLEX_TEST_DIR.'/child/');
554585
$dockerComposeFile = FLEX_TEST_DIR.'/child/docker-compose.yml';
@@ -593,7 +624,7 @@ public function testConfigureEnvVar()
593624
$this->assertStringEqualsFile($dockerComposeOverrideFile, self::ORIGINAL_CONTENT);
594625
}
595626

596-
public function testConfigureFileInParentDir()
627+
public function testConfigureFileInParentDir(): void
597628
{
598629
$this->configurator = new DockerComposeConfigurator(
599630
$this->composer,
@@ -636,7 +667,7 @@ public function testConfigureFileInParentDir()
636667
$this->assertEquals(self::ORIGINAL_CONTENT, file_get_contents($dockerComposeFile));
637668
}
638669

639-
public function testConfigureWithoutExistingDockerComposeFiles()
670+
public function testConfigureWithoutExistingDockerComposeFiles(): void
640671
{
641672
$dockerComposeFile = FLEX_TEST_DIR.'/compose.yaml';
642673

@@ -672,7 +703,7 @@ public function testConfigureWithoutExistingDockerComposeFiles()
672703
$this->assertEquals('', file_get_contents($dockerComposeFile));
673704
}
674705

675-
public function testUpdate()
706+
public function testUpdate(): void
676707
{
677708
$recipe = $this->createMock(Recipe::class);
678709
$recipe->method('getName')

0 commit comments

Comments
 (0)