diff --git a/.docker/php/Dockerfile b/.docker/php/Dockerfile index 37fea50ac..d5317080c 100755 --- a/.docker/php/Dockerfile +++ b/.docker/php/Dockerfile @@ -42,6 +42,10 @@ RUN if [ $xdebug = "1" ] ; then \ RUN docker-php-ext-configure opcache --enable-opcache \ && docker-php-ext-install opcache +#AMQP +RUN apt-get -y install --no-install-recommends gcc make autoconf libc-dev pkg-config librabbitmq-dev --fix-missing +RUN pecl install amqp-1.11.0 && docker-php-ext-enable amqp + #INTL RUN apt-get update \ && apt-get install -y --no-install-recommends libicu-dev @@ -76,4 +80,4 @@ USER dev WORKDIR /home/wwwroot/ -EXPOSE 9000 \ No newline at end of file +EXPOSE 9000 diff --git a/.github/workflows/after_splitting_test.yaml b/.github/workflows/after_splitting_test.yaml index e7625bf8f..e39bb7e7b 100644 --- a/.github/workflows/after_splitting_test.yaml +++ b/.github/workflows/after_splitting_test.yaml @@ -18,6 +18,7 @@ jobs: - aws-tool-kit - console - core + - cron-job - doctrine-extra - entity-migrator - fixer diff --git a/app/migrations/Version20240416163152.php b/app/migrations/Version20240416163152.php new file mode 100644 index 000000000..7c90ebf3c --- /dev/null +++ b/app/migrations/Version20240416163152.php @@ -0,0 +1,60 @@ +addSql('CREATE TABLE cron_job__cron_job ( + id INT AUTO_INCREMENT NOT NULL, + name VARCHAR(255) NOT NULL, + active TINYINT(1) DEFAULT 0 NOT NULL, + command LONGTEXT NOT NULL, + schedule VARCHAR(255) DEFAULT NULL, + time_to_live INT DEFAULT 0 NOT NULL, + priority INT DEFAULT NULL, + UNIQUE INDEX UNIQ_5D454BF65E237E06 (name), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE cron_job__cron_job_execution ( + id INT AUTO_INCREMENT NOT NULL, + cron_job_id INT NOT NULL, + requested_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', + `force` TINYINT(1) DEFAULT 0 NOT NULL, + execution_started_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', + execution_ended_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', + execution_delay INT DEFAULT NULL, + exit_code INT DEFAULT NULL, + error JSON DEFAULT NULL, + INDEX IDX_2DD653DD79099ED8 (cron_job_id), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE + cron_job__cron_job_execution + ADD + CONSTRAINT FK_2DD653DD79099ED8 FOREIGN KEY (cron_job_id) REFERENCES cron_job__cron_job (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE cron_job__cron_job_execution DROP FOREIGN KEY FK_2DD653DD79099ED8'); + $this->addSql('DROP TABLE cron_job__cron_job'); + $this->addSql('DROP TABLE cron_job__cron_job_execution'); + } +} diff --git a/composer.json b/composer.json index ee3626131..fc536a6ff 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "doctrine/doctrine-bundle": "^2.5", "doctrine/orm": "^2.11", "doctrine/persistence": "^3.2", + "dragonmantank/cron-expression": "^3.3", "guzzlehttp/psr7": "^1.8 || ^2.0", "jms/serializer": "^3.29.1", "jms/serializer-bundle": "^5.4.0", @@ -29,6 +30,7 @@ "sonata-project/admin-bundle": "^4.8", "sonata-project/doctrine-orm-admin-bundle": "^4.2", "sonata-project/form-extensions": "^1.13", + "symfony/amqp-messenger": "^6.4.0", "symfony/browser-kit": "^6.4.0", "symfony/config": "^6.4.0", "symfony/console": "^6.4.0", @@ -126,6 +128,7 @@ "draw/console": "self.version", "draw/contracts": "self.version", "draw/core": "self.version", + "draw/cron-job": "self.version", "draw/doctrine-extra": "self.version", "draw/entity-migrator": "self.version", "draw/fixer": "self.version", @@ -174,6 +177,7 @@ "Draw\\Component\\AwsToolKit\\": "packages/aws-tool-kit/", "Draw\\Component\\Console\\": "packages/console/", "Draw\\Component\\Core\\": "packages/core/", + "Draw\\Component\\CronJob\\": "packages/cron-job/", "Draw\\Component\\EntityMigrator\\": "packages/entity-migrator/", "Draw\\Component\\Log\\": "packages/log/", "Draw\\Component\\Mailer\\": "packages/mailer/", @@ -288,4 +292,4 @@ "vendor/bin/phpstan analyse --generate-baseline" ] } -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index bc9b0f56a..13a49981c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "88b2508d973c11c3ef04509b8036dd3f", + "content-hash": "17076ca60f35700e71304311facbb28c", "packages": [ { "name": "aws/aws-crt-php", @@ -1981,6 +1981,67 @@ }, "time": "2023-08-16T21:49:04+00:00" }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.3.3", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "adfb1f505deb6384dc8b39804c5065dd3c8c8c0a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/adfb1f505deb6384dc8b39804c5065dd3c8c8c0a", + "reference": "adfb1f505deb6384dc8b39804c5065dd3c8c8c0a", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0", + "webmozart/assert": "^1.0" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-webmozart-assert": "^1.0", + "phpunit/phpunit": "^7.0|^8.0|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.3.3" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2023-08-10T19:36:49+00:00" + }, { "name": "egulias/email-validator", "version": "4.0.2", @@ -6720,6 +6781,75 @@ ], "time": "2023-11-23T20:39:43+00:00" }, + { + "name": "symfony/amqp-messenger", + "version": "v6.4.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/amqp-messenger.git", + "reference": "6ba7a032d6074b4806c9d036c6d21cc092c7d18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/amqp-messenger/zipball/6ba7a032d6074b4806c9d036c6d21cc092c7d18d", + "reference": "6ba7a032d6074b4806c9d036c6d21cc092c7d18d", + "shasum": "" + }, + "require": { + "ext-amqp": "*", + "php": ">=8.1", + "symfony/messenger": "^6.1|^7.0" + }, + "require-dev": { + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/serializer": "^5.4|^6.0|^7.0" + }, + "type": "symfony-messenger-bridge", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\Bridge\\Amqp\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony AMQP extension Messenger Bridge", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/amqp-messenger/tree/v6.4.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-03-05T20:59:08+00:00" + }, { "name": "symfony/asset", "version": "v6.4.3", diff --git a/config/packages/draw_framework_extra.yaml b/config/packages/draw_framework_extra.yaml index 7635e75d2..c7d9f686a 100644 --- a/config/packages/draw_framework_extra.yaml +++ b/config/packages/draw_framework_extra.yaml @@ -10,6 +10,8 @@ draw_framework_extra: filter: 'in' command_names: [ 'draw:console:generate-documentation' ] + cron_job: ~ + doctrine_extra: ~ entity_migrator: ~ diff --git a/config/packages/draw_sonata_integration.yaml b/config/packages/draw_sonata_integration.yaml index 5e58860e5..15330c85e 100644 --- a/config/packages/draw_sonata_integration.yaml +++ b/config/packages/draw_sonata_integration.yaml @@ -7,6 +7,8 @@ draw_sonata_integration: commandName: 'app:null' label: 'Null' + cron_job: ~ + entity_migrator: ~ messenger: diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index d0a403a28..dbdb40552 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -16,6 +16,7 @@ framework: sync: 'sync://' routing: + Draw\Component\CronJob\Message\ExecuteCronJobMessage: 'async_high_priority' App\Message\NewTestDocumentMessage: ['sync', 'async'] Draw\Component\Messenger\ManualTrigger\Message\ManuallyTriggeredInterface: 'async' Draw\Bundle\UserBundle\Message\NewUserLockMessage: 'sync' diff --git a/packages/contracts/Process/ProcessFactoryInterface.php b/packages/contracts/Process/ProcessFactoryInterface.php index 8dad99472..5ce514277 100644 --- a/packages/contracts/Process/ProcessFactoryInterface.php +++ b/packages/contracts/Process/ProcessFactoryInterface.php @@ -7,4 +7,12 @@ interface ProcessFactoryInterface { public function create(array $command, ?string $cwd = null, ?array $env = null, $input = null, ?float $timeout = 60): Process; + + public function createFromShellCommandLine( + string $command, + ?string $cwd = null, + ?array $env = null, + $input = null, + ?float $timeout = 60 + ): Process; } diff --git a/packages/cron-job/.gitignore b/packages/cron-job/.gitignore new file mode 100644 index 000000000..17ae66f14 --- /dev/null +++ b/packages/cron-job/.gitignore @@ -0,0 +1,8 @@ +/vendor/ +/.idea/ +composer.lock + +###> phpunit/phpunit ### +/phpunit.xml +.phpunit.result.cache +###< phpunit/phpunit ### diff --git a/packages/cron-job/Command/QueueCronJobByNameCommand.php b/packages/cron-job/Command/QueueCronJobByNameCommand.php new file mode 100644 index 000000000..3b16cdcb4 --- /dev/null +++ b/packages/cron-job/Command/QueueCronJobByNameCommand.php @@ -0,0 +1,56 @@ +setDescription('Queues cron job by name') + ->addArgument('name', InputArgument::REQUIRED, 'Cron job name'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $cronJob = $this->managerRegistry + ->getRepository(CronJob::class) + ->findOneBy(['name' => $input->getArgument('name')]); + + if (null === $cronJob) { + $io->error('Cron job could not be found.'); + + return Command::FAILURE; + } + + $io->section('Queueing cron job...'); + + $this->cronJobProcessor->queue($cronJob, true); + + $io->section('Cron job successfully queued.'); + + return Command::SUCCESS; + } +} diff --git a/packages/cron-job/Command/QueueDueCronJobsCommand.php b/packages/cron-job/Command/QueueDueCronJobsCommand.php new file mode 100644 index 000000000..5dfbc31bf --- /dev/null +++ b/packages/cron-job/Command/QueueDueCronJobsCommand.php @@ -0,0 +1,55 @@ +setDescription('Queues due cron jobs'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->section('Queueing cron jobs...'); + + $cronJobs = $this->managerRegistry + ->getRepository(CronJob::class) + ->findBy(['active' => true]); + + foreach ($cronJobs as $cronJob) { + if (!$cronJob->isDue()) { + continue; + } + + $io->note(sprintf('Queueing cron job "%s"...', $cronJob->getName())); + + $this->cronJobProcessor->queue($cronJob, false); + } + + $io->success('Cron jobs successfully queued...'); + + return Command::SUCCESS; + } +} diff --git a/packages/cron-job/CronJobProcessor.php b/packages/cron-job/CronJobProcessor.php new file mode 100644 index 000000000..eb86fe43d --- /dev/null +++ b/packages/cron-job/CronJobProcessor.php @@ -0,0 +1,74 @@ +managerRegistry->getManagerForClass(CronJobExecution::class); + + $manager->persist($execution = $cronJob->newExecution($force)); + $manager->flush(); + + $this->messageBus->dispatch(new ExecuteCronJobMessage($execution)); + } + + public function process(CronJobExecution $execution): void + { + $event = $this->eventDispatcher->dispatch(new PreCronJobExecutionEvent($execution)); + + if ($event->isExecutionCancelled()) { + return; + } + + $manager = $this->managerRegistry->getManagerForClass(CronJobExecution::class); + + $execution->start(); + $manager->flush(); + + $process = $this->processFactory->createFromShellCommandLine( + $this->parameterBag->resolveValue( + $event->getCommand() + ), + timeout: 1800 + ); + + try { + $process->mustRun(); + + $execution->end(); + } catch (\Throwable $error) { + $execution->fail( + $process->getExitCode(), + (array) $error + ); + } + + $manager->flush(); + + $this->eventDispatcher->dispatch(new PostCronJobExecutionEvent($execution)); + } +} diff --git a/packages/cron-job/Entity/CronJob.php b/packages/cron-job/Entity/CronJob.php new file mode 100644 index 000000000..c795b7c9b --- /dev/null +++ b/packages/cron-job/Entity/CronJob.php @@ -0,0 +1,199 @@ + false])] + private bool $active = false; + + #[ORM\Column(name: 'command', type: 'text', nullable: false)] + #[Assert\NotNull] + private ?string $command = null; + + #[ORM\Column(name: 'schedule', type: 'string', length: 255, nullable: true)] + private ?string $schedule = null; + + #[ORM\Column(name: 'time_to_live', type: 'integer', nullable: false, options: ['default' => 0])] + #[ + Assert\NotNull, + Assert\GreaterThanOrEqual(0), + ] + private int $timeToLive = 0; + + #[ORM\Column(name: 'priority', type: 'integer', nullable: true)] + #[Assert\Range(min: 0, max: 255)] + private ?int $priority = null; + + /** + * @var Selectable&Collection + */ + #[ + ORM\OneToMany( + mappedBy: 'cronJob', + targetEntity: CronJobExecution::class, + cascade: ['persist'], + fetch: 'EXTRA_LAZY', + orphanRemoval: true, + ) + ] + private Selectable&Collection $executions; + + public function __construct() + { + $this->executions = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): self + { + $this->name = $name; + + return $this; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): self + { + $this->active = $active; + + return $this; + } + + public function getCommand(): ?string + { + return $this->command; + } + + public function setCommand(?string $command): self + { + $this->command = $command; + + return $this; + } + + public function getSchedule(): ?string + { + return $this->schedule; + } + + public function setSchedule(?string $schedule): self + { + if (null !== $schedule) { + $schedule = (new CronExpression($schedule))->getExpression(); + } + + $this->schedule = $schedule; + + return $this; + } + + public function getTimeToLive(): int + { + return $this->timeToLive; + } + + public function setTimeToLive(int $timeToLive): self + { + $this->timeToLive = $timeToLive; + + return $this; + } + + public function getPriority(): ?int + { + return $this->priority; + } + + public function setPriority(?int $priority): self + { + $this->priority = $priority; + + return $this; + } + + /** + * @return Selectable&Collection + */ + public function getExecutions(): Collection + { + return $this->executions; + } + + /** + * @return Selectable&Collection + */ + public function getRecentExecutions(): Selectable&Collection + { + return $this->executions + ->matching( + Criteria::create() + ->orderBy(['requestedAt' => 'DESC']) + ->setMaxResults(10) + ); + } + + public function isDue(): bool + { + if (null === $this->getSchedule()) { + return false; + } + + return (new CronExpression($this->getSchedule()))->isDue(); + } + + public function newExecution(bool $force = false): CronJobExecution + { + $cronJobExecution = new CronJobExecution($this, new \DateTimeImmutable(), $force); + + $this->executions->add($cronJobExecution); + + return $cronJobExecution; + } + + public function __toString(): string + { + return (string) $this->name; + } +} diff --git a/packages/cron-job/Entity/CronJobExecution.php b/packages/cron-job/Entity/CronJobExecution.php new file mode 100644 index 000000000..586e2619f --- /dev/null +++ b/packages/cron-job/Entity/CronJobExecution.php @@ -0,0 +1,204 @@ + false])] + private bool $force; + + #[ORM\Column(name: 'execution_started_at', type: 'datetime_immutable', nullable: true)] + private ?\DateTimeImmutable $executionStartedAt = null; + + #[ORM\Column(name: 'execution_ended_at', type: 'datetime_immutable', nullable: true)] + private ?\DateTimeImmutable $executionEndedAt = null; + + #[ORM\Column(name: 'execution_delay', type: 'integer', nullable: true)] + private ?int $executionDelay = null; + + #[ORM\Column(name: 'exit_code', type: 'integer', nullable: true)] + private ?int $exitCode = null; + + #[ORM\Column(name: 'error', type: 'json', nullable: true)] + private ?array $error = null; + + #[ + ORM\ManyToOne( + targetEntity: CronJob::class, + inversedBy: 'executions', + ), + ORM\JoinColumn( + name: 'cron_job_id', + referencedColumnName: 'id', + nullable: false, + onDelete: 'CASCADE', + ) + ] + private CronJob $cronJob; + + public function __construct( + CronJob $cronJob, + \DateTimeImmutable $requestedAt, + bool $force + ) { + $this->cronJob = $cronJob; + $this->requestedAt = $requestedAt; + $this->force = $force; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getRequestedAt(): ?\DateTimeImmutable + { + return $this->requestedAt; + } + + public function isForce(): bool + { + return $this->force; + } + + public function getExecutionStartedAt(): ?\DateTimeImmutable + { + return $this->executionStartedAt; + } + + private function setExecutionStartedAt(?\DateTimeImmutable $executionStartedAt): self + { + $this->executionStartedAt = $executionStartedAt; + + return $this; + } + + public function getExecutionEndedAt(): ?\DateTimeImmutable + { + return $this->executionEndedAt; + } + + private function setExecutionEndedAt(?\DateTimeImmutable $executionEndedAt): self + { + $this->executionEndedAt = $executionEndedAt; + + return $this; + } + + public function getExecutionDelay(): ?int + { + return $this->executionDelay; + } + + private function setExecutionDelay(?int $executionDelay): self + { + $this->executionDelay = $executionDelay; + + return $this; + } + + public function getExitCode(): ?int + { + return $this->exitCode; + } + + private function setExitCode(?int $exitCode): self + { + $this->exitCode = $exitCode; + + return $this; + } + + public function getError(): ?array + { + return $this->error; + } + + private function setError(?array $error): self + { + $this->error = $error; + + return $this; + } + + public function getCronJob(): ?CronJob + { + return $this->cronJob; + } + + public function isExecutable(\DateTimeImmutable $dateTime): bool + { + if (!($cronJob = $this->getCronJob())?->isActive()) { + return false; + } + + if (0 === ($timeToLive = $cronJob->getTimeToLive())) { + return true; + } + + if (null === $this->getRequestedAt()) { + return false; + } + + return $dateTime->getTimestamp() <= $this->getRequestedAt()->getTimestamp() + $timeToLive; + } + + public function start(): void + { + $this + ->setExecutionStartedAt(new \DateTimeImmutable()) + ->setExecutionEndedAt(null); + } + + public function end(): static + { + $this + ->setExitCode(0) + ->setExecutionEndedAt($executionEndedAt = new \DateTimeImmutable()) + ->setExecutionDelay( + $executionEndedAt->getTimestamp() - $this->getExecutionStartedAt()->getTimestamp() + ); + + return $this; + } + + public function fail(?int $exitCode, ?array $error): void + { + $this + ->end() + ->setExitCode($exitCode) + ->setError($error); + } + + public function __toString(): string + { + return implode( + ', ', + array_filter( + [ + $this->getRequestedAt()?->format('Y-m-d H:i:s.u') ?? '-', + $this->getExitCode(), + $this->getExecutionDelay(), + ] + ) + ); + } +} diff --git a/packages/cron-job/Event/PostCronJobExecutionEvent.php b/packages/cron-job/Event/PostCronJobExecutionEvent.php new file mode 100644 index 000000000..fd08bb2c1 --- /dev/null +++ b/packages/cron-job/Event/PostCronJobExecutionEvent.php @@ -0,0 +1,21 @@ +execution; + } +} diff --git a/packages/cron-job/Event/PreCronJobExecutionEvent.php b/packages/cron-job/Event/PreCronJobExecutionEvent.php new file mode 100644 index 000000000..3371b09d5 --- /dev/null +++ b/packages/cron-job/Event/PreCronJobExecutionEvent.php @@ -0,0 +1,49 @@ +command = $this->execution->getCronJob()->getCommand(); + } + + public function getExecution(): CronJobExecution + { + return $this->execution; + } + + public function isExecutionCancelled(): bool + { + return $this->executionCancelled; + } + + public function setExecutionCancelled(bool $executionCancelled): self + { + $this->executionCancelled = $executionCancelled; + + return $this; + } + + public function getCommand(): string + { + return $this->command; + } + + public function setCommand(string $command): self + { + $this->command = $command; + + return $this; + } +} diff --git a/packages/cron-job/Message/ExecuteCronJobMessage.php b/packages/cron-job/Message/ExecuteCronJobMessage.php new file mode 100644 index 000000000..0f1571162 --- /dev/null +++ b/packages/cron-job/Message/ExecuteCronJobMessage.php @@ -0,0 +1,47 @@ +execution = $execution; + } + + public function getExecution(): CronJobExecution + { + if (null === $this->execution) { + throw new UnrecoverableMessageHandlingException('CronJobExecution is not set.'); + } + + return $this->execution; + } + + public function getPropertiesWithDoctrineObject(): array + { + return ['execution']; + } + + public function stamp(Envelope $envelope): Envelope + { + if (null !== ($priority = $this->execution?->getCronJob()?->getPriority())) { + return $envelope->with( + AmqpStamp::createWithAttributes(['priority' => $priority]) + ); + } + + return $envelope; + } +} diff --git a/packages/cron-job/MessageHandler/ExecuteCronJobMessageHandler.php b/packages/cron-job/MessageHandler/ExecuteCronJobMessageHandler.php new file mode 100644 index 000000000..2383036f1 --- /dev/null +++ b/packages/cron-job/MessageHandler/ExecuteCronJobMessageHandler.php @@ -0,0 +1,27 @@ +getExecution())->isExecutable(new \DateTimeImmutable())) { + return; + } + + $this->cronJobProcessor->process($execution); + } +} diff --git a/packages/cron-job/README.md b/packages/cron-job/README.md new file mode 100644 index 000000000..441c35339 --- /dev/null +++ b/packages/cron-job/README.md @@ -0,0 +1,78 @@ +# Cron Job + +This library is used to manage and process cron jobs from the database. + +The cron are sent to a queue and processed by a worker via symfony messenger. + +## Configuration + +The package can be enabled as follows: +> **_NOTE:_** Below are the default configs which can be overwritten by your needs. + +### Framework extra + +```yaml +draw_framework_extra: + # ... + cron_job: + enabled: true + doctrine: + orm: + mappings: + DrawCronJob: + is_bundle: false + type: attribute + dir: ... + prefix: Draw\Component\CronJob\Entity +``` + +> **_NOTE:_** The following services are available: +> - **draw.cron_job.command.queue_cron_job_by_name_command**: Draw\Component\CronJob\Command\QueueCronJobByNameCommand +> - **draw.cron_job.command.queue_due_cron_jobs_command**: Draw\Component\CronJob\Command\QueueDueCronJobsCommand +> - **draw.cron_job.cron_job_processor**: Draw\Component\CronJob\CronJobProcessor +> - **draw.cron_job.message_handler.execute_cron_job_message_handler**: Draw\Component\CronJob\MessageHandler\ExecuteCronJobMessageHandler + +### Sonata integration + +```yaml +draw_sonata_integration: + cron_job: + enabled: true + admin: + cron_job: + group: Cron Job + entity_class: Draw\Component\CronJob\Entity\CronJob + controller_class: Draw\Bundle\SonataIntegrationBundle\CronJob\Controller\CronJobController + icon: fas fa-clock + label: Cron Job + pager_type: simple + show_in_dashboard: true + translation_domain: SonataAdminBundle + cron_job_execution: + group: Cron Job + entity_class: Draw\Component\CronJob\Entity\CronJobExecution + controller_class: sonata.admin.controller.crud + icon: null + label: Cron Job Execution + pager_type: simple + show_in_dashboard: true + translation_domain: SonataAdminBundle +``` + +### Messenger + +You need to configure the routing for the messenger component for the message that will be used to process the cron jobs. + +```yaml +framework: + messenger: + routing: + Draw\Component\CronJob\Message\ExecuteCronJobMessage: 'async' +``` + +## Usage + +Once the package is enabled, a new admin page will be available - **Cron Job**. The package also provides +2 console commands: +- **draw:cron-job:queue-due** - it is used to process due cron jobs by their configs; it should be configured as a cron to be executed with * * * * * +- **draw:cron-job:queue-by-name** - it allows to manually process a cron job by its name passed as an argument diff --git a/packages/cron-job/Tests/Command/QueueCronJobByNameCommandTest.php b/packages/cron-job/Tests/Command/QueueCronJobByNameCommandTest.php new file mode 100644 index 000000000..fcf4b2c26 --- /dev/null +++ b/packages/cron-job/Tests/Command/QueueCronJobByNameCommandTest.php @@ -0,0 +1,110 @@ +managerRegistry = $this->createMock(ManagerRegistry::class); + $this->managerRegistry + ->expects(static::any()) + ->method('getRepository') + ->with(CronJob::class) + ->willReturn($this->repository = $this->createMock(EntityRepository::class)); + + return new QueueCronJobByNameCommand( + $this->managerRegistry, + $this->cronJobProcessor = $this->createMock(CronJobProcessor::class) + ); + } + + public function getCommandName(): string + { + return 'draw:cron-job:queue-by-name'; + } + + public static function provideTestArgument(): iterable + { + yield ['name', InputArgument::REQUIRED]; + } + + public static function provideTestOption(): iterable + { + return []; + } + + public function testExecuteWithExistingCronJob(): void + { + $this->repository + ->expects(static::once()) + ->method('findOneBy') + ->with(['name' => $cronJobName = 'Existing Cron Job']) + ->willReturn($cronJob = new CronJob()); + + $this->cronJobProcessor + ->expects(static::once()) + ->method('queue') + ->with($cronJob, true); + + $this + ->execute(['name' => $cronJobName]) + ->test( + CommandDataTester::create( + Command::SUCCESS, + [ + 'Queueing cron job...', + 'Cron job successfully queued.', + ] + ) + ); + } + + public function testExecuteWithoutExistingCronJob(): void + { + $this->repository + ->expects(static::once()) + ->method('findOneBy') + ->with(['name' => $cronJobName = 'Invalid Cron Job']) + ->willReturn(null); + + $this->cronJobProcessor + ->expects(static::never()) + ->method('queue'); + + $this + ->execute(['name' => $cronJobName]) + ->test( + CommandDataTester::create( + Command::FAILURE, + [ + '[ERROR] Cron job could not be found.', + ] + ) + ); + } +} diff --git a/packages/cron-job/Tests/Command/QueueDueCronJobsCommandTest.php b/packages/cron-job/Tests/Command/QueueDueCronJobsCommandTest.php new file mode 100644 index 000000000..e7805b180 --- /dev/null +++ b/packages/cron-job/Tests/Command/QueueDueCronJobsCommandTest.php @@ -0,0 +1,181 @@ +managerRegistry = $this->createMock(ManagerRegistry::class), + $this->cronJobProcessor = $this->createMock(CronJobProcessor::class) + ); + } + + public function getCommandName(): string + { + return 'draw:cron-job:queue-due'; + } + + public static function provideTestArgument(): iterable + { + return []; + } + + public static function provideTestOption(): iterable + { + return []; + } + + /** + * @param array{name: string, due: bool}[] $rawCronJobs + * @param string[] $expectedDisplay + */ + #[DataProvider('provideDataForTestExecute')] + public function testExecute(array $rawCronJobs, array $expectedDisplay): void + { + $this->managerRegistry + ->expects(static::any()) + ->method('getRepository') + ->with(CronJob::class) + ->willReturn($repository = $this->createMock(EntityRepository::class)); + + $repository + ->expects(static::once()) + ->method('findBy') + ->with(['active' => true]) + ->willReturn( + $cronJobs = array_map( + fn (array $rawCronJob): CronJob&MockObject => $this->createCronJob( + $rawCronJob['name'], + $rawCronJob['due'] + ), + $rawCronJobs + ) + ); + + $dueCronJobs = array_filter( + $cronJobs, + static fn (CronJob $cronJob): bool => $cronJob->isDue() + ); + + if (0 === $numDueCronJobs = \count($dueCronJobs)) { + $this->cronJobProcessor + ->expects(static::never()) + ->method('queue'); + } else { + $this->cronJobProcessor + ->expects(static::exactly($numDueCronJobs)) + ->method('queue') + ->with( + ...static::withConsecutive(...array_map( + static fn (CronJob $cronJob): array => [$cronJob, false], + $dueCronJobs + )) + ); + } + + $this + ->execute([]) + ->test( + CommandDataTester::create( + Command::SUCCESS, + array_merge( + [ + 'Queueing cron jobs...', + '---------------------', + ], + $expectedDisplay, + [ + '[OK] Cron jobs successfully queued...', + ] + ) + ) + ); + } + + public static function provideDataForTestExecute(): iterable + { + yield [ + '$rawCronJobs' => [], + '$expectedDisplay' => [], + ]; + + yield [ + '$rawCronJobs' => [ + ['name' => 'CronJob1', 'due' => true], + ['name' => 'CronJob2', 'due' => false], + ['name' => 'CronJob3', 'due' => true], + ], + '$expectedDisplay' => [ + '! [NOTE] Queueing cron job "CronJob1"...', + '! [NOTE] Queueing cron job "CronJob3"...', + ], + ]; + + yield [ + '$rawCronJobs' => [ + ['name' => 'CronJob1', 'due' => false], + ['name' => 'CronJob2', 'due' => false], + ['name' => 'CronJob3', 'due' => false], + ['name' => 'CronJob4', 'due' => false], + ], + '$expectedDisplay' => [], + ]; + + yield [ + '$rawCronJobs' => [ + ['name' => 'CronJob1', 'due' => true], + ['name' => 'CronJob2', 'due' => true], + ['name' => 'CronJob3', 'due' => true], + ['name' => 'CronJob4', 'due' => true], + ['name' => 'CronJob5', 'due' => false], + ], + '$expectedDisplay' => [ + '! [NOTE] Queueing cron job "CronJob1"...', + '! [NOTE] Queueing cron job "CronJob2"...', + '! [NOTE] Queueing cron job "CronJob3"...', + '! [NOTE] Queueing cron job "CronJob4"...', + ], + ]; + } + + private function createCronJob(string $name, bool $due): CronJob&MockObject + { + $cronJob = $this->createMock(CronJob::class); + $cronJob + ->expects(static::any()) + ->method('getName') + ->willReturn($name); + $cronJob + ->expects(static::any()) + ->method('isDue') + ->willReturn($due); + + return $cronJob; + } +} diff --git a/packages/cron-job/Tests/CronJobProcessorTest.php b/packages/cron-job/Tests/CronJobProcessorTest.php new file mode 100644 index 000000000..7ef6e24e1 --- /dev/null +++ b/packages/cron-job/Tests/CronJobProcessorTest.php @@ -0,0 +1,266 @@ +createMock(ManagerRegistry::class); + $managerRegistry + ->expects(static::any()) + ->method('getManagerForClass') + ->with(CronJobExecution::class) + ->willReturn($this->entityManager = $this->createMock(EntityManagerInterface::class)); + + $this->cronJobProcessor = new CronJobProcessor( + $managerRegistry, + new ParameterBag([ + 'kernel.cache_dir' => '/var/cache', + ]), + $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class), + $this->processFactory = $this->createMock(ProcessFactoryInterface::class), + $this->messageBus = $this->createMock(MessageBusInterface::class) + ); + } + + #[DataProvider('provideDataForTestQueue')] + public function testQueue(bool $force): void + { + $cronJob = $this->createMock(CronJob::class); + $cronJob + ->expects(static::any()) + ->method('newExecution') + ->with($force) + ->willReturn($execution = $this->createCronJobExecution()); + + $this->entityManager + ->expects(static::once()) + ->method('persist') + ->with($execution); + + $this->entityManager + ->expects(static::once()) + ->method('flush'); + + $this->messageBus + ->expects(static::once()) + ->method('dispatch') + ->with($message = new ExecuteCronJobMessage($execution)) + ->willReturn(new Envelope($message, [])); + + $this->cronJobProcessor->queue($cronJob, $force); + } + + public static function provideDataForTestQueue(): iterable + { + yield 'normal' => ['$force' => false]; + + yield 'forced' => ['$force' => true]; + } + + #[DataProvider('provideDataForTestProcess')] + public function testProcess( + string $command, + ?string $overwrittenCommand, + string $expectedProcessCommand + ): void { + $returnedPreCronJobExecutionEvent = new PreCronJobExecutionEvent( + $execution = $this->createCronJobExecution($command) + ); + + if (null !== $overwrittenCommand) { + $returnedPreCronJobExecutionEvent->setCommand($overwrittenCommand); + } + + $this->eventDispatcher + ->expects(static::exactly(2)) + ->method('dispatch') + ->with( + ...static::withConsecutive( + [ + new PreCronJobExecutionEvent($execution), + ], + [ + $postExecutionEvent = new PostCronJobExecutionEvent($execution), + ] + ) + ) + ->willReturnOnConsecutiveCalls( + $returnedPreCronJobExecutionEvent, + $postExecutionEvent + ); + + $this->entityManager + ->expects(static::exactly(2)) + ->method('flush'); + + $this->processFactory + ->expects(static::once()) + ->method('createFromShellCommandLine') + ->with( + $expectedProcessCommand, + null, + null, + null, + 1800 + ) + ->willReturn($process = $this->createMock(Process::class)); + + $process + ->expects(static::once()) + ->method('mustRun'); + + $this->cronJobProcessor->process($execution); + + static::assertNotNull($execution->getExecutionStartedAt()); + static::assertNotNull($execution->getExecutionEndedAt()); + static::assertEquals( + $execution->getExecutionEndedAt()->getTimestamp() - $execution->getExecutionStartedAt()->getTimestamp(), + $execution->getExecutionDelay() + ); + static::assertEquals(0, $execution->getExitCode()); + static::assertNull($execution->getError()); + } + + public static function provideDataForTestProcess(): iterable + { + yield 'original command' => [ + '$command' => 'bin/console draw:test:successfully', + '$overwrittenCommand' => null, + '$expectedProcessCommand' => 'bin/console draw:test:successfully', + ]; + + yield 'overwritten command' => [ + '$command' => $command = 'ls -lah %kernel.cache_dir%', + '$overwrittenCommand' => sprintf('%s | wc', $command), + '$expectedProcessCommand' => 'ls -lah /var/cache | wc', + ]; + } + + public function testProcessWithError(): void + { + $this->eventDispatcher + ->expects(static::exactly(2)) + ->method('dispatch') + ->with( + ...static::withConsecutive( + [ + $preExecutionEvent = new PreCronJobExecutionEvent( + $execution = $this->createCronJobExecution('echo 12345 > %kernel.cache_dir%/crontab.out') + ), + ], + [ + $postExecutionEvent = new PostCronJobExecutionEvent($execution), + ] + ) + ) + ->willReturnOnConsecutiveCalls($preExecutionEvent, $postExecutionEvent); + + $this->entityManager + ->expects(static::exactly(2)) + ->method('flush'); + + $process = $this->createMock(Process::class); + $process + ->expects(static::any()) + ->method('getExitCode') + ->willReturn($exitCode = 127); + $process + ->expects(static::any()) + ->method('mustRun') + ->willThrowException( + new \Exception( + 'Exception while processing command.', + previous: new \Exception('Nested exception.') + ) + ); + + $this->processFactory + ->expects(static::once()) + ->method('createFromShellCommandLine') + ->with( + 'echo 12345 > /var/cache/crontab.out', + null, + null, + null, + 1800 + ) + ->willReturn($process); + + $this->cronJobProcessor->process($execution); + + static::assertNotNull($execution->getExecutionStartedAt()); + static::assertNotNull($execution->getExecutionEndedAt()); + static::assertNotNull($execution->getExecutionDelay()); + static::assertEquals($exitCode, $execution->getExitCode()); + static::assertNotNull($execution->getError()); + } + + public function testProcessWithCancelledExecution(): void + { + $this->eventDispatcher + ->expects(static::once()) + ->method('dispatch') + ->with( + new PreCronJobExecutionEvent($execution = $this->createCronJobExecution()) + ) + ->willReturn( + new PreCronJobExecutionEvent($execution, true) + ); + + $this->entityManager + ->expects(static::never()) + ->method('flush'); + + $this->processFactory + ->expects(static::never()) + ->method('createFromShellCommandLine'); + + $this->cronJobProcessor->process($execution); + } + + private function createCronJobExecution(string $command = 'bin/console draw:test:execute'): CronJobExecution + { + return new CronJobExecution( + (new CronJob())->setCommand($command), + new \DateTimeImmutable(), + false + ); + } +} diff --git a/packages/cron-job/Tests/Entity/CronJobExecutionTest.php b/packages/cron-job/Tests/Entity/CronJobExecutionTest.php new file mode 100644 index 000000000..a3169b960 --- /dev/null +++ b/packages/cron-job/Tests/Entity/CronJobExecutionTest.php @@ -0,0 +1,87 @@ +setActive($active) + ->setTimeToLive($timeToLive), + $requestedAt, + false + ); + + static::assertSame($expectedExecutable, $execution->isExecutable(Carbon::now()->toDateTimeImmutable())); + } + + public static function provideDataForTestIsExecutable(): iterable + { + yield 'inactive' => [ + '$expectedExecutable' => false, + '$active' => false, + '$timeToLive' => 0, + '$requestedAt' => new \DateTimeImmutable('2024-04-17 00:00:00'), + ]; + + yield 'active with no time to live' => [ + '$expectedExecutable' => true, + '$active' => true, + '$timeToLive' => 0, + '$requestedAt' => new \DateTimeImmutable('2024-04-17 00:00:00'), + '$now' => new \DateTimeImmutable('2024-04-17 01:00:00'), + ]; + + yield 'inactive with no time to live' => [ + '$expectedExecutable' => false, + '$active' => false, + '$timeToLive' => 0, + '$requestedAt' => new \DateTimeImmutable('2024-04-17 00:00:00'), + '$now' => new \DateTimeImmutable('2024-04-17 01:00:00'), + ]; + + yield 'active with exceeding time to live' => [ + '$expectedExecutable' => false, + '$active' => true, + '$timeToLive' => 55, + '$requestedAt' => new \DateTimeImmutable('2024-04-17 00:00:00'), + '$now' => new \DateTimeImmutable('2024-04-17 00:00:59'), + ]; + + yield 'inactive with exceeding time to live' => [ + '$expectedExecutable' => false, + '$active' => false, + '$timeToLive' => 120, + '$requestedAt' => new \DateTimeImmutable('2024-04-17 00:00:00'), + '$now' => new \DateTimeImmutable('2024-04-17 00:00:59'), + ]; + + yield 'active with time to live' => [ + '$expectedExecutable' => true, + '$active' => true, + '$timeToLive' => 180, + '$requestedAt' => new \DateTimeImmutable('2024-04-17 00:00:00'), + '$now' => new \DateTimeImmutable('2024-04-17 00:02:59'), + ]; + } +} diff --git a/packages/cron-job/Tests/MessageHandler/ExecuteCronJobMessageHandlerTest.php b/packages/cron-job/Tests/MessageHandler/ExecuteCronJobMessageHandlerTest.php new file mode 100644 index 000000000..2790df7ef --- /dev/null +++ b/packages/cron-job/Tests/MessageHandler/ExecuteCronJobMessageHandlerTest.php @@ -0,0 +1,64 @@ +handler = new ExecuteCronJobMessageHandler( + $this->cronJobProcessor = $this->createMock(CronJobProcessor::class) + ); + } + + public function testHandleExecuteCronJobMessage(): void + { + $this->cronJobProcessor + ->expects(static::once()) + ->method('process') + ->with($execution = $this->createCronJobExecution()); + + $this->handler->handleExecuteCronJobMessage( + new ExecuteCronJobMessage($execution) + ); + } + + public function testHandleExecuteCronJobMessageWithNotExecutableExecution(): void + { + $this->cronJobProcessor + ->expects(static::never()) + ->method('process'); + + $this->handler->handleExecuteCronJobMessage( + new ExecuteCronJobMessage($this->createCronJobExecution(false)) + ); + } + + private function createCronJobExecution(bool $executable = true): CronJobExecution&MockObject + { + $execution = $this->createMock(CronJobExecution::class); + $execution + ->expects(static::any()) + ->method('isExecutable') + ->willReturn($executable); + + return $execution; + } +} diff --git a/packages/cron-job/composer.json b/packages/cron-job/composer.json new file mode 100644 index 000000000..fe37ae824 --- /dev/null +++ b/packages/cron-job/composer.json @@ -0,0 +1,43 @@ +{ + "name": "draw/cron-job", + "description": "This component allows to manage cron jobs.", + "license": "MIT", + "type": "library", + "keywords": ["draw", "cronjob", "cron", "cron job"], + "authors": [ + { + "name": "Martin Poirier Theoret", + "email": "mpoiriert@gmail.com" + } + ], + "require": { + "php": ">=8.1", + "doctrine/orm": "^2.11", + "dragonmantank/cron-expression": "^3.3", + "draw/core": "^0.11", + "draw/messenger": "^0.11", + "draw/process": "^0.11", + "symfony/amqp-messenger": "^6.4.0", + "symfony/console": "^6.4.0", + "symfony/dependency-injection": "^6.4.0", + "symfony/event-dispatcher": "^6.4.0", + "symfony/messenger": "^6.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0 || ^10.0", + "draw/tester": "^0.11", + "nesbot/carbon": "^2.0" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "autoload": { + "psr-4": { + "Draw\\Component\\CronJob\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-master": "0.11-dev" + } + } +} diff --git a/packages/cron-job/phpunit.xml.dist b/packages/cron-job/phpunit.xml.dist new file mode 100644 index 000000000..4134c1453 --- /dev/null +++ b/packages/cron-job/phpunit.xml.dist @@ -0,0 +1,9 @@ + + + + ./Tests + + + diff --git a/packages/framework-extra-bundle/DependencyInjection/DrawFrameworkExtraExtension.php b/packages/framework-extra-bundle/DependencyInjection/DrawFrameworkExtraExtension.php index 2d6ad3887..962bae2ae 100644 --- a/packages/framework-extra-bundle/DependencyInjection/DrawFrameworkExtraExtension.php +++ b/packages/framework-extra-bundle/DependencyInjection/DrawFrameworkExtraExtension.php @@ -6,6 +6,7 @@ use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Integration\ConfigurationIntegration; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Integration\ConsoleIntegration; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Integration\CronIntegration; +use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Integration\CronJobIntegration; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Integration\DoctrineExtraIntegration; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Integration\EntityMigratorIntegration; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Integration\FeatureIntegration; @@ -52,6 +53,7 @@ private function registerDefaultIntegrations(): void $this->integrations[] = new ConfigurationIntegration(); $this->integrations[] = new ConsoleIntegration(); $this->integrations[] = new CronIntegration(); + $this->integrations[] = new CronJobIntegration(); $this->integrations[] = new DoctrineExtraIntegration(); $this->integrations[] = new EntityMigratorIntegration(); $this->integrations[] = new FeatureIntegration(); diff --git a/packages/framework-extra-bundle/DependencyInjection/Integration/CronJobIntegration.php b/packages/framework-extra-bundle/DependencyInjection/Integration/CronJobIntegration.php new file mode 100644 index 000000000..90811273e --- /dev/null +++ b/packages/framework-extra-bundle/DependencyInjection/Integration/CronJobIntegration.php @@ -0,0 +1,64 @@ +registerClasses( + $loader, + $namespace = 'Draw\\Component\\CronJob\\', + \dirname((new \ReflectionClass(CronJobProcessor::class))->getFileName()) + ); + + $this->renameDefinitions( + $container, + $namespace, + 'draw.cron_job.' + ); + } + + public function addConfiguration(ArrayNodeDefinition $node): void + { + // nothing to do + } + + public function prepend(ContainerBuilder $container, array $config): void + { + $this->assertHasExtension($container, 'doctrine'); + + $reflection = new \ReflectionClass(CronJob::class); + + $container->prependExtensionConfig( + 'doctrine', + [ + 'orm' => [ + 'mappings' => [ + 'DrawCronJob' => [ + 'is_bundle' => false, + 'type' => 'attribute', + 'dir' => \dirname($reflection->getFileName()), + 'prefix' => $reflection->getNamespaceName(), + ], + ], + ], + ] + ); + } +} diff --git a/packages/framework-extra-bundle/Tests/DependencyInjection/Integration/CronJobIntegrationTest.php b/packages/framework-extra-bundle/Tests/DependencyInjection/Integration/CronJobIntegrationTest.php new file mode 100644 index 000000000..a6234fc26 --- /dev/null +++ b/packages/framework-extra-bundle/Tests/DependencyInjection/Integration/CronJobIntegrationTest.php @@ -0,0 +1,66 @@ +getTimeout() ); } + + public function testCreateFromShellCommandLineDefault(): void + { + $process = $this->service->createFromShellCommandLine('ls -lah | grep test'); + + static::assertInstanceOf(Process::class, $process); + static::assertSame('ls -lah | grep test', $process->getCommandLine()); + static::assertSame(getcwd(), $process->getWorkingDirectory()); + static::assertEmpty($process->getEnv()); + static::assertNull($process->getInput()); + static::assertSame(60.0, $process->getTimeout()); + } + + public function testCreateFromShellCommandLineWithArguments(): void + { + $process = $this->service->createFromShellCommandLine( + 'ls -lah | grep test', + $workingDirectory = __DIR__, + $env = ['key' => 'value'], + $input = 'input', + $timeout = 5.0 + ); + + static::assertInstanceOf(Process::class, $process); + static::assertSame('ls -lah | grep test', $process->getCommandLine()); + static::assertSame($workingDirectory, $process->getWorkingDirectory()); + static::assertSame($env, $process->getEnv()); + static::assertSame($input, $process->getInput()); + static::assertSame($timeout, $process->getTimeout()); + } } diff --git a/packages/sonata-extra-bundle/Configuration/SonataAdminNodeConfiguration.php b/packages/sonata-extra-bundle/Configuration/SonataAdminNodeConfiguration.php index f3ce93096..469fdb8c8 100644 --- a/packages/sonata-extra-bundle/Configuration/SonataAdminNodeConfiguration.php +++ b/packages/sonata-extra-bundle/Configuration/SonataAdminNodeConfiguration.php @@ -57,6 +57,13 @@ public function pagerTypeDefaultValue(?string $value): self return $this; } + public function translationDomainDefaultValue(?string $value): self + { + $this->children['translation_domain']->defaultValue($value); + + return $this; + } + public static function configureFromConfiguration(Definition $definition, array $config): Definition { return $definition diff --git a/packages/sonata-integration-bundle/CronJob/Admin/CronJobAdmin.php b/packages/sonata-integration-bundle/CronJob/Admin/CronJobAdmin.php new file mode 100644 index 000000000..c68fac7ac --- /dev/null +++ b/packages/sonata-integration-bundle/CronJob/Admin/CronJobAdmin.php @@ -0,0 +1,116 @@ +add('id') + ->add('name') + ->add('command') + ->add('active'); + } + + protected function configureListFields(ListMapper $list): void + { + $list + ->addIdentifier('name') + ->add('command') + ->add('schedule') + ->add('active', null, ['editable' => true]) + ->add('timeToLive') + ->add('priority') + ->add( + ListMapper::NAME_ACTIONS, + ListMapper::TYPE_ACTIONS, + [ + 'actions' => [ + 'show' => [], + 'edit' => [], + 'queue' => [ + 'template' => '@DrawSonataIntegration/CronJob/CronJob/list__action_queue.html.twig', + ], + 'delete' => [], + ], + ] + ); + } + + protected function configureFormFields(FormMapper $form): void + { + $form + ->with('Definition', ['class' => 'col-md-8']) + ->add('name') + ->add( + 'command', + null, + [ + 'help' => 'Parameters bag is available. Use like %kernel.project_dir%', + ] + ) + ->add('schedule') + ->add('active') + ->end() + ->with('Queue Configuration', ['class' => 'col-md-4']) + ->add('timeToLive') + ->add('priority') + ->end(); + } + + protected function configureShowFields(ShowMapper $show): void + { + /** @var CronJobExecutionAdmin $executionAdmin */ + $executionAdmin = $this->getConfigurationPool()->getAdminByAdminCode(CronJobExecutionAdmin::class); + + $show + ->add('name') + ->add('command') + ->add('schedule') + ->add('active') + ->add('timeToLive') + ->add('priority') + ->ifTrue(!$this->getSubject()->getExecutions()->isEmpty()) + ->add( + 'recentExecutions', + 'grid', + [ + 'fieldValueOnly' => false, + 'colspan' => true, + 'fieldsAdmin' => $executionAdmin, + 'fields' => $executionAdmin->configureGridFields([]), + ] + ) + ->ifEnd(); + } + + protected function configureRoutes(RouteCollectionInterface $collection): void + { + $collection->add('queue', sprintf('%s/queue', $this->getRouterIdParameter())); + } + + protected function configureActionButtons(array $buttonList, string $action, ?object $object = null): array + { + if ('show' === $action) { + $buttonList['queue'] = [ + 'template' => '@DrawSonataIntegration/CronJob/CronJob/show__action_queue.html.twig', + ]; + } + + return $buttonList; + } +} diff --git a/packages/sonata-integration-bundle/CronJob/Admin/CronJobExecutionAdmin.php b/packages/sonata-integration-bundle/CronJob/Admin/CronJobExecutionAdmin.php new file mode 100644 index 000000000..c1c635b59 --- /dev/null +++ b/packages/sonata-integration-bundle/CronJob/Admin/CronJobExecutionAdmin.php @@ -0,0 +1,119 @@ +add( + 'cronJob', + ModelFilter::class, + [ + 'field_type' => ModelAutocompleteType::class, + 'field_options' => [ + 'property' => 'name', + ], + 'show_filter' => true, + ] + ) + ->add('requestedAt') + ->add('force') + ->add('executionStartedAt') + ->add('executionEndedAt') + ->add('executionDelay') + ->add( + 'exitCode', + filterOptions: [ + 'show_filter' => true, + ] + ); + + } + + protected function configureListFields(ListMapper $list): void + { + $list + ->addIdentifier('id') + ->add( + 'cronJob', + fieldDescriptionOptions: [ + 'sortable' => 'cronJob.name', + ] + ) + ->add('requestedAt') + ->add('force') + ->add('executionStartedAt') + ->add('executionEndedAt') + ->add('executionDelay') + ->add('exitCode') + ->add( + ListMapper::NAME_ACTIONS, + ListMapper::TYPE_ACTIONS, + [ + 'actions' => [ + 'show' => [], + 'delete' => [], + ], + ] + ); + } + + protected function configureShowFields(ShowMapper $show): void + { + $show + ->add('requestedAt') + ->add('force') + ->add('executionStartedAt') + ->add('executionEndedAt') + ->add('executionDelay') + ->add('exitCode') + ->add('error', 'json'); + } + + protected function configureRoutes(RouteCollectionInterface $collection): void + { + $collection->clearExcept(['list', 'show', 'delete']); + } + + public function configureGridFields(array $fields): array + { + return array_merge( + $fields, + [ + 'requestedAt' => [], + 'force' => [], + 'executionStartedAt' => [], + 'executionEndedAt' => [], + 'executionDelay' => [], + 'exitCode' => [], + 'actions' => [ + 'type' => ListMapper::TYPE_ACTIONS, + 'options' => [ + 'virtual_field' => true, + 'admin' => $this, + 'actions' => [ + 'show' => [ + 'label' => 'Show', + 'icon' => 'fa-eye', + 'route_object' => 'show', + 'check_callback' => fn (object $object) => $this->hasAccess('show', $object), + ], + ], + ], + ], + ] + ); + } +} diff --git a/packages/sonata-integration-bundle/CronJob/Controller/CronJobController.php b/packages/sonata-integration-bundle/CronJob/Controller/CronJobController.php new file mode 100644 index 000000000..028db6443 --- /dev/null +++ b/packages/sonata-integration-bundle/CronJob/Controller/CronJobController.php @@ -0,0 +1,38 @@ +queue($cronJob, true); + + $this->addFlash( + 'sonata_flash_success', + $this->trans( + 'cron_job_successfully_queued', + domain: 'DrawCronJobSonata' + ) + ); + + return $this->redirect( + $this->admin->generateObjectUrl( + 'show', + $cronJob, + $this->getSelectedTab($request) + ) + ); + } +} diff --git a/packages/sonata-integration-bundle/DependencyInjection/Configuration.php b/packages/sonata-integration-bundle/DependencyInjection/Configuration.php index 178242ea1..402123124 100644 --- a/packages/sonata-integration-bundle/DependencyInjection/Configuration.php +++ b/packages/sonata-integration-bundle/DependencyInjection/Configuration.php @@ -6,11 +6,14 @@ use App\Sonata\Admin\UserAdmin; use Draw\Bundle\SonataExtraBundle\Configuration\SonataAdminNodeConfiguration; use Draw\Bundle\SonataIntegrationBundle\Console\Controller\ExecutionController; +use Draw\Bundle\SonataIntegrationBundle\CronJob\Controller\CronJobController; use Draw\Bundle\SonataIntegrationBundle\User\Extension\TwoFactorAuthenticationExtension; use Draw\Bundle\UserBundle\DrawUserBundle; use Draw\Bundle\UserBundle\Entity\UserLock; use Draw\Component\Application\Configuration\Entity\Config; use Draw\Component\Console\Entity\Execution; +use Draw\Component\CronJob\Entity\CronJob; +use Draw\Component\CronJob\Entity\CronJobExecution; use Draw\Component\EntityMigrator\Entity\Migration; use Draw\Component\Messenger\Broker\Broker; use Sonata\AdminBundle\Datagrid\ListMapper; @@ -27,6 +30,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->children() ->append($this->createConfigurationNode()) ->append($this->createConsoleNode()) + ->append($this->createCronJobNode()) ->append($this->createEntityMigratorNode()) ->append($this->createMessengerNode()) ->append($this->createUserNode()) @@ -83,6 +87,34 @@ private function createConsoleNode(): ArrayNodeDefinition ->end(); } + private function createCronJobNode(): ArrayNodeDefinition + { + return (new ArrayNodeDefinition('cron_job')) + ->canBeDisabled() + ->children() + ->arrayNode('admin') + ->addDefaultsIfNotSet() + ->append( + (new SonataAdminNodeConfiguration(CronJob::class, 'Cron Job', 'cron_job')) + ->addDefaultsIfNotSet() + ->pagerTypeDefaultValue('simple') + ->controllerClassDefaultValue(CronJobController::class) + ->labelDefaultValue('Cron Job') + ->iconDefaultValue('fas fa-clock') + ->translationDomainDefaultValue('DrawCronJobAdmin') + ) + ->append( + (new SonataAdminNodeConfiguration(CronJobExecution::class, 'Cron Job', 'cron_job_execution')) + ->addDefaultsIfNotSet() + ->pagerTypeDefaultValue('simple') + ->labelDefaultValue('Cron Job Execution') + ->iconDefaultValue('fas fa-clock') + ->translationDomainDefaultValue('DrawCronJobAdmin') + ) + ->end() + ->end(); + } + private function createEntityMigratorNode(): ArrayNodeDefinition { return (new ArrayNodeDefinition('entity_migrator')) diff --git a/packages/sonata-integration-bundle/DependencyInjection/DrawSonataIntegrationExtension.php b/packages/sonata-integration-bundle/DependencyInjection/DrawSonataIntegrationExtension.php index 4b2eceb93..a700ae65a 100644 --- a/packages/sonata-integration-bundle/DependencyInjection/DrawSonataIntegrationExtension.php +++ b/packages/sonata-integration-bundle/DependencyInjection/DrawSonataIntegrationExtension.php @@ -7,6 +7,8 @@ use Draw\Bundle\SonataIntegrationBundle\Console\Admin\ExecutionAdmin; use Draw\Bundle\SonataIntegrationBundle\Console\Command; use Draw\Bundle\SonataIntegrationBundle\Console\CommandRegistry; +use Draw\Bundle\SonataIntegrationBundle\CronJob\Admin\CronJobAdmin; +use Draw\Bundle\SonataIntegrationBundle\CronJob\Admin\CronJobExecutionAdmin; use Draw\Bundle\SonataIntegrationBundle\EntityMigrator\Admin\MigrationAdmin; use Draw\Bundle\SonataIntegrationBundle\Messenger\Admin\MessengerMessageAdmin; use Draw\Bundle\SonataIntegrationBundle\Messenger\EventListener\FinalizeContextQueueCountEventListener; @@ -44,6 +46,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->configureConfiguration($config['configuration'], $loader, $container); $this->configureConsole($config['console'], $loader, $container); + $this->configureCronJob($config['cron_job'], $loader, $container); $this->configureEntityMigrator($config['entity_migrator'], $loader, $container); $this->configureMessenger($config['messenger'], $loader, $container); $this->configureUser($config['user'], $loader, $container); @@ -89,15 +92,7 @@ private function configureConsole(array $config, Loader\FileLoader $loader, Cont ->setAutowired(true) ->setAutoconfigured(true); - if (!$container->hasDefinition($config['admin']['controller_class'])) { - $container->setDefinition( - $config['admin']['controller_class'], - (new Definition($config['admin']['controller_class'])) - ->setAutoconfigured(true) - ->setAutowired(true) - ->addTag('controller.service_arguments') - ); - } + $this->setControllerClassDefinition($config['admin'], $container); $definition = $container ->setDefinition( @@ -118,6 +113,33 @@ private function configureConsole(array $config, Loader\FileLoader $loader, Cont } } + private function configureCronJob(array $config, Loader\FileLoader $loader, ContainerBuilder $container): void + { + if (!$config['enabled']) { + return; + } + + foreach ( + [ + 'cron_job' => CronJobAdmin::class, + 'cron_job_execution' => CronJobExecutionAdmin::class, + ] as $adminId => $adminClass + ) { + $container + ->setDefinition( + $adminClass, + SonataAdminNodeConfiguration::configureFromConfiguration( + new Definition($adminClass), + $config['admin'][$adminId] + ) + ) + ->setAutowired(true) + ->setAutoconfigured(true); + } + + $this->setControllerClassDefinition($config['admin']['cron_job'], $container); + } + private function configureMessenger(array $config, Loader\FileLoader $loader, ContainerBuilder $container): void { if (!$config['enabled']) { @@ -401,4 +423,19 @@ private function prependUser(array $config, ContainerBuilder $container): void ); } } + + private function setControllerClassDefinition(array $config, ContainerBuilder $container): void + { + if ($container->hasDefinition($config['controller_class'])) { + return; + } + + $container->setDefinition( + $config['controller_class'], + (new Definition($config['controller_class'])) + ->setAutoconfigured(true) + ->setAutowired(true) + ->addTag('controller.service_arguments') + ); + } } diff --git a/packages/sonata-integration-bundle/Resources/translations/DrawCronJobSonata.en.yaml b/packages/sonata-integration-bundle/Resources/translations/DrawCronJobSonata.en.yaml new file mode 100644 index 000000000..700ca6aff --- /dev/null +++ b/packages/sonata-integration-bundle/Resources/translations/DrawCronJobSonata.en.yaml @@ -0,0 +1 @@ +cron_job_successfully_queued: Cron job successfully queued. diff --git a/packages/sonata-integration-bundle/Resources/views/CronJob/CronJob/list__action_queue.html.twig b/packages/sonata-integration-bundle/Resources/views/CronJob/CronJob/list__action_queue.html.twig new file mode 100644 index 000000000..17d89713d --- /dev/null +++ b/packages/sonata-integration-bundle/Resources/views/CronJob/CronJob/list__action_queue.html.twig @@ -0,0 +1,6 @@ + + Queue + diff --git a/packages/sonata-integration-bundle/Resources/views/CronJob/CronJob/show__action_queue.html.twig b/packages/sonata-integration-bundle/Resources/views/CronJob/CronJob/show__action_queue.html.twig new file mode 100644 index 000000000..188663bb1 --- /dev/null +++ b/packages/sonata-integration-bundle/Resources/views/CronJob/CronJob/show__action_queue.html.twig @@ -0,0 +1,8 @@ +
  • + + Queue + +
  • diff --git a/packages/sonata-integration-bundle/Tests/DependencyInjection/ConfigurationTest.php b/packages/sonata-integration-bundle/Tests/DependencyInjection/ConfigurationTest.php index e0c6f9779..15937f168 100644 --- a/packages/sonata-integration-bundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/packages/sonata-integration-bundle/Tests/DependencyInjection/ConfigurationTest.php @@ -5,11 +5,14 @@ use App\Entity\MessengerMessage; use App\Sonata\Admin\UserAdmin; use Draw\Bundle\SonataIntegrationBundle\Console\Controller\ExecutionController; +use Draw\Bundle\SonataIntegrationBundle\CronJob\Controller\CronJobController; use Draw\Bundle\SonataIntegrationBundle\DependencyInjection\Configuration; use Draw\Bundle\SonataIntegrationBundle\User\Extension\TwoFactorAuthenticationExtension; use Draw\Bundle\UserBundle\Entity\UserLock; use Draw\Component\Application\Configuration\Entity\Config; use Draw\Component\Console\Entity\Execution; +use Draw\Component\CronJob\Entity\CronJob; +use Draw\Component\CronJob\Entity\CronJobExecution; use Draw\Component\EntityMigrator\Entity\Migration; use Draw\Component\Tester\DependencyInjection\ConfigurationTestCase; use Sonata\AdminBundle\Datagrid\ListMapper; @@ -52,6 +55,31 @@ public function getDefaultConfiguration(): array ], 'commands' => [], ], + 'cron_job' => [ + 'enabled' => true, + 'admin' => [ + 'cron_job' => [ + 'group' => 'Cron Job', + 'entity_class' => CronJob::class, + 'controller_class' => CronJobController::class, + 'icon' => 'fas fa-clock', + 'label' => 'Cron Job', + 'pager_type' => 'simple', + 'show_in_dashboard' => true, + 'translation_domain' => 'DrawCronJobAdmin', + ], + 'cron_job_execution' => [ + 'group' => 'Cron Job', + 'entity_class' => CronJobExecution::class, + 'controller_class' => 'sonata.admin.controller.crud', + 'icon' => 'fas fa-clock', + 'label' => 'Cron Job Execution', + 'pager_type' => 'simple', + 'show_in_dashboard' => true, + 'translation_domain' => 'DrawCronJobAdmin', + ], + ], + ], 'entity_migrator' => [ 'enabled' => false, 'admin' => [ diff --git a/packages/sonata-integration-bundle/Tests/DependencyInjection/DrawSonataIntegrationExtensionCronJobEnabledTest.php b/packages/sonata-integration-bundle/Tests/DependencyInjection/DrawSonataIntegrationExtensionCronJobEnabledTest.php new file mode 100644 index 000000000..b2bb8f6e4 --- /dev/null +++ b/packages/sonata-integration-bundle/Tests/DependencyInjection/DrawSonataIntegrationExtensionCronJobEnabledTest.php @@ -0,0 +1,34 @@ + true, + ]; + + return $configuration; + } + + public static function provideTestHasServiceDefinition(): iterable + { + yield [CronJobAdmin::class]; + yield [CronJobExecutionAdmin::class]; + yield [CronJobController::class]; + } +} diff --git a/packages/sonata-integration-bundle/Tests/DependencyInjection/DrawSonataIntegrationExtensionTest.php b/packages/sonata-integration-bundle/Tests/DependencyInjection/DrawSonataIntegrationExtensionTest.php index ddbd94f32..23cb9319a 100644 --- a/packages/sonata-integration-bundle/Tests/DependencyInjection/DrawSonataIntegrationExtensionTest.php +++ b/packages/sonata-integration-bundle/Tests/DependencyInjection/DrawSonataIntegrationExtensionTest.php @@ -24,6 +24,9 @@ public function getConfiguration(): array 'console' => [ 'enabled' => false, ], + 'cron_job' => [ + 'enabled' => false, + ], 'messenger' => [ 'enabled' => false, ], diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 1edbbb483..bf9c97098 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,5 +1,10 @@ parameters: ignoreErrors: + - + message: "#^Method Draw\\\\Component\\\\CronJob\\\\Entity\\\\CronJob\\:\\:getRecentExecutions\\(\\) should return Doctrine\\\\Common\\\\Collections\\\\Collection&Doctrine\\\\Common\\\\Collections\\\\Selectable&iterable\\ but returns Doctrine\\\\Common\\\\Collections\\\\ReadableCollection\\<\\(int\\|string\\), mixed\\>&Doctrine\\\\Common\\\\Collections\\\\Selectable\\<\\(int\\|string\\), mixed\\>\\.$#" + count: 1 + path: packages/cron-job/Entity/CronJob.php + - message: "#^Parameter \\#1 \\$node of method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeBuilder\\:\\:append\\(\\) expects Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeDefinition, Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeParentInterface\\|null given\\.$#" count: 1 diff --git a/vendor-bin/monorepo/composer.lock b/vendor-bin/monorepo/composer.lock index 6c504e401..b88f4ff9e 100644 --- a/vendor-bin/monorepo/composer.lock +++ b/vendor-bin/monorepo/composer.lock @@ -488,16 +488,16 @@ }, { "name": "symfony/config", - "version": "v5.4.36", + "version": "v5.4.38", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "0a4f363dc2f13d2f871f917cc563796d9ddc78d1" + "reference": "3dcd47d4bbd9fea4d1210e7a7a0a5ca02d99df14" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/0a4f363dc2f13d2f871f917cc563796d9ddc78d1", - "reference": "0a4f363dc2f13d2f871f917cc563796d9ddc78d1", + "url": "https://api.github.com/repos/symfony/config/zipball/3dcd47d4bbd9fea4d1210e7a7a0a5ca02d99df14", + "reference": "3dcd47d4bbd9fea4d1210e7a7a0a5ca02d99df14", "shasum": "" }, "require": { @@ -547,7 +547,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v5.4.36" + "source": "https://github.com/symfony/config/tree/v5.4.38" }, "funding": [ { @@ -563,7 +563,7 @@ "type": "tidelift" } ], - "time": "2024-02-23T16:13:23+00:00" + "time": "2024-03-22T10:04:40+00:00" }, { "name": "symfony/console", @@ -666,16 +666,16 @@ }, { "name": "symfony/dependency-injection", - "version": "v5.4.36", + "version": "v5.4.38", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "cc1fb237cd0e6da33005062b13b8485deb6e4440" + "reference": "0ba1fa459d284a9398c71afa1cb5d13de025de17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/cc1fb237cd0e6da33005062b13b8485deb6e4440", - "reference": "cc1fb237cd0e6da33005062b13b8485deb6e4440", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/0ba1fa459d284a9398c71afa1cb5d13de025de17", + "reference": "0ba1fa459d284a9398c71afa1cb5d13de025de17", "shasum": "" }, "require": { @@ -735,7 +735,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v5.4.36" + "source": "https://github.com/symfony/dependency-injection/tree/v5.4.38" }, "funding": [ { @@ -751,7 +751,7 @@ "type": "tidelift" } ], - "time": "2024-02-22T18:43:31+00:00" + "time": "2024-03-18T16:56:51+00:00" }, { "name": "symfony/deprecation-contracts", @@ -976,16 +976,16 @@ }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.4.0", + "version": "v3.4.2", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "a76aed96a42d2b521153fb382d418e30d18b59df" + "reference": "4e64b49bf370ade88e567de29465762e316e4224" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/a76aed96a42d2b521153fb382d418e30d18b59df", - "reference": "a76aed96a42d2b521153fb382d418e30d18b59df", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/4e64b49bf370ade88e567de29465762e316e4224", + "reference": "4e64b49bf370ade88e567de29465762e316e4224", "shasum": "" }, "require": { @@ -1032,7 +1032,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.4.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.4.2" }, "funding": [ { @@ -1048,20 +1048,20 @@ "type": "tidelift" } ], - "time": "2023-05-23T14:45:45+00:00" + "time": "2024-01-23T14:51:35+00:00" }, { "name": "symfony/filesystem", - "version": "v5.4.35", + "version": "v5.4.38", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "5a553607d4ffbfa9c0ab62facadea296c9db7086" + "reference": "899330a01056077271e2f614c7b28b0379a671eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/5a553607d4ffbfa9c0ab62facadea296c9db7086", - "reference": "5a553607d4ffbfa9c0ab62facadea296c9db7086", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/899330a01056077271e2f614c7b28b0379a671eb", + "reference": "899330a01056077271e2f614c7b28b0379a671eb", "shasum": "" }, "require": { @@ -1096,7 +1096,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v5.4.35" + "source": "https://github.com/symfony/filesystem/tree/v5.4.38" }, "funding": [ { @@ -1112,7 +1112,7 @@ "type": "tidelift" } ], - "time": "2024-01-23T13:51:25+00:00" + "time": "2024-03-21T08:05:07+00:00" }, { "name": "symfony/finder", @@ -1256,16 +1256,16 @@ }, { "name": "symfony/http-kernel", - "version": "v5.4.37", + "version": "v5.4.38", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "4ef7ed872564852b3c6c15fecf492975a52cbff3" + "reference": "21c32c7c6c32ea8d0f4b8e88a2607a2dc72799e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/4ef7ed872564852b3c6c15fecf492975a52cbff3", - "reference": "4ef7ed872564852b3c6c15fecf492975a52cbff3", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/21c32c7c6c32ea8d0f4b8e88a2607a2dc72799e5", + "reference": "21c32c7c6c32ea8d0f4b8e88a2607a2dc72799e5", "shasum": "" }, "require": { @@ -1348,7 +1348,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v5.4.37" + "source": "https://github.com/symfony/http-kernel/tree/v5.4.38" }, "funding": [ { @@ -1364,7 +1364,7 @@ "type": "tidelift" } ], - "time": "2024-03-04T20:55:44+00:00" + "time": "2024-04-02T19:56:39+00:00" }, { "name": "symfony/polyfill-ctype", @@ -1825,16 +1825,16 @@ }, { "name": "symfony/service-contracts", - "version": "v2.5.2", + "version": "v2.5.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "4b426aac47d6427cc1a1d0f7e2ac724627f5966c" + "reference": "a2329596ddc8fd568900e3fc76cba42489ecc7f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/4b426aac47d6427cc1a1d0f7e2ac724627f5966c", - "reference": "4b426aac47d6427cc1a1d0f7e2ac724627f5966c", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/a2329596ddc8fd568900e3fc76cba42489ecc7f3", + "reference": "a2329596ddc8fd568900e3fc76cba42489ecc7f3", "shasum": "" }, "require": { @@ -1888,7 +1888,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v2.5.2" + "source": "https://github.com/symfony/service-contracts/tree/v2.5.3" }, "funding": [ { @@ -1904,7 +1904,7 @@ "type": "tidelift" } ], - "time": "2022-05-30T19:17:29+00:00" + "time": "2023-04-21T15:04:16+00:00" }, { "name": "symfony/string", @@ -1994,16 +1994,16 @@ }, { "name": "symfony/var-dumper", - "version": "v6.4.4", + "version": "v6.4.6", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "b439823f04c98b84d4366c79507e9da6230944b1" + "reference": "95bd2706a97fb875185b51ecaa6112ec184233d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/b439823f04c98b84d4366c79507e9da6230944b1", - "reference": "b439823f04c98b84d4366c79507e9da6230944b1", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/95bd2706a97fb875185b51ecaa6112ec184233d4", + "reference": "95bd2706a97fb875185b51ecaa6112ec184233d4", "shasum": "" }, "require": { @@ -2059,7 +2059,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.4" + "source": "https://github.com/symfony/var-dumper/tree/v6.4.6" }, "funding": [ { @@ -2075,7 +2075,7 @@ "type": "tidelift" } ], - "time": "2024-02-15T11:23:52+00:00" + "time": "2024-03-19T11:56:30+00:00" }, { "name": "symfony/yaml",