From 65e59e740ce1109c84480b51b4c602c1a47aa261 Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Tue, 5 May 2026 10:05:48 +0200 Subject: [PATCH] feat: synchronize installer scripts from git --- .../Admin/ScriptMappingController.php | 21 +++ src/Exception/GitSyncFailedException.php | 9 + src/Service/GitSynchronizer.php | 175 ++++++++++++++++++ templates/admin/dashboard.html.twig | 7 + .../Admin/ScriptMappingControllerTest.php | 69 +++++++ tests/Service/GitSynchronizerTest.php | 126 +++++++++++++ 6 files changed, 407 insertions(+) create mode 100644 src/Exception/GitSyncFailedException.php create mode 100644 src/Service/GitSynchronizer.php create mode 100644 tests/Service/GitSynchronizerTest.php diff --git a/src/Controller/Admin/ScriptMappingController.php b/src/Controller/Admin/ScriptMappingController.php index 62503f5..931ecb7 100644 --- a/src/Controller/Admin/ScriptMappingController.php +++ b/src/Controller/Admin/ScriptMappingController.php @@ -3,7 +3,9 @@ namespace App\Controller\Admin; use App\Entity\ScriptMapping; +use App\Exception\GitSyncFailedException; use App\Form\ScriptMappingType; +use App\Service\GitSynchronizer; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -83,4 +85,23 @@ final class ScriptMappingController extends AbstractController return $this->redirectToRoute('admin_dashboard'); } + + #[Route('/{id}/sync', name: 'admin_mapping_sync', methods: ['POST'])] + public function sync(Request $request, ScriptMapping $mapping, GitSynchronizer $synchronizer): Response + { + if (!$this->isCsrfTokenValid('sync_mapping_'.$mapping->getId(), (string) $request->request->get('_token'))) { + throw $this->createAccessDeniedException(); + } + + try { + $synchronizer->sync($mapping); + $this->addFlash('success', 'Mapping synchronisé.'); + } catch (GitSyncFailedException) { + $this->addFlash('error', 'La synchronisation du mapping a échoué.'); + } + + $this->entityManager->flush(); + + return $this->redirectToRoute('admin_dashboard'); + } } diff --git a/src/Exception/GitSyncFailedException.php b/src/Exception/GitSyncFailedException.php new file mode 100644 index 0000000..bf5297e --- /dev/null +++ b/src/Exception/GitSyncFailedException.php @@ -0,0 +1,9 @@ +getId() === null) { + throw new LogicException('Cannot synchronize a mapping before it is persisted.'); + } + + try { + $repositoryDir = $this->checkoutRepository($mapping); + $cacheKey = $this->cachePathResolver->scriptCacheKeyForMapping($mapping->getId()); + $this->copyRepositoryFileToCache($mapping, $repositoryDir, $cacheKey); + + $mapping->markSyncSucceeded($cacheKey); + } catch (Throwable $exception) { + $message = $this->sanitizeError($exception->getMessage(), $mapping->getAccessToken()); + $mapping->markSyncFailed($message); + + throw new GitSyncFailedException('Git synchronization failed.', 0, $exception); + } + } + + private function checkoutRepository(ScriptMapping $mapping): string + { + $repositoryDir = $this->repositoryDir($mapping); + $this->ensureDirectory(dirname($repositoryDir)); + + $env = $this->gitEnvironment($mapping, $repositoryDir); + + if (!is_dir($repositoryDir.'/.git')) { + $this->removeDirectory($repositoryDir); + $this->runGit(['git', 'clone', '--no-checkout', $mapping->getRepositoryUrl(), $repositoryDir], null, $env); + } else { + $this->runGit(['git', '-C', $repositoryDir, 'remote', 'set-url', 'origin', $mapping->getRepositoryUrl()], null, $env); + } + + $this->runGit(['git', '-C', $repositoryDir, 'fetch', '--prune', '--tags', 'origin', $mapping->getGitRef()], null, $env); + $this->runGit(['git', '-C', $repositoryDir, 'checkout', '--force', 'FETCH_HEAD'], null, $env); + + return $repositoryDir; + } + + private function copyRepositoryFileToCache(ScriptMapping $mapping, string $repositoryDir, string $cacheKey): void + { + $repositoryBase = realpath($repositoryDir); + if ($repositoryBase === false) { + throw new RuntimeException(sprintf('Repository directory "%s" does not exist.', $repositoryDir)); + } + + $sourcePath = $repositoryDir.'/'.$mapping->getRepositoryFilePath(); + $sourceRealPath = realpath($sourcePath); + if ($sourceRealPath === false || !str_starts_with($sourceRealPath, rtrim($repositoryBase, '/').'/')) { + throw new RuntimeException(sprintf('Repository file "%s" was not found.', $mapping->getRepositoryFilePath())); + } + + if (!is_file($sourceRealPath) || !is_readable($sourceRealPath)) { + throw new RuntimeException(sprintf('Repository file "%s" is not readable.', $mapping->getRepositoryFilePath())); + } + + $targetPath = $this->cachePathResolver->servedScriptPath($cacheKey); + $this->ensureDirectory(dirname($targetPath)); + + $temporaryPath = $targetPath.'.tmp.'.bin2hex(random_bytes(6)); + if (!copy($sourceRealPath, $temporaryPath)) { + throw new RuntimeException(sprintf('Could not write cache file "%s".', $targetPath)); + } + + chmod($temporaryPath, 0644); + if (!rename($temporaryPath, $targetPath)) { + @unlink($temporaryPath); + + throw new RuntimeException(sprintf('Could not publish cache file "%s".', $targetPath)); + } + } + + /** @param list $command */ + private function runGit(array $command, ?string $cwd, array $env): void + { + try { + (new Process($command, $cwd, $env))->mustRun(); + } catch (ProcessFailedException $exception) { + throw new RuntimeException($exception->getMessage(), 0, $exception); + } + } + + private function repositoryDir(ScriptMapping $mapping): string + { + return $this->cachePathResolver->baseDir().'/repos/'.$mapping->getId(); + } + + /** @return array */ + private function gitEnvironment(ScriptMapping $mapping, string $repositoryDir): array + { + $env = [ + 'GIT_CONFIG_COUNT' => '1', + 'GIT_CONFIG_KEY_0' => 'safe.directory', + 'GIT_CONFIG_VALUE_0' => $repositoryDir, + 'GIT_TERMINAL_PROMPT' => '0', + ]; + + if (!$mapping->hasAccessToken()) { + return $env; + } + + $askPassPath = $this->cachePathResolver->baseDir().'/git-askpass.sh'; + $this->ensureDirectory(dirname($askPassPath)); + file_put_contents($askPassPath, <<<'SH' +#!/usr/bin/env sh +case "$1" in + *Username*) printf '%s\n' oauth2 ;; + *) printf '%s\n' "$GIT_ACCESS_TOKEN" ;; +esac +SH); + chmod($askPassPath, 0700); + + $env['GIT_ASKPASS'] = $askPassPath; + $env['GIT_ACCESS_TOKEN'] = (string) $mapping->getAccessToken(); + + return $env; + } + + private function sanitizeError(string $message, ?string $accessToken): string + { + if ($accessToken === null || $accessToken === '') { + return $message; + } + + return str_replace($accessToken, '[redacted]', $message); + } + + private function ensureDirectory(string $directory): void + { + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) { + throw new RuntimeException(sprintf('Could not create directory "%s".', $directory)); + } + } + + private function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $item) { + if ($item->isDir()) { + rmdir($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + + rmdir($directory); + } +} diff --git a/templates/admin/dashboard.html.twig b/templates/admin/dashboard.html.twig index 40ed249..fed1a70 100644 --- a/templates/admin/dashboard.html.twig +++ b/templates/admin/dashboard.html.twig @@ -10,6 +10,9 @@ {% for message in app.flashes('success') %}

{{ message }}

{% endfor %} + {% for message in app.flashes('error') %} +

{{ message }}

+ {% endfor %} @@ -38,6 +41,10 @@
{{ mapping.lastSyncError }} Modifier +
+ + +
diff --git a/tests/Controller/Admin/ScriptMappingControllerTest.php b/tests/Controller/Admin/ScriptMappingControllerTest.php index 8338cc3..a6f4295 100644 --- a/tests/Controller/Admin/ScriptMappingControllerTest.php +++ b/tests/Controller/Admin/ScriptMappingControllerTest.php @@ -6,18 +6,25 @@ use App\Entity\ScriptMapping; use App\Entity\User; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Tools\SchemaTool; +use Symfony\Component\Filesystem\Filesystem; use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\Process\Process; final class ScriptMappingControllerTest extends WebTestCase { private KernelBrowser $client; private EntityManagerInterface $entityManager; + private Filesystem $filesystem; + private string $workDir; protected function setUp(): void { self::ensureKernelShutdown(); $this->client = self::createClient(); + $this->filesystem = new Filesystem(); + $this->workDir = sys_get_temp_dir().'/get-installer-bootstrap-controller-test-'.bin2hex(random_bytes(6)); + $this->filesystem->mkdir($this->workDir); $this->entityManager = static::getContainer()->get(EntityManagerInterface::class); $metadata = $this->entityManager->getMetadataFactory()->getAllMetadata(); @@ -31,6 +38,7 @@ final class ScriptMappingControllerTest extends WebTestCase parent::tearDown(); $this->entityManager->close(); + $this->filesystem->remove($this->workDir); } public function testNewMappingRequiresAuthentication(): void @@ -83,6 +91,39 @@ final class ScriptMappingControllerTest extends WebTestCase self::assertSelectorTextContains('form', 'Public path must end with .sh.'); } + public function testAdminCanSynchronizeMapping(): void + { + $this->loginAsAdmin(); + + $repositoryDir = $this->createRepository([ + 'install.sh' => "#!/usr/bin/env bash\nprintf 'synced'\n", + ]); + $mapping = (new ScriptMapping()) + ->setPublicPath('mcp/graylog/install.sh') + ->setRepositoryUrl($repositoryDir) + ->setGitRef('main') + ->setRepositoryFilePath('install.sh') + ->setActive(true); + $this->entityManager->persist($mapping); + $this->entityManager->flush(); + + $crawler = $this->client->request('GET', '/admin'); + $form = $crawler->selectButton('Synchroniser')->form(); + + $this->client->submit($form); + + self::assertResponseRedirects('/admin'); + + $syncedMapping = $this->entityManager->getRepository(ScriptMapping::class)->find($mapping->getId()); + self::assertInstanceOf(ScriptMapping::class, $syncedMapping); + self::assertSame( + ScriptMapping::SYNC_STATUS_SYNCED, + $syncedMapping->getLastSyncStatus(), + (string) $syncedMapping->getLastSyncError() + ); + self::assertSame('scripts/'.$mapping->getId().'.sh', $syncedMapping->getCacheKey()); + } + private function loginAsAdmin(): void { $user = (new User())->setUsername('admin')->setPasswordHash('unused'); @@ -91,4 +132,32 @@ final class ScriptMappingControllerTest extends WebTestCase $this->client->loginUser($user); } + + /** @param array $files */ + private function createRepository(array $files): string + { + $repositoryDir = $this->workDir.'/repo'; + $this->filesystem->mkdir($repositoryDir); + + foreach ($files as $path => $content) { + $fullPath = $repositoryDir.'/'.$path; + $this->filesystem->mkdir(dirname($fullPath)); + file_put_contents($fullPath, $content); + } + + $this->runGit(['init'], $repositoryDir); + $this->runGit(['config', 'user.email', 'tests@example.com'], $repositoryDir); + $this->runGit(['config', 'user.name', 'Tests'], $repositoryDir); + $this->runGit(['add', '.'], $repositoryDir); + $this->runGit(['commit', '-m', 'Initial commit'], $repositoryDir); + $this->runGit(['branch', '-M', 'main'], $repositoryDir); + + return $repositoryDir; + } + + /** @param list $arguments */ + private function runGit(array $arguments, string $cwd): void + { + (new Process(['git', ...$arguments], $cwd))->mustRun(); + } } diff --git a/tests/Service/GitSynchronizerTest.php b/tests/Service/GitSynchronizerTest.php new file mode 100644 index 0000000..b4a0275 --- /dev/null +++ b/tests/Service/GitSynchronizerTest.php @@ -0,0 +1,126 @@ +filesystem = new Filesystem(); + $this->workDir = sys_get_temp_dir().'/get-installer-bootstrap-test-'.bin2hex(random_bytes(6)); + $this->filesystem->mkdir($this->workDir); + } + + protected function tearDown(): void + { + $this->filesystem->remove($this->workDir); + } + + public function testSyncCopiesRepositoryScriptToCache(): void + { + $repositoryDir = $this->createRepository([ + 'install.sh' => "#!/usr/bin/env bash\nprintf 'ok'\n", + ]); + + $mapping = $this->mapping($repositoryDir, 'install.sh'); + + $synchronizer = $this->synchronizer(); + $synchronizer->sync($mapping); + + self::assertSame(ScriptMapping::SYNC_STATUS_SYNCED, $mapping->getLastSyncStatus()); + self::assertNotNull($mapping->getLastSuccessfulSyncAt()); + self::assertNull($mapping->getLastSyncError()); + self::assertSame('scripts/42.sh', $mapping->getCacheKey()); + self::assertSame( + "#!/usr/bin/env bash\nprintf 'ok'\n", + file_get_contents($this->workDir.'/cache/scripts/42.sh') + ); + } + + public function testSyncFailureMarksMappingWithoutTokenLeak(): void + { + $repositoryDir = $this->createRepository([ + 'install.sh' => "#!/usr/bin/env bash\n", + ]); + + $mapping = $this->mapping($repositoryDir, 'missing.sh')->setAccessToken('secret-token'); + + $synchronizer = $this->synchronizer(); + + $this->expectExceptionMessage('Git synchronization failed.'); + + try { + $synchronizer->sync($mapping); + } finally { + self::assertSame(ScriptMapping::SYNC_STATUS_FAILED, $mapping->getLastSyncStatus()); + self::assertNotNull($mapping->getLastSyncError()); + self::assertStringContainsString('missing.sh', $mapping->getLastSyncError()); + self::assertStringNotContainsString('secret-token', $mapping->getLastSyncError()); + } + } + + /** @param array $files */ + private function createRepository(array $files): string + { + $repositoryDir = $this->workDir.'/repo'; + $this->filesystem->mkdir($repositoryDir); + + foreach ($files as $path => $content) { + $fullPath = $repositoryDir.'/'.$path; + $this->filesystem->mkdir(dirname($fullPath)); + file_put_contents($fullPath, $content); + } + + $this->runGit(['init'], $repositoryDir); + $this->runGit(['config', 'user.email', 'tests@example.com'], $repositoryDir); + $this->runGit(['config', 'user.name', 'Tests'], $repositoryDir); + $this->runGit(['add', '.'], $repositoryDir); + $this->runGit(['commit', '-m', 'Initial commit'], $repositoryDir); + $this->runGit(['branch', '-M', 'main'], $repositoryDir); + + return $repositoryDir; + } + + /** @param list $arguments */ + private function runGit(array $arguments, string $cwd): void + { + $process = new Process(['git', ...$arguments], $cwd); + $process->mustRun(); + } + + private function mapping(string $repositoryUrl, string $repositoryFilePath): ScriptMapping + { + $mapping = (new ScriptMapping()) + ->setPublicPath('mcp/graylog/install.sh') + ->setRepositoryUrl($repositoryUrl) + ->setGitRef('main') + ->setRepositoryFilePath($repositoryFilePath) + ->setActive(true); + + return $this->setMappingId($mapping, 42); + } + + private function synchronizer(): GitSynchronizer + { + return new GitSynchronizer(new CachePathResolver($this->workDir.'/cache', $this->workDir)); + } + + private function setMappingId(ScriptMapping $mapping, int $id): ScriptMapping + { + $property = new ReflectionProperty(ScriptMapping::class, 'id'); + $property->setValue($mapping, $id); + + return $mapping; + } +}