diff --git a/app/config.php b/app/config.php index 8125e652..41e140cd 100644 --- a/app/config.php +++ b/app/config.php @@ -30,6 +30,7 @@ use PhpSchool\PhpWorkshop\Command\PrintCommand; use PhpSchool\PhpWorkshop\Command\RunCommand; use PhpSchool\PhpWorkshop\Command\VerifyCommand; +use PhpSchool\PhpWorkshop\Command\WarmCacheCommand; use PhpSchool\PhpWorkshop\CommandDefinition; use PhpSchool\PhpWorkshop\CommandRouter; use PhpSchool\PhpWorkshop\Event\EventDispatcher; @@ -51,6 +52,7 @@ use PhpSchool\PhpWorkshop\Listener\InitialCodeListener; use PhpSchool\PhpWorkshop\Listener\OutputRunInfoListener; use PhpSchool\PhpWorkshop\Listener\PrepareSolutionListener; +use PhpSchool\PhpWorkshop\ReferencePackages; use PhpSchool\PhpWorkshop\Listener\RealPathListener; use PhpSchool\PhpWorkshop\Listener\RemoveStudentSymlinksListener; use PhpSchool\PhpWorkshop\Listener\SelfCheckListener; @@ -160,6 +162,7 @@ new CommandDefinition('verify', [], VerifyCommand::class), new CommandDefinition('run', [], RunCommand::class), new CommandDefinition('credits', [], CreditsCommand::class), + new CommandDefinition('warm-cache', [], WarmCacheCommand::class), ], 'menu', $c->get(EventDispatcher::class), @@ -221,6 +224,10 @@ }, //commands + WarmCacheCommand::class => function (ContainerInterface $c) { + return new WarmCacheCommand($c->get(ReferencePackages::class)); + }, + MenuCommand::class => function (ContainerInterface $c) { return new MenuCommand($c->get('menu')); }, @@ -272,6 +279,10 @@ ); }, + ReferencePackages::class => function (ContainerInterface $c) { + return new ReferencePackages($c->get(ExerciseRepository::class), $c->get(Sandbox::class)); + }, + //Listeners InitialCodeListener::class => function (ContainerInterface $c) { return new InitialCodeListener($c->get('currentWorkingDirectory'), $c->get(LoggerInterface::class)); diff --git a/src/Command/WarmCacheCommand.php b/src/Command/WarmCacheCommand.php new file mode 100644 index 00000000..c150dfd1 --- /dev/null +++ b/src/Command/WarmCacheCommand.php @@ -0,0 +1,22 @@ +referencePackages->warm(); + + return 0; + } +} diff --git a/src/Listener/PrepareSolutionListener.php b/src/Listener/PrepareSolutionListener.php index 65d3a396..90df8463 100644 --- a/src/Listener/PrepareSolutionListener.php +++ b/src/Listener/PrepareSolutionListener.php @@ -9,9 +9,10 @@ use PhpSchool\PhpWorkshop\Exception\RuntimeException; use PhpSchool\PhpWorkshop\Process\Sandbox; use PhpSchool\PhpWorkshop\Process\ProcessInput; +use PhpSchool\PhpWorkshop\Process\SandboxExecution; /** - * Listener to install composer deps for an exercise solution + * Listener to install composer deps for an exercise solution, offline when ReferencePackages has warmed the cache */ class PrepareSolutionListener { @@ -39,16 +40,33 @@ public function __invoke(ExerciseRunnerEvent $event): void //prepare composer deps //only install if vendor folder not available - if (!file_exists(sprintf('%s/vendor', $event->getContext()->getReferenceExecutionDirectory()))) { - $execution = $this->sandbox->create( - new ProcessInput('composer', ['install', '--no-interaction'], $event->getContext()->getReferenceExecutionDirectory(), [], []), - ); - - try { - $execution->mustRun(); - } catch (\Symfony\Component\Process\Exception\RuntimeException $e) { - throw new RuntimeException('Composer dependencies could not be installed', 0, $e); - } + if (file_exists(sprintf('%s/vendor', $event->getContext()->getReferenceExecutionDirectory()))) { + return; + } + + //offline first, from the cache ReferencePackages warms. Without it, eg. a workshop run from a clone, download + $offline = $this->install($event, ['COMPOSER_DISABLE_NETWORK' => '1']); + $offline->start(); + $offline->wait(); + + if ($offline->isSuccessful()) { + return; + } + + try { + $this->install($event, [])->mustRun(); + } catch (\Symfony\Component\Process\Exception\RuntimeException $e) { + throw new RuntimeException('Composer dependencies could not be installed', 0, $e); } } + + /** + * @param array $env + */ + private function install(ExerciseRunnerEvent $event, array $env): SandboxExecution + { + return $this->sandbox->create( + new ProcessInput('composer', ['install', '--no-interaction'], $event->getContext()->getReferenceExecutionDirectory(), $env, []), + ); + } } diff --git a/src/ReferencePackages.php b/src/ReferencePackages.php new file mode 100644 index 00000000..85c0a00a --- /dev/null +++ b/src/ReferencePackages.php @@ -0,0 +1,53 @@ +filesystem = new Filesystem(); + } + + public function warm(): void + { + foreach ($this->exerciseRepository->findAll() as $exercise) { + if (!$exercise instanceof ProvidesSolution || !$exercise->getSolution()->hasComposerFile()) { + continue; + } + + $directory = $exercise->getSolution()->getBaseDirectory(); + + //an old vendor folder would mean nothing is downloaded, so nothing is cached + $this->filesystem->remove($directory . '/vendor'); + + //keeps a committed composer.lock, or writes one next to the solution. It is copied with it on each verify + $execution = $this->sandbox->create( + new ProcessInput('composer', ['install', '--no-interaction'], $directory, [], []), + ); + + try { + $execution->mustRun(); + } catch (\Symfony\Component\Process\Exception\RuntimeException $e) { + throw new RuntimeException(sprintf('Composer packages for "%s" could not be installed', $exercise->getName()), 0, $e); + } + + //verifies install from the cache, this copy is never used + $this->filesystem->remove($directory . '/vendor'); + } + } +} diff --git a/src/Solution/DirectorySolution.php b/src/Solution/DirectorySolution.php index 5f1425f3..f96576cb 100644 --- a/src/Solution/DirectorySolution.php +++ b/src/Solution/DirectorySolution.php @@ -88,7 +88,7 @@ public function __construct(string $directory, string $entryPoint, array $exclus */ public static function fromDirectory(string $directory, array $exclusions = [], $entryPoint = 'solution.php'): self { - return new self($directory, $entryPoint, array_merge($exclusions, ['composer.lock', 'vendor'])); + return new self($directory, $entryPoint, array_merge($exclusions, ['vendor'])); } /** diff --git a/test/Command/WarmCacheCommandTest.php b/test/Command/WarmCacheCommandTest.php new file mode 100644 index 00000000..de759fa6 --- /dev/null +++ b/test/Command/WarmCacheCommandTest.php @@ -0,0 +1,20 @@ +createMock(ReferencePackages::class); + $referencePackages->expects($this->once())->method('warm'); + + $this->assertSame(0, (new WarmCacheCommand($referencePackages))->__invoke()); + } +} diff --git a/test/ReferencePackagesTest.php b/test/ReferencePackagesTest.php new file mode 100644 index 00000000..f0294ff7 --- /dev/null +++ b/test/ReferencePackagesTest.php @@ -0,0 +1,39 @@ + ['psr/log' => '^3.0']])); + + $withPackages = new CliExerciseImpl(); + $withPackages->setSolution(DirectorySolution::fromDirectory($directory)); + + (new ReferencePackages(new ExerciseRepository([$withPackages]), new HostSandbox()))->warm(); + + $this->assertFileExists($directory . '/composer.lock'); + $this->assertDirectoryDoesNotExist($directory . '/vendor'); + $this->assertContains('composer.lock', array_map( + fn($file) => $file->getRelativePath(), + DirectorySolution::fromDirectory($directory)->getFiles(), + )); + + (new Filesystem())->remove($directory); + } +}