feat : ajout de la lecture des logs symfony et docker (#3)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Reviewed-on: #3 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #3.
This commit is contained in:
31
src/ApiResource/LogOutput.php
Normal file
31
src/ApiResource/LogOutput.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\State\DockerLogProvider;
|
||||
use App\State\SymfonyLogProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/environments/{id}/logs/docker',
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
provider: DockerLogProvider::class,
|
||||
),
|
||||
new Get(
|
||||
uriTemplate: '/environments/{id}/logs/symfony/{logFileId}',
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
provider: SymfonyLogProvider::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class LogOutput
|
||||
{
|
||||
public string $content = '';
|
||||
public int $lines = 0;
|
||||
public string $source = '';
|
||||
}
|
||||
@@ -57,21 +57,21 @@ class AppFixtures extends Fixture
|
||||
$sirh->setGiteaUrl('https://gitea.malio.fr/malio-dev/sirh');
|
||||
|
||||
$sirhProd = new Environment();
|
||||
$sirhProd->setName('production');
|
||||
$sirhProd->setContainerName('sirh-app');
|
||||
$sirhProd->setDeployScriptPath('/home/m-tristan/workspace/SIRH/deploy/docker/deploy.sh');
|
||||
$sirhProd->setMaintenanceFilePath('/home/m-tristan/workspace/SIRH/deploy/docker/maintenance.on');
|
||||
$sirhProd->setName('Production');
|
||||
$sirhProd->setContainerName('php-sirh-fpm');
|
||||
$sirhProd->setDeployScriptPath('/SIRH/deploy/docker/deploy.sh');
|
||||
$sirhProd->setMaintenanceFilePath('/SIRH/deploy/docker/maintenance.on');
|
||||
$sirhProd->setAppUrl('http://sirh.malio-dev.fr');
|
||||
$sirh->addEnvironment($sirhProd);
|
||||
|
||||
$sirhProdLog = new LogFile();
|
||||
$sirhProdLog->setLabel('prod');
|
||||
$sirhProdLog->setPath('/home/m-tristan/workspace/SIRH/var/log/prod.log');
|
||||
$sirhProdLog->setLabel('dev');
|
||||
$sirhProdLog->setPath('/SIRH/var/log/dev.log');
|
||||
$sirhProd->addLogFile($sirhProdLog);
|
||||
|
||||
$sirhCronLog = new LogFile();
|
||||
$sirhCronLog->setLabel('cron');
|
||||
$sirhCronLog->setPath('/home/m-tristan/workspace/SIRH/var/log/cron.log');
|
||||
$sirhCronLog->setPath('/SIRH/var/log/cron.log');
|
||||
$sirhProd->addLogFile($sirhCronLog);
|
||||
|
||||
$manager->persist($sirh);
|
||||
@@ -86,8 +86,8 @@ class AppFixtures extends Fixture
|
||||
$lesstimeProd = new Environment();
|
||||
$lesstimeProd->setName('production');
|
||||
$lesstimeProd->setContainerName('lesstime-app');
|
||||
$lesstimeProd->setDeployScriptPath('/home/m-tristan/workspace/lesstime/deploy/docker/deploy.sh');
|
||||
$lesstimeProd->setMaintenanceFilePath('/home/m-tristan/workspace/lesstime/deploy/docker/maintenance.on');
|
||||
$lesstimeProd->setDeployScriptPath('/lesstime/deploy/docker/deploy.sh');
|
||||
$lesstimeProd->setMaintenanceFilePath('/lesstime/deploy/docker/maintenance.on');
|
||||
$lesstimeProd->setAppUrl('http://lesstime.malio-dev.fr');
|
||||
$lesstime->addEnvironment($lesstimeProd);
|
||||
|
||||
@@ -103,16 +103,16 @@ class AppFixtures extends Fixture
|
||||
$inventoryProd = new Environment();
|
||||
$inventoryProd->setName('production');
|
||||
$inventoryProd->setContainerName('inventory-app');
|
||||
$inventoryProd->setDeployScriptPath('/home/m-tristan/workspace/inventory/deploy/docker/deploy.sh');
|
||||
$inventoryProd->setMaintenanceFilePath('/home/m-tristan/workspace/inventory/deploy/docker/maintenance.on');
|
||||
$inventoryProd->setDeployScriptPath('/inventory/deploy/docker/deploy.sh');
|
||||
$inventoryProd->setMaintenanceFilePath('/inventory/deploy/docker/maintenance.on');
|
||||
$inventoryProd->setAppUrl('http://inventory.malio-dev.fr');
|
||||
$inventory->addEnvironment($inventoryProd);
|
||||
|
||||
$inventoryRecette = new Environment();
|
||||
$inventoryRecette->setName('recette');
|
||||
$inventoryRecette->setContainerName('inventory-test-app');
|
||||
$inventoryRecette->setDeployScriptPath('/home/m-tristan/workspace/inventory/deploy/docker/deploy-test.sh');
|
||||
$inventoryRecette->setMaintenanceFilePath('/home/m-tristan/workspace/inventory/deploy/docker/maintenance-test.on');
|
||||
$inventoryRecette->setDeployScriptPath('/inventory/deploy/docker/deploy-test.sh');
|
||||
$inventoryRecette->setMaintenanceFilePath('/inventory/deploy/docker/maintenance-test.on');
|
||||
$inventoryRecette->setAppUrl('http://inventory-test.malio-dev.fr');
|
||||
$inventory->addEnvironment($inventoryRecette);
|
||||
|
||||
|
||||
@@ -8,11 +8,34 @@ use Symfony\Component\Process\Process;
|
||||
|
||||
final class DockerService
|
||||
{
|
||||
private ?bool $dockerAvailable = null;
|
||||
|
||||
private function isDockerAvailable(): bool
|
||||
{
|
||||
if (null === $this->dockerAvailable) {
|
||||
$process = new Process(['which', 'docker']);
|
||||
$process->setTimeout(5);
|
||||
$process->run();
|
||||
$this->dockerAvailable = $process->isSuccessful();
|
||||
}
|
||||
|
||||
return $this->dockerAvailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, image: string, version: string, startedAt: string}
|
||||
*/
|
||||
public function getContainerStatus(string $containerName): array
|
||||
{
|
||||
if (!$this->isDockerAvailable()) {
|
||||
return [
|
||||
'status' => 'unavailable',
|
||||
'image' => '',
|
||||
'version' => '',
|
||||
'startedAt' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$process = new Process([
|
||||
'docker', 'inspect',
|
||||
'--format', '{{.State.Status}}||{{.Config.Image}}||{{.State.StartedAt}}',
|
||||
@@ -60,6 +83,15 @@ final class DockerService
|
||||
*/
|
||||
public function getContainerStats(string $containerName): array
|
||||
{
|
||||
if (!$this->isDockerAvailable()) {
|
||||
return [
|
||||
'cpuPercent' => 0.0,
|
||||
'memoryUsage' => '',
|
||||
'memoryLimit' => '',
|
||||
'memoryPercent' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
$process = new Process([
|
||||
'docker', 'stats', '--no-stream',
|
||||
'--format', '{{.CPUPerc}}||{{.MemUsage}}||{{.MemPerc}}',
|
||||
|
||||
113
src/Service/LogService.php
Normal file
113
src/Service/LogService.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
final readonly class LogService
|
||||
{
|
||||
public function __construct(
|
||||
private AppPathResolver $pathResolver,
|
||||
) {}
|
||||
|
||||
public function getDockerLogs(string $containerName, int $lines = 100, ?string $since = null): string
|
||||
{
|
||||
$check = new Process(['which', 'docker']);
|
||||
$check->setTimeout(5);
|
||||
$check->run();
|
||||
|
||||
if (!$check->isSuccessful()) {
|
||||
return 'Docker CLI is not available in this environment.';
|
||||
}
|
||||
|
||||
$args = ['docker', 'logs', '--tail', (string) $lines];
|
||||
|
||||
if (null !== $since) {
|
||||
$args[] = '--since';
|
||||
$args[] = $since;
|
||||
}
|
||||
|
||||
$args[] = $containerName;
|
||||
|
||||
$process = new Process($args);
|
||||
$process->setTimeout(10);
|
||||
$process->run();
|
||||
|
||||
return $process->getOutput() . $process->getErrorOutput();
|
||||
}
|
||||
|
||||
public function getSymfonyLog(string $relativePath, int $lines = 100, ?string $level = null): string
|
||||
{
|
||||
$path = $this->pathResolver->resolve($relativePath);
|
||||
|
||||
if (!file_exists($path)) {
|
||||
return sprintf('Log file not found: %s', $path);
|
||||
}
|
||||
|
||||
// Read more lines than requested to compensate for filtering
|
||||
$readLines = (null !== $level && '' !== $level) ? $lines * 5 : $lines;
|
||||
|
||||
$process = new Process(['tail', '-n', (string) $readLines, $path]);
|
||||
$process->setTimeout(10);
|
||||
$process->run();
|
||||
|
||||
$rawLines = explode("\n", trim($process->getOutput()));
|
||||
$formatted = [];
|
||||
|
||||
foreach ($rawLines as $line) {
|
||||
if ('' === $line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsed = $this->parseSymfonyLogLine($line);
|
||||
|
||||
if (null === $parsed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip noisy channels
|
||||
if ('doctrine' === $parsed['channel']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (null !== $level && '' !== $level && !str_contains(strtoupper($parsed['level']), strtoupper($level))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$formatted[] = sprintf('[%s] %s.%s: %s', $parsed['date'], $parsed['channel'], $parsed['level'], $parsed['message']);
|
||||
}
|
||||
|
||||
// Keep only the last N lines after filtering
|
||||
$formatted = \array_slice($formatted, -$lines);
|
||||
|
||||
return implode("\n", $formatted);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{date: string, level: string, channel: string, message: string}|null
|
||||
*/
|
||||
private function parseSymfonyLogLine(string $line): ?array
|
||||
{
|
||||
// Standard Symfony monolog format: [2026-04-03T15:33:19.304937+02:00] channel.LEVEL: message {context} []
|
||||
if (preg_match('/^\[([^\]]+)\]\s+(\w+)\.(\w+):\s+(.+)$/', $line, $matches)) {
|
||||
$date = substr($matches[1], 0, 19); // Trim to YYYY-MM-DDTHH:MM:SS
|
||||
$message = $matches[4];
|
||||
|
||||
// Remove JSON context at the end: {"key":"value"} []
|
||||
$message = preg_replace('/\s*\{.*\}\s*\[\]\s*$/', '', $message) ?? $message;
|
||||
// Remove trailing []
|
||||
$message = preg_replace('/\s*\[\]\s*$/', '', $message) ?? $message;
|
||||
|
||||
return [
|
||||
'date' => str_replace('T', ' ', $date),
|
||||
'level' => $matches[3],
|
||||
'channel' => $matches[2],
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
49
src/State/DockerLogProvider.php
Normal file
49
src/State/DockerLogProvider.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\LogOutput;
|
||||
use App\Repository\EnvironmentRepository;
|
||||
use App\Service\LogService;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final readonly class DockerLogProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EnvironmentRepository $environmentRepository,
|
||||
private LogService $logService,
|
||||
private RequestStack $requestStack,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): LogOutput
|
||||
{
|
||||
$id = $uriVariables['id'] ?? null;
|
||||
$environment = $id ? $this->environmentRepository->find($id) : null;
|
||||
|
||||
if (null === $environment) {
|
||||
throw new NotFoundHttpException(sprintf('Environment "%s" not found.', $id));
|
||||
}
|
||||
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$lines = (int) ($request?->query->get('lines', '100') ?? 100);
|
||||
$since = $request?->query->get('since');
|
||||
|
||||
$content = $this->logService->getDockerLogs(
|
||||
$environment->getContainerName(),
|
||||
$lines,
|
||||
$since,
|
||||
);
|
||||
|
||||
$dto = new LogOutput();
|
||||
$dto->content = $content;
|
||||
$dto->lines = $lines;
|
||||
$dto->source = sprintf('docker:%s', $environment->getContainerName());
|
||||
|
||||
return $dto;
|
||||
}
|
||||
}
|
||||
57
src/State/SymfonyLogProvider.php
Normal file
57
src/State/SymfonyLogProvider.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\LogOutput;
|
||||
use App\Repository\EnvironmentRepository;
|
||||
use App\Repository\LogFileRepository;
|
||||
use App\Service\LogService;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final readonly class SymfonyLogProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EnvironmentRepository $environmentRepository,
|
||||
private LogFileRepository $logFileRepository,
|
||||
private LogService $logService,
|
||||
private RequestStack $requestStack,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): LogOutput
|
||||
{
|
||||
$envId = $uriVariables['id'] ?? null;
|
||||
$logFileId = $uriVariables['logFileId'] ?? null;
|
||||
|
||||
$environment = $envId ? $this->environmentRepository->find($envId) : null;
|
||||
if (null === $environment) {
|
||||
throw new NotFoundHttpException(sprintf('Environment "%s" not found.', $envId));
|
||||
}
|
||||
|
||||
$logFile = $logFileId ? $this->logFileRepository->find($logFileId) : null;
|
||||
if (null === $logFile || $logFile->getEnvironment()?->getId() !== $environment->getId()) {
|
||||
throw new NotFoundHttpException(sprintf('Log file "%s" not found.', $logFileId));
|
||||
}
|
||||
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$lines = (int) ($request?->query->get('lines', '100') ?? 100);
|
||||
$level = $request?->query->get('level');
|
||||
|
||||
$content = $this->logService->getSymfonyLog(
|
||||
$logFile->getPath(),
|
||||
$lines,
|
||||
$level,
|
||||
);
|
||||
|
||||
$dto = new LogOutput();
|
||||
$dto->content = $content;
|
||||
$dto->lines = $lines;
|
||||
$dto->source = sprintf('symfony:%s', $logFile->getLabel());
|
||||
|
||||
return $dto;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user