diff --git a/src/Messenger/Kernel/CommandBusDependencies.php b/src/Messenger/Kernel/CommandBusDependencies.php index 406b2fd..b9ccf03 100644 --- a/src/Messenger/Kernel/CommandBusDependencies.php +++ b/src/Messenger/Kernel/CommandBusDependencies.php @@ -9,4 +9,6 @@ enum CommandBusDependencies: string { case Logger = self::class.'::Logger'; case EventDispatcher = self::class.'::EventDispatcher'; case Serializer = self::class.'::Serializer'; + case Worker = self::class.'::Worker'; + case SupervisorConfigDir = self::class.'::SupervisorConfigDir'; } diff --git a/src/Messenger/Kernel/MessengerServiceFactory.php b/src/Messenger/Kernel/MessengerServiceFactory.php index 5728522..5645e08 100644 --- a/src/Messenger/Kernel/MessengerServiceFactory.php +++ b/src/Messenger/Kernel/MessengerServiceFactory.php @@ -13,6 +13,7 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Console\Application; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Messenger\Command\ConsumeMessagesCommand; use Symfony\Component\Messenger\Command\StopWorkersCommand; @@ -31,6 +32,8 @@ use WonderNetwork\SlimKernel\Messenger\QueryBus; use WonderNetwork\SlimKernel\ServiceFactory; use WonderNetwork\SlimKernel\ServicesBuilder; +use WonderNetwork\SlimKernel\Supervisor\GenerateSupervisorConfigCommand; +use WonderNetwork\SlimKernel\Supervisor\SupervisorConfiguration; use function DI\autowire; use function DI\create; use function DI\factory; @@ -41,10 +44,12 @@ public function __construct( private string $commandPath = '/src/Application/Command/**/*Handler.php', private string $queryPath = '/src/Application/Query/**/*Handler.php', + private string $supervisorConfigDir = 'app/supervisor', private null|Closure|Reference|DefinitionHelper|TransportLocatorBuilder $transports = null, private null|Closure|Reference|DefinitionHelper|EventDispatcher $eventDispatcher = null, private null|Closure|Reference|DefinitionHelper|LoggerInterface $logger = null, private null|Closure|Reference|DefinitionHelper|CacheItemPoolInterface $cachePool = null, + private null|Closure|Reference|DefinitionHelper|SupervisorConfiguration $programs = null, ) { } @@ -66,7 +71,7 @@ public function __invoke(ServicesBuilder $builder): iterable { // region utilities yield CommandBusDependencies::Serializer->value => factory(fn () => Serializer::create()); yield SerializerInterface::class => get(CommandBusDependencies::Serializer->value); - yield CommandBusDependencies::EventDispatcher->value => $this->eventDispatcher ?? new EventDispatcher(); + yield CommandBusDependencies::EventDispatcher->value => $this->eventDispatcher ?? get(EventDispatcher::class); yield CommandBusDependencies::Logger->value => $this->logger ?? new NullLogger(); yield CommandBusDependencies::CachePool->value => $this->cachePool ?? new ArrayAdapter(); // endregion @@ -126,5 +131,29 @@ public function __invoke(ServicesBuilder $builder): iterable { yield StopWorkersCommand::class => autowire()->constructor( restartSignalCachePool: get(CommandBusDependencies::CachePool->value), ); + + yield CommandBusDependencies::SupervisorConfigDir->value => $this->supervisorConfigDir; + yield SupervisorConfiguration::class => $this->programs ?? SupervisorConfiguration::empty(); + yield GenerateSupervisorConfigCommand::class => autowire()->constructor( + configDir: get(CommandBusDependencies::SupervisorConfigDir->value), + ); + + yield CommandBusDependencies::Worker->value => function (ContainerInterface $container) { + $app = new Application('worker'); + /** @var EventDispatcher $eventDispatcher */ + $eventDispatcher = $container->get(CommandBusDependencies::EventDispatcher->value); + + $app->setDispatcher($eventDispatcher); + $app->addCommands( + [ + $container->get(StopWorkersCommand::class), + $container->get(ConsumeMessagesCommand::class), + $container->get(GenerateSupervisorConfigCommand::class), + ], + ); + $app->setAutoExit(false); + + return $app; + }; } } diff --git a/src/ServiceFactory/EventDispatcherServiceFactory.php b/src/ServiceFactory/EventDispatcherServiceFactory.php index 9e726c4..422e36b 100644 --- a/src/ServiceFactory/EventDispatcherServiceFactory.php +++ b/src/ServiceFactory/EventDispatcherServiceFactory.php @@ -7,6 +7,7 @@ use Psr\EventDispatcher as Psr; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\EventDispatcher as Contracts; use WonderNetwork\SlimKernel\ServiceFactory; use WonderNetwork\SlimKernel\ServicesBuilder; use function DI\autowire; @@ -17,5 +18,6 @@ public function __invoke(ServicesBuilder $builder): iterable { yield EventDispatcher::class => autowire(); yield EventDispatcherInterface::class => get(EventDispatcher::class); yield Psr\EventDispatcherInterface::class => get(EventDispatcher::class); + yield Contracts\EventDispatcherInterface::class => get(EventDispatcher::class); } } diff --git a/src/Supervisor/GenerateSupervisorConfigCommand.php b/src/Supervisor/GenerateSupervisorConfigCommand.php new file mode 100644 index 0000000..fc923c9 --- /dev/null +++ b/src/Supervisor/GenerateSupervisorConfigCommand.php @@ -0,0 +1,130 @@ +addArgument( + name: 'current-directory', + mode: InputArgument::REQUIRED, + description: 'The absolute directory to the script', + ); + + $this->addOption( + name: 'stdio', + mode: InputOption::VALUE_NONE, + description: "Log to stdout/stderr instead of files", + ); + + $this->addOption( + name: 'purge', + mode: InputOption::VALUE_NONE | InputOption::VALUE_NEGATABLE, + description: 'Remove all files before generating new ones', + ); + + $this->addOption( + name: 'logfile', + mode: InputOption::VALUE_REQUIRED, + default: '/var/log/supervisor/{processName}.{suffix}.log', + ); + + $this->addOption( + name: 'logfile-maxbytes', + mode: InputOption::VALUE_REQUIRED, + default: "50MB", + ); + + $this->addOption( + name: 'config-dir', + mode: InputOption::VALUE_REQUIRED, + default: $this->configDir, + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $io = new SymfonyStyle($input, $output); + + $currentDirectory = $this->params->arguments->string('current-directory'); + $stdio = $this->params->options->bool('stdio'); + $noPurge = $this->params->options->bool('no-purge'); + $logfile = $this->params->options->string('logfile'); + $maxBytes = $this->params->options->string('logfile-maxbytes'); + $configDir = $this->params->options->string('config-dir'); + + if ("" === $configDir) { + $io->error("Config directory can't be empty"); + + return self::FAILURE; + } + + if (false === $noPurge) { + foreach (glob(sprintf('%s/*.conf', $configDir)) ?: [] as $preExistingConfig) { + unlink($preExistingConfig); + } + } + + if ($stdio) { + $maxBytes = "0"; + $logfile = '/dev/std{suffix}'; + } + + foreach ($this->configuration->programs as $program) { + $concurrency = $program->concurrency; + $processName = $program->name; + + if ($concurrency > 1) { + $processName = '%(program_name)s_%(process_num)02d'; + } + + $errorLog = strtr($logfile, ['{processName}' => $processName, '{suffix}' => 'err']); + $standardLog = strtr($logfile, ['{processName}' => $processName, '{suffix}' => 'out']); + + $fullCommand = sprintf('%s/%s', rtrim($currentDirectory, '/'), $program->command); + $supervisorConfigPath = sprintf('%s/%s.conf', $configDir, $program->name); + + $supervisorConfig = <<name] + command=$fullCommand + process_name=$processName + numprocs=$concurrency + user=www-data + autostart=true + autorestart=true + stderr_logfile=$errorLog + stderr_logfile_maxbytes=$maxBytes + stdout_logfile=$standardLog + stdout_logfile_maxbytes=$maxBytes + EOF; + + file_put_contents($supervisorConfigPath, $supervisorConfig); + $io->writeln( + sprintf( + 'Writing %s configuration file to %s', + $program->name, + $supervisorConfigPath, + ), + ); + } + + return self::SUCCESS; + } +} diff --git a/src/Supervisor/SupervisorConfiguration.php b/src/Supervisor/SupervisorConfiguration.php new file mode 100644 index 0000000..339578f --- /dev/null +++ b/src/Supervisor/SupervisorConfiguration.php @@ -0,0 +1,29 @@ + $programs + */ + public function __construct(public array $programs) { + } + + public function withPrograms(SupervisorProgram ...$programs): self { + return new self([...$this->programs, ...array_values($programs)]); + } + + public function withSimpleCommand(string $name, string $command): self { + return new self([...$this->programs, SupervisorProgram::single($name, $command)]); + } +} diff --git a/src/Supervisor/SupervisorProgram.php b/src/Supervisor/SupervisorProgram.php new file mode 100644 index 0000000..2681a64 --- /dev/null +++ b/src/Supervisor/SupervisorProgram.php @@ -0,0 +1,25 @@ +withSimpleCommand('worker', 'bin/worker async') + ->withPrograms( + new SupervisorProgram( + name: 'jobs', + command: 'bin/worker jobs', + concurrency: 3, + ), + ); + $configDir = __DIR__.'/../Resources/Supervisor'; + $sut = new GenerateSupervisorConfigCommand( + configDir: $configDir, + configuration: $programs, + ); + + $output = new BufferedOutput(); + $argv = [(string) $sut->getName(), '--config-dir', $configDir, '/var/app/current']; + $sut->run(new ArgvInput($argv), $output); + + self::assertSame( + <<fetch()), + ); + + $files = glob($configDir.'/*.conf') ?: []; + sort($files); + + self::assertSame(["$configDir/jobs.conf", "$configDir/worker.conf"], $files); + self::assertSame( + << file_get_contents($filename), + $files, + ), + ), + ); + } + + public function testGeneratesConfigWithStdio(): void { + $programs = SupervisorConfiguration::start() + ->withSimpleCommand('worker', 'bin/worker async') + ->withPrograms( + new SupervisorProgram( + name: 'jobs', + command: 'bin/worker jobs', + concurrency: 3, + ), + ); + $configDir = __DIR__.'/../Resources/Supervisor'; + $sut = new GenerateSupervisorConfigCommand( + configDir: $configDir, + configuration: $programs, + ); + + $output = new BufferedOutput(); + $argv = [(string) $sut->getName(), '--stdio', '--config-dir', $configDir, '/var/app/current']; + $sut->run(new ArgvInput($argv), $output); + + $files = glob($configDir.'/*.conf') ?: []; + sort($files); + + self::assertSame( + << file_get_contents($filename), + $files, + ), + ), + ); + } +}