1
0
Fork 0

feat: synchronize installer scripts from git

This commit is contained in:
thibaud-leclere 2026-05-05 10:05:48 +02:00
parent 4e2f181dd9
commit 65e59e740c
6 changed files with 407 additions and 0 deletions

View file

@ -3,7 +3,9 @@
namespace App\Controller\Admin; namespace App\Controller\Admin;
use App\Entity\ScriptMapping; use App\Entity\ScriptMapping;
use App\Exception\GitSyncFailedException;
use App\Form\ScriptMappingType; use App\Form\ScriptMappingType;
use App\Service\GitSynchronizer;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@ -83,4 +85,23 @@ final class ScriptMappingController extends AbstractController
return $this->redirectToRoute('admin_dashboard'); 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');
}
} }

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exception;
use RuntimeException;
final class GitSyncFailedException extends RuntimeException
{
}

View file

@ -0,0 +1,175 @@
<?php
namespace App\Service;
use App\Entity\ScriptMapping;
use App\Exception\GitSyncFailedException;
use LogicException;
use RuntimeException;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
use Throwable;
final class GitSynchronizer
{
public function __construct(private readonly CachePathResolver $cachePathResolver)
{
}
public function sync(ScriptMapping $mapping): void
{
if ($mapping->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<string> $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<string, string> */
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);
}
}

View file

@ -10,6 +10,9 @@
{% for message in app.flashes('success') %} {% for message in app.flashes('success') %}
<p>{{ message }}</p> <p>{{ message }}</p>
{% endfor %} {% endfor %}
{% for message in app.flashes('error') %}
<p>{{ message }}</p>
{% endfor %}
<table> <table>
<thead> <thead>
@ -38,6 +41,10 @@
<td>{{ mapping.lastSyncError }}</td> <td>{{ mapping.lastSyncError }}</td>
<td> <td>
<a href="{{ path('admin_mapping_edit', {id: mapping.id}) }}">Modifier</a> <a href="{{ path('admin_mapping_edit', {id: mapping.id}) }}">Modifier</a>
<form method="post" action="{{ path('admin_mapping_sync', {id: mapping.id}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('sync_mapping_' ~ mapping.id) }}">
<button type="submit">Synchroniser</button>
</form>
<form method="post" action="{{ path('admin_mapping_delete', {id: mapping.id}) }}"> <form method="post" action="{{ path('admin_mapping_delete', {id: mapping.id}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('delete_mapping_' ~ mapping.id) }}"> <input type="hidden" name="_token" value="{{ csrf_token('delete_mapping_' ~ mapping.id) }}">
<button type="submit">Supprimer</button> <button type="submit">Supprimer</button>

View file

@ -6,18 +6,25 @@ use App\Entity\ScriptMapping;
use App\Entity\User; use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool; use Doctrine\ORM\Tools\SchemaTool;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Process\Process;
final class ScriptMappingControllerTest extends WebTestCase final class ScriptMappingControllerTest extends WebTestCase
{ {
private KernelBrowser $client; private KernelBrowser $client;
private EntityManagerInterface $entityManager; private EntityManagerInterface $entityManager;
private Filesystem $filesystem;
private string $workDir;
protected function setUp(): void protected function setUp(): void
{ {
self::ensureKernelShutdown(); self::ensureKernelShutdown();
$this->client = self::createClient(); $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); $this->entityManager = static::getContainer()->get(EntityManagerInterface::class);
$metadata = $this->entityManager->getMetadataFactory()->getAllMetadata(); $metadata = $this->entityManager->getMetadataFactory()->getAllMetadata();
@ -31,6 +38,7 @@ final class ScriptMappingControllerTest extends WebTestCase
parent::tearDown(); parent::tearDown();
$this->entityManager->close(); $this->entityManager->close();
$this->filesystem->remove($this->workDir);
} }
public function testNewMappingRequiresAuthentication(): void public function testNewMappingRequiresAuthentication(): void
@ -83,6 +91,39 @@ final class ScriptMappingControllerTest extends WebTestCase
self::assertSelectorTextContains('form', 'Public path must end with .sh.'); 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 private function loginAsAdmin(): void
{ {
$user = (new User())->setUsername('admin')->setPasswordHash('unused'); $user = (new User())->setUsername('admin')->setPasswordHash('unused');
@ -91,4 +132,32 @@ final class ScriptMappingControllerTest extends WebTestCase
$this->client->loginUser($user); $this->client->loginUser($user);
} }
/** @param array<string, string> $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<string> $arguments */
private function runGit(array $arguments, string $cwd): void
{
(new Process(['git', ...$arguments], $cwd))->mustRun();
}
} }

View file

@ -0,0 +1,126 @@
<?php
namespace App\Tests\Service;
use App\Entity\ScriptMapping;
use App\Service\CachePathResolver;
use App\Service\GitSynchronizer;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\Process;
final class GitSynchronizerTest extends TestCase
{
private Filesystem $filesystem;
private string $workDir;
protected function setUp(): void
{
$this->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<string, string> $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<string> $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;
}
}