Skip to content

Commit 5a4f5a9

Browse files
bug #829 Interactively asking if the user wants Docker support from Flex (weaverryan)
This PR was merged into the 1.x branch. Discussion ---------- Interactively asking if the user wants Docker support from Flex Hi! After #821 and #788, Docker support is enabled by default. This PR changes that behavior to interactively ask the user want they want: <img width="1134" alt="Screen Shot 2021-11-18 at 11 36 55 AM" src="https://user-images.githubusercontent.com/121003/142457428-a23ea361-6359-481f-9941-5083146e9b3a.png"> This allows users to easily opt out. But it also allows users to immediately opt IN and get the Docker config. I've tested this locally with every combination I could think of :) Cheers! Commits ------- 59c89c1 Interactively asking if the user wants Docker support from Flex
2 parents 375e01d + 59c89c1 commit 5a4f5a9

File tree

6 files changed

+173
-25
lines changed

6 files changed

+173
-25
lines changed

src/Configurator/DockerComposeConfigurator.php

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
namespace Symfony\Flex\Configurator;
1313

1414
use Composer\Composer;
15+
use Composer\Factory;
1516
use Composer\IO\IOInterface;
17+
use Composer\Json\JsonFile;
18+
use Composer\Json\JsonManipulator;
1619
use Symfony\Component\Filesystem\Filesystem;
1720
use Symfony\Flex\Lock;
1821
use Symfony\Flex\Options;
@@ -27,6 +30,8 @@ class DockerComposeConfigurator extends AbstractConfigurator
2730
{
2831
private $filesystem;
2932

33+
public static $configureDockerRecipes = null;
34+
3035
public function __construct(Composer $composer, IOInterface $io, Options $options)
3136
{
3237
parent::__construct($composer, $io, $options);
@@ -36,8 +41,7 @@ public function __construct(Composer $composer, IOInterface $io, Options $option
3641

3742
public function configure(Recipe $recipe, $config, Lock $lock, array $options = [])
3843
{
39-
$installDocker = $this->composer->getPackage()->getExtra()['symfony']['docker'] ?? true;
40-
if (!$installDocker) {
44+
if (!self::shouldConfigureDockerRecipe($this->composer, $this->io, $recipe)) {
4145
return;
4246
}
4347

@@ -128,6 +132,72 @@ public function unconfigure(Recipe $recipe, $config, Lock $lock)
128132
$this->write('Docker Compose definitions have been modified. Please run "docker-compose up" again to apply the changes.');
129133
}
130134

135+
public static function shouldConfigureDockerRecipe(Composer $composer, IOInterface $io, Recipe $recipe): bool
136+
{
137+
if (null !== self::$configureDockerRecipes) {
138+
return self::$configureDockerRecipes;
139+
}
140+
141+
if (null !== $dockerPreference = $composer->getPackage()->getExtra()['symfony']['docker'] ?? null) {
142+
self::$configureDockerRecipes = $dockerPreference;
143+
144+
return self::$configureDockerRecipes;
145+
}
146+
147+
if ('install' !== $recipe->getJob()) {
148+
// default to not configuring
149+
return false;
150+
}
151+
152+
$warning = $io->isInteractive() ? 'WARNING' : 'IGNORING';
153+
$io->writeError(sprintf(' - <warning> %s </> %s', $warning, $recipe->getFormattedOrigin()));
154+
$question = ' The recipe for this package contains some Docker configuration.
155+
156+
This may create/update <comment>docker-compose.yml</comment> or update <comment>Dockerfile</comment> (if it exists).
157+
158+
Do you want to include Docker configuration from recipes?
159+
[<comment>y</>] Yes
160+
[<comment>n</>] No
161+
[<comment>p</>] Yes permanently, never ask again for this project
162+
[<comment>x</>] No permanently, never ask again for this project
163+
(defaults to <comment>y</>): ';
164+
$answer = $io->askAndValidate(
165+
$question,
166+
function ($value) {
167+
if (null === $value) {
168+
return 'y';
169+
}
170+
$value = strtolower($value[0]);
171+
if (!\in_array($value, ['y', 'n', 'p', 'x'], true)) {
172+
throw new \InvalidArgumentException('Invalid choice.');
173+
}
174+
175+
return $value;
176+
},
177+
null,
178+
'y'
179+
);
180+
if ('n' === $answer) {
181+
self::$configureDockerRecipes = false;
182+
183+
return self::$configureDockerRecipes;
184+
}
185+
if ('y' === $answer) {
186+
self::$configureDockerRecipes = true;
187+
188+
return self::$configureDockerRecipes;
189+
}
190+
191+
// yes or no permanently
192+
self::$configureDockerRecipes = 'p' === $answer;
193+
$json = new JsonFile(Factory::getComposerFile());
194+
$manipulator = new JsonManipulator(file_get_contents($json->getPath()));
195+
$manipulator->addSubNode('extra', 'symfony.docker', self::$configureDockerRecipes);
196+
file_put_contents($json->getPath(), $manipulator->getContents());
197+
198+
return self::$configureDockerRecipes;
199+
}
200+
131201
/**
132202
* Normalizes the config and return the name of the main Docker Compose file if applicable.
133203
*/

src/Configurator/DockerfileConfigurator.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,7 @@ class DockerfileConfigurator extends AbstractConfigurator
2323
{
2424
public function configure(Recipe $recipe, $config, Lock $lock, array $options = [])
2525
{
26-
$installDocker = $this->composer->getPackage()->getExtra()['symfony']['docker'] ?? true;
27-
if (!$installDocker) {
26+
if (!DockerComposeConfigurator::shouldConfigureDockerRecipe($this->composer, $this->io, $recipe)) {
2827
return;
2928
}
3029

src/Flex.php

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,6 @@ class Flex implements PluginInterface, EventSubscriberInterface
9797
'remove' => false,
9898
'unpack' => true,
9999
];
100-
private $shouldUpdateComposerLock = false;
101100
private $filter;
102101

103102
public function activate(Composer $composer, IOInterface $io)
@@ -427,10 +426,11 @@ public function install(Event $event)
427426
$this->io->writeError(sprintf('<info>Symfony operations: %d recipe%s (%s)</>', \count($recipes), \count($recipes) > 1 ? 's' : '', $this->downloader->getSessionId()));
428427
$installContribs = $this->composer->getPackage()->getExtra()['symfony']['allow-contrib'] ?? false;
429428
$manifest = null;
429+
$originalComposerJsonHash = $this->getComposerJsonHash();
430430
foreach ($recipes as $recipe) {
431431
if ('install' === $recipe->getJob() && !$installContribs && $recipe->isContrib()) {
432432
$warning = $this->io->isInteractive() ? 'WARNING' : 'IGNORING';
433-
$this->io->writeError(sprintf(' - <warning> %s </> %s', $warning, $this->formatOrigin($recipe->getOrigin())));
433+
$this->io->writeError(sprintf(' - <warning> %s </> %s', $warning, $recipe->getFormattedOrigin()));
434434
$question = sprintf(' The recipe for this package comes from the "contrib" repository, which is open to community contributions.
435435
Review the recipe at %s
436436
@@ -468,13 +468,12 @@ function ($value) {
468468
$manipulator = new JsonManipulator(file_get_contents($json->getPath()));
469469
$manipulator->addSubNode('extra', 'symfony.allow-contrib', true);
470470
file_put_contents($json->getPath(), $manipulator->getContents());
471-
$this->shouldUpdateComposerLock = true;
472471
}
473472
}
474473

475474
switch ($recipe->getJob()) {
476475
case 'install':
477-
$this->io->writeError(sprintf(' - Configuring %s', $this->formatOrigin($recipe->getOrigin())));
476+
$this->io->writeError(sprintf(' - Configuring %s', $recipe->getFormattedOrigin()));
478477
$this->configurator->install($recipe, $this->lock, [
479478
'force' => $event instanceof UpdateEvent && $event->force(),
480479
]);
@@ -491,7 +490,7 @@ function ($value) {
491490
case 'update':
492491
break;
493492
case 'uninstall':
494-
$this->io->writeError(sprintf(' - Unconfiguring %s', $this->formatOrigin($recipe->getOrigin())));
493+
$this->io->writeError(sprintf(' - Unconfiguring %s', $recipe->getFormattedOrigin()));
495494
$this->configurator->unconfigure($recipe, $this->lock);
496495
break;
497496
}
@@ -512,7 +511,7 @@ function ($value) {
512511
$this->synchronizePackageJson($rootDir);
513512
$this->lock->write();
514513

515-
if ($this->shouldUpdateComposerLock) {
514+
if ($this->getComposerJsonHash() !== $originalComposerJsonHash) {
516515
$this->updateComposerLock();
517516
}
518517
}
@@ -808,16 +807,6 @@ private function initOptions(): Options
808807
return new Options($options, $this->io);
809808
}
810809

811-
private function formatOrigin(string $origin): string
812-
{
813-
// symfony/translation:3.3@github.com/symfony/recipes:branch
814-
if (!preg_match('/^([^:]++):([^@]++)@(.+)$/', $origin, $matches)) {
815-
return $origin;
816-
}
817-
818-
return sprintf('<info>%s</> (<comment>>=%s</>): From %s', $matches[1], $matches[2], 'auto-generated recipe' === $matches[3] ? '<comment>'.$matches[3].'</>' : $matches[3]);
819-
}
820-
821810
private function shouldRecordOperation(PackageEvent $event): bool
822811
{
823812
$operation = $event->getOperation();
@@ -981,4 +970,9 @@ public static function getSubscribedEvents(): array
981970

982971
return $events;
983972
}
973+
974+
private function getComposerJsonHash(): string
975+
{
976+
return md5_file(Factory::getComposerFile());
977+
}
984978
}

src/Recipe.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,20 @@ public function getOrigin(): string
6767
return $this->data['origin'] ?? '';
6868
}
6969

70+
public function getFormattedOrigin(): string
71+
{
72+
if (!$this->getOrigin()) {
73+
return '';
74+
}
75+
76+
// symfony/translation:3.3@github.com/symfony/recipes:branch
77+
if (!preg_match('/^([^:]++):([^@]++)@(.+)$/', $this->getOrigin(), $matches)) {
78+
return $this->getOrigin();
79+
}
80+
81+
return sprintf('<info>%s</> (<comment>>=%s</>): From %s', $matches[1], $matches[2], 'auto-generated recipe' === $matches[3] ? '<comment>'.$matches[3].'</>' : $matches[3]);
82+
}
83+
7084
public function getURL(): string
7185
{
7286
if (!$this->data['origin']) {

tests/Configurator/DockerComposeConfiguratorTest.php

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,29 @@ class DockerComposeConfiguratorTest extends TestCase
107107
/** @var Composer|\PHPUnit\Framework\MockObject\MockObject */
108108
private $composer;
109109

110+
/** @var IOInterface|\PHPUnit\Framework\MockObject\MockObject */
111+
private $io;
112+
110113
/** @var DockerComposeConfigurator */
111114
private $configurator;
112115

116+
/** @var Package */
117+
private $package;
118+
119+
private $originalEnvComposer;
120+
113121
protected function setUp(): void
114122
{
115123
@mkdir(FLEX_TEST_DIR);
116124

125+
$this->originalEnvComposer = $_SERVER['COMPOSER'] ?? null;
126+
$_SERVER['COMPOSER'] = FLEX_TEST_DIR.'/composer.json';
127+
// composer 2.1 and lower support
128+
putenv('COMPOSER='.FLEX_TEST_DIR.'/composer.json');
129+
130+
// reset state
131+
DockerComposeConfigurator::$configureDockerRecipes = null;
132+
117133
// Recipe
118134
$this->recipeDb = $this->getMockBuilder(Recipe::class)->disableOriginalConstructor()->getMock();
119135
$this->recipeDb->method('getName')->willReturn('doctrine/doctrine-bundle');
@@ -122,27 +138,37 @@ protected function setUp(): void
122138
$this->lock = $this->getMockBuilder(Lock::class)->disableOriginalConstructor()->getMock();
123139

124140
// Configurator
125-
$package = new Package('dummy/dummy', '1.0.0', '1.0.0');
126-
$package->setExtra(['symfony' => ['docker' => true]]);
141+
$this->package = new Package('dummy/dummy', '1.0.0', '1.0.0');
142+
$this->package->setExtra(['symfony' => ['docker' => true]]);
127143

128144
$this->composer = $this->getMockBuilder(Composer::class)->getMock();
129-
$this->composer->method('getPackage')->willReturn($package);
145+
$this->composer->method('getPackage')->willReturn($this->package);
146+
147+
$this->io = $this->getMockBuilder(IOInterface::class)->getMock();
130148

131149
$this->configurator = new DockerComposeConfigurator(
132150
$this->composer,
133-
$this->getMockBuilder(IOInterface::class)->getMock(),
151+
$this->io,
134152
new Options(['config-dir' => 'config', 'root-dir' => FLEX_TEST_DIR])
135153
);
136154
}
137155

138156
protected function tearDown(): void
139157
{
140158
unset($_SERVER['COMPOSE_FILE']);
159+
if ($this->originalEnvComposer) {
160+
$_SERVER['COMPOSER'] = $this->originalEnvComposer;
161+
} else {
162+
unset($_SERVER['COMPOSER']);
163+
}
164+
// composer 2.1 and lower support
165+
putenv('COMPOSER='.$this->originalEnvComposer);
141166

142167
(new Filesystem())->remove([
143168
FLEX_TEST_DIR.'/docker-compose.yml',
144169
FLEX_TEST_DIR.'/docker-compose.override.yml',
145170
FLEX_TEST_DIR.'/docker-compose.yaml',
171+
FLEX_TEST_DIR.'/composer.json',
146172
FLEX_TEST_DIR.'/child/docker-compose.override.yaml',
147173
FLEX_TEST_DIR.'/child',
148174
]);
@@ -184,6 +210,52 @@ public function testConfigure()
184210
$this->assertEquals(self::ORIGINAL_CONTENT, file_get_contents($dockerComposeFile));
185211
}
186212

213+
public function testNotConfiguredIfConfigSet()
214+
{
215+
$this->package->setExtra(['symfony' => ['docker' => false]]);
216+
$this->configurator->configure($this->recipeDb, self::CONFIG_DB, $this->lock);
217+
218+
$this->assertFileDoesNotExist(FLEX_TEST_DIR.'/docker-compose.yml');
219+
}
220+
221+
/**
222+
* @dataProvider getInteractiveDockerPreferenceTests
223+
*/
224+
public function testPreferenceAskedInteractively(string $userInput, bool $expectedIsConfigured, bool $expectedIsComposerJsonUpdated)
225+
{
226+
$composerJsonPath = FLEX_TEST_DIR.'/composer.json';
227+
file_put_contents($composerJsonPath, json_encode(['name' => 'test/app']));
228+
229+
$this->package->setExtra(['symfony' => []]);
230+
$this->recipeDb->method('getJob')->willReturn('install');
231+
$this->io->method('isInteractive')->willReturn(true);
232+
$this->io->expects($this->once())->method('askAndValidate')->willReturn($userInput);
233+
234+
$this->configurator->configure($this->recipeDb, self::CONFIG_DB, $this->lock);
235+
236+
if ($expectedIsConfigured) {
237+
$this->assertFileExists(FLEX_TEST_DIR.'/docker-compose.yml');
238+
} else {
239+
$this->assertFileDoesNotExist(FLEX_TEST_DIR.'/docker-compose.yml');
240+
}
241+
242+
$composerJsonData = json_decode(file_get_contents($composerJsonPath), true);
243+
if ($expectedIsComposerJsonUpdated) {
244+
$this->assertArrayHasKey('extra', $composerJsonData);
245+
$this->assertSame($expectedIsConfigured, $composerJsonData['extra']['symfony']['docker']);
246+
} else {
247+
$this->assertArrayNotHasKey('extra', $composerJsonData);
248+
}
249+
}
250+
251+
public function getInteractiveDockerPreferenceTests()
252+
{
253+
yield 'yes_once' => ['y', true, false];
254+
yield 'no_once' => ['n', false, false];
255+
yield 'yes_forever' => ['p', true, true];
256+
yield 'no_forever' => ['x', false, true];
257+
}
258+
187259
public function testConfigureFileWithExistingVolumes()
188260
{
189261
$originalContent = self::ORIGINAL_CONTENT.<<<'YAML'

tests/UnpackerTest.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ public function testDoNotDuplicateEntry(): void
3939
@unlink($composerJsonPath);
4040
file_put_contents($composerJsonPath, '{}');
4141

42-
$originalEnvComposer = getenv('COMPOSER');
4342
$originalEnvComposer = $_SERVER['COMPOSER'];
4443
$_SERVER['COMPOSER'] = $composerJsonPath;
4544
// composer 2.1 and lower support

0 commit comments

Comments
 (0)