├── LICENSE ├── UPGRADE.md ├── bootstrap.php ├── composer.json ├── config └── services.php ├── crunz ├── resources └── config │ └── crunz.yml └── src ├── Application.php ├── Application ├── Cron │ ├── CronExpressionFactoryInterface.php │ └── CronExpressionInterface.php ├── Query │ └── TaskInformation │ │ ├── TaskInformation.php │ │ ├── TaskInformationHandler.php │ │ └── TaskInformationView.php └── Service │ ├── ClosureSerializerInterface.php │ ├── ConfigurationInterface.php │ └── LoggerFactoryInterface.php ├── Clock ├── Clock.php └── ClockInterface.php ├── Configuration ├── ConfigFileNotExistsException.php ├── ConfigFileNotReadableException.php ├── Configuration.php ├── ConfigurationParser.php ├── ConfigurationParserInterface.php ├── Definition.php └── FileParser.php ├── Console └── Command │ ├── Command.php │ ├── ConfigGeneratorCommand.php │ ├── ScheduleListCommand.php │ ├── ScheduleRunCommand.php │ └── TaskGeneratorCommand.php ├── EnvFlags └── EnvFlags.php ├── Event.php ├── EventRunner.php ├── Exception ├── CrunzException.php ├── EmptyTimezoneException.php ├── MailerException.php ├── NotImplementedException.php ├── TaskNotExistException.php └── WrongTaskNumberException.php ├── Filesystem ├── Filesystem.php └── FilesystemInterface.php ├── Finder ├── Finder.php └── FinderInterface.php ├── HttpClient ├── CurlHttpClient.php ├── FallbackHttpClient.php ├── HttpClientException.php ├── HttpClientInterface.php ├── HttpClientLoggerDecorator.php └── StreamHttpClient.php ├── Infrastructure ├── Dragonmantank │ └── CronExpression │ │ ├── DragonmantankCronExpression.php │ │ └── DragonmantankCronExpressionFactory.php ├── Opis │ └── Closure │ │ └── OpisClosureSerializer.php └── Psr │ └── Logger │ ├── EnabledLoggerDecorator.php │ ├── PsrStreamLogger.php │ └── PsrStreamLoggerFactory.php ├── Invoker.php ├── Logger ├── ConsoleLogger.php ├── ConsoleLoggerInterface.php ├── Logger.php └── LoggerFactory.php ├── Mailer.php ├── Output └── OutputFactory.php ├── Path └── Path.php ├── Pinger ├── PingableException.php ├── PingableInterface.php └── PingableTrait.php ├── Process └── Process.php ├── ProcessUtils.php ├── Schedule.php ├── Schedule └── ScheduleFactory.php ├── Stubs └── BasicTask.php ├── Task ├── Collection.php ├── Loader.php ├── LoaderInterface.php ├── TaskException.php ├── TaskNumber.php ├── Timezone.php └── WrongTaskInstanceException.php ├── Timezone ├── Provider.php └── ProviderInterface.php └── UserInterface └── Cli ├── ClosureRunCommand.php └── DebugTaskCommand.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Moe Reza Lavarian 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | # Upgrading from v1.12 to v2.0 2 | 3 | ## Stop using `mail` transport for mailer 4 | 5 | As of `v6.0` SwiftMailer dropped support for `mail` transport, 6 | so `Crunz` `v2.0` won't support it either, 7 | please use `smtp` or `sendmail` transport. 8 | 9 | # Upgrading from v1.11 to v1.12 10 | 11 | ## Always return `\Crunz\Schedule` from task files 12 | 13 | Example of wrong task file: 14 | 15 | ```php 16 | run('php -v') 31 | ->description('PHP version') 32 | ->everyMinute(); 33 | 34 | // Crunz\Schedule instance returned 35 | return $scheduler; 36 | ``` 37 | 38 | ## Stop using `\Crunz\Event::setProcess` 39 | 40 | If you, for some reason, use above method you should stop it. 41 | This method was intended to be `private` and will be in `v2.0`, 42 | which will lead to exception if you call it. 43 | 44 | Example of wrong usage 45 | 46 | ```php 47 | run('php -v'); 54 | $task 55 | // setProcess is deprecated 56 | ->setProcess($process) 57 | ->description('PHP version') 58 | ->everyMinute() 59 | ; 60 | 61 | return $scheduler; 62 | ``` 63 | 64 | # Upgrading from v1.10 to v1.11 65 | 66 | ## Run `Crunz` in directory with your `crunz.yml` 67 | 68 | Searching for Crunz's config is now related to `cwd`, not to `vendor/bin/crunz`. 69 | 70 | For example, if your `crunz.yml` is in `/var/www/project/crunz.yml`, then run Crunz with `cd` first: 71 | ```bash 72 | cd /var/www/project && vendor/bin/crunz schedule:list 73 | ``` 74 | 75 | Cron job also should be changed: 76 | ```bash 77 | * * * * * cd /var/www/project && vendor/bin/crunz schedule:run 78 | ``` 79 | 80 | # Upgrading from v1.9 to v1.10 81 | 82 | ### Do not pass more than five parts to `Crunz\Event::cron()` 83 | 84 | Example correct call: 85 | ```yaml 86 | $event = new Crunz\Event; 87 | $event->cron('0 * * * *'); 88 | ``` 89 | 90 | # Upgrading from v1.7 to v1.8 91 | 92 | ### Add `timezone` to your `crunz.yml` 93 | 94 | Example config file: 95 | ```yaml 96 | source: tasks 97 | suffix: Tasks.php 98 | timezone: Europe/Warsaw 99 | ``` 100 | -------------------------------------------------------------------------------- /bootstrap.php: -------------------------------------------------------------------------------- 1 | disableDeprecationHandler(); 8 | 9 | // Make sure current working directory is "tests" 10 | $filesystem = new \Crunz\Filesystem\Filesystem(); 11 | if (\strpos($filesystem->getCwd(), 'tests') !== false) { 12 | return; 13 | } 14 | 15 | if (!\chdir('tests')) { 16 | throw new RuntimeException("Unable to change current directory to 'tests'."); 17 | } 18 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lavary/crunz", 3 | "description": "Schedule your tasks right from the code.", 4 | "type": "library", 5 | "keywords": [ 6 | "scheduler", 7 | "cron jobs", 8 | "cron", 9 | "Task Scheduler", 10 | "PHP Task Scheduler", 11 | "Job Scheduler", 12 | "Job Manager", 13 | "Event Runner" 14 | ], 15 | "homepage": "https://github.com/lavary/crunz", 16 | "license": "MIT", 17 | "authors": [ 18 | { 19 | "name": "Reza M. Lavaryan", 20 | "email": "mrl.8081@gmail.com" 21 | } 22 | ], 23 | "support": { 24 | "email": "mrl.8081@gmail.com", 25 | "issues": "http://github.com/lavary/crunz/issues" 26 | }, 27 | "require": { 28 | "php": ">=7.4", 29 | "dragonmantank/cron-expression": "^3.1", 30 | "opis/closure": "^3.5", 31 | "symfony/config": "^4.4 || ^5.2", 32 | "symfony/console": "^4.4 || ^5.2", 33 | "symfony/dependency-injection": "^4.4 || ^5.2", 34 | "symfony/filesystem": "^4.4 || ^5.2", 35 | "symfony/lock": "^4.4 || ^5.2", 36 | "symfony/mailer": "^5.3", 37 | "symfony/process": "^4.4 || ^5.2", 38 | "symfony/yaml": "^4.4 || ^5.2" 39 | }, 40 | "require-dev": { 41 | "ext-json": "*", 42 | "ext-mbstring": "*", 43 | "bamarni/composer-bin-plugin": "^1.2", 44 | "phpunit/phpunit": "^9.5.2", 45 | "symfony/error-handler": "^4.4 || ^5.2", 46 | "symfony/phpunit-bridge": "^4.4.8 || ^5.2.0" 47 | }, 48 | "autoload": { 49 | "psr-4": { 50 | "Crunz\\": "src/" 51 | } 52 | }, 53 | "autoload-dev": { 54 | "psr-4": { 55 | "Crunz\\Tests\\": "tests/" 56 | } 57 | }, 58 | "bin": [ 59 | "crunz" 60 | ], 61 | "minimum-stability": "dev", 62 | "prefer-stable": true, 63 | "config": { 64 | "sort-packages": true 65 | }, 66 | "scripts": { 67 | "crunz:cs-fix": "@php vendor/bin/php-cs-fixer fix --diff -v --ansi", 68 | "crunz:analyze": [ 69 | "@php vendor/bin/php-cs-fixer fix --diff --dry-run -v", 70 | "@phpstan:check" 71 | ], 72 | "crunz:link-changelog": "@php vendor/bin/changelog-linker dump-merges --dry-run --in-categories", 73 | "crunz:link-changelog:since": "@php vendor/bin/changelog-linker dump-merges --dry-run --in-categories --since-id", 74 | "phpstan:check": "@php vendor/bin/phpstan analyse -c phpstan.neon src tests crunz config bootstrap.php" 75 | }, 76 | "abandoned": "crunzphp/crunz" 77 | } 78 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | Provider::class, 64 | Filesystem::class, 65 | ScheduleFactory::class, 66 | StreamHttpClient::class, 67 | CurlHttpClient::class, 68 | FilesystemInterface::class => CrunzFilesystem::class, 69 | FinderInterface::class => Finder::class, 70 | LoaderInterface::class => Loader::class, 71 | CronExpressionFactoryInterface::class => DragonmantankCronExpressionFactory::class, 72 | ClosureSerializerInterface::class => OpisClosureSerializer::class, 73 | ClockInterface::class => Clock::class, 74 | ]; 75 | 76 | /* @var ContainerBuilder $container */ 77 | 78 | $container 79 | ->register(ScheduleRunCommand::class, ScheduleRunCommand::class) 80 | ->setPublic(true) 81 | ->setArguments( 82 | [ 83 | new Reference(Collection::class), 84 | new Reference(ConfigurationInterface::class), 85 | new Reference(EventRunner::class), 86 | new Reference(Timezone::class), 87 | new Reference(ScheduleFactory::class), 88 | new Reference(LoaderInterface::class), 89 | ] 90 | ) 91 | ; 92 | $container 93 | ->register(ClosureRunCommand::class, ClosureRunCommand::class) 94 | ->setArguments( 95 | [ 96 | new Reference(ClosureSerializerInterface::class), 97 | ] 98 | ) 99 | ->setPublic(true) 100 | ; 101 | $container 102 | ->register(ConfigGeneratorCommand::class, ConfigGeneratorCommand::class) 103 | ->setPublic(true) 104 | ->setArguments( 105 | [ 106 | new Reference(ProviderInterface::class), 107 | new Reference(Filesystem::class), 108 | new Reference(FilesystemInterface::class), 109 | ] 110 | ) 111 | ; 112 | $container 113 | ->register(ScheduleListCommand::class, ScheduleListCommand::class) 114 | ->setPublic(true) 115 | ->setArguments( 116 | [ 117 | new Reference(ConfigurationInterface::class), 118 | new Reference(Collection::class), 119 | new Reference(LoaderInterface::class), 120 | ] 121 | ) 122 | ; 123 | $container 124 | ->register(TaskGeneratorCommand::class, TaskGeneratorCommand::class) 125 | ->setPublic(true) 126 | ->setArguments( 127 | [ 128 | new Reference(ConfigurationInterface::class), 129 | new Reference(FilesystemInterface::class), 130 | ] 131 | ) 132 | ; 133 | $container 134 | ->register(DebugTaskCommand::class, DebugTaskCommand::class) 135 | ->setPublic(true) 136 | ->setArguments( 137 | [ 138 | new Reference(TaskInformationHandler::class), 139 | ] 140 | ) 141 | ; 142 | $container 143 | ->register(Collection::class, Collection::class) 144 | ->setPublic(false) 145 | ->setArguments( 146 | [ 147 | new Reference(ConfigurationInterface::class), 148 | new Reference(FinderInterface::class), 149 | new Reference(ConsoleLoggerInterface::class), 150 | ] 151 | ) 152 | ; 153 | $container 154 | ->register(FileParser::class, FileParser::class) 155 | ->setPublic(false) 156 | ->setArguments( 157 | [ 158 | new Reference(Yaml::class), 159 | ] 160 | ) 161 | ; 162 | $container 163 | ->register(ConfigurationInterface::class, Configuration::class) 164 | ->setPublic(false) 165 | ->setArguments( 166 | [ 167 | new Reference(ConfigurationParserInterface::class), 168 | new Reference(FilesystemInterface::class), 169 | ] 170 | ) 171 | ; 172 | $container 173 | ->register(Mailer::class, Mailer::class) 174 | ->setPublic(false) 175 | ->setArguments( 176 | [ 177 | new Reference(ConfigurationInterface::class), 178 | ] 179 | ) 180 | ; 181 | $container 182 | ->register(LoggerFactory::class, LoggerFactory::class) 183 | ->setPublic(false) 184 | ->setArguments( 185 | [ 186 | new Reference(ConfigurationInterface::class), 187 | new Reference(Timezone::class), 188 | new Reference(ConsoleLoggerInterface::class), 189 | new Reference(ClockInterface::class), 190 | ] 191 | ) 192 | ; 193 | $container 194 | ->register(EventRunner::class, EventRunner::class) 195 | ->setPublic(false) 196 | ->setArguments( 197 | [ 198 | new Reference(Invoker::class), 199 | new Reference(ConfigurationInterface::class), 200 | new Reference(Mailer::class), 201 | new Reference(LoggerFactory::class), 202 | new Reference(HttpClientInterface::class), 203 | new Reference(ConsoleLoggerInterface::class), 204 | ] 205 | ) 206 | ; 207 | $container 208 | ->register(Timezone::class, Timezone::class) 209 | ->setPublic(false) 210 | ->setArguments( 211 | [ 212 | new Reference(ConfigurationInterface::class), 213 | new Reference(ConsoleLoggerInterface::class), 214 | ] 215 | ) 216 | ; 217 | $container 218 | ->register(OutputInterface::class, ConsoleOutput::class) 219 | ->setPublic(true) 220 | ->setFactory([new Reference(OutputFactory::class), 'createOutput']) 221 | ; 222 | $container 223 | ->register(OutputFactory::class, OutputFactory::class) 224 | ->setPublic(false) 225 | ->setArguments( 226 | [ 227 | new Reference(InputInterface::class), 228 | ] 229 | ) 230 | ; 231 | $container 232 | ->register(InputInterface::class, ArgvInput::class) 233 | ->setPublic(true) 234 | ; 235 | $container 236 | ->register(SymfonyStyle::class, SymfonyStyle::class) 237 | ->setPublic(true) 238 | ->setArguments( 239 | [ 240 | new Reference(InputInterface::class), 241 | new Reference(OutputInterface::class), 242 | ] 243 | ) 244 | ; 245 | $container 246 | ->register(ConsoleLoggerInterface::class, ConsoleLogger::class) 247 | ->setPublic(false) 248 | ->setArguments( 249 | [ 250 | new Reference(SymfonyStyle::class), 251 | ] 252 | ) 253 | ; 254 | $container 255 | ->register(ConsoleLoggerInterface::class, ConsoleLogger::class) 256 | ->setPublic(false) 257 | ->setArguments( 258 | [ 259 | new Reference(SymfonyStyle::class), 260 | ] 261 | ) 262 | ; 263 | $container 264 | ->register(FallbackHttpClient::class, FallbackHttpClient::class) 265 | ->setPublic(false) 266 | ->setArguments( 267 | [ 268 | new Reference(StreamHttpClient::class), 269 | new Reference(CurlHttpClient::class), 270 | new Reference(ConsoleLoggerInterface::class), 271 | ] 272 | ) 273 | ; 274 | $container 275 | ->register(HttpClientInterface::class, HttpClientLoggerDecorator::class) 276 | ->setPublic(false) 277 | ->setArguments( 278 | [ 279 | new Reference(FallbackHttpClient::class), 280 | new Reference(ConsoleLoggerInterface::class), 281 | ] 282 | ) 283 | ; 284 | $container 285 | ->register(ConfigurationParserInterface::class, ConfigurationParser::class) 286 | ->setPublic(false) 287 | ->setArguments( 288 | [ 289 | new Reference(Definition::class), 290 | new Reference(Processor::class), 291 | new Reference(FileParser::class), 292 | new Reference(ConsoleLoggerInterface::class), 293 | new Reference(FilesystemInterface::class), 294 | ] 295 | ) 296 | ; 297 | 298 | $container 299 | ->register(TaskInformationHandler::class, TaskInformationHandler::class) 300 | ->setPublic(false) 301 | ->setArguments( 302 | [ 303 | new Reference(Timezone::class), 304 | new Reference(ConfigurationInterface::class), 305 | new Reference(Collection::class), 306 | new Reference(LoaderInterface::class), 307 | new Reference(ScheduleFactory::class), 308 | new Reference(CronExpressionFactoryInterface::class), 309 | ] 310 | ) 311 | ; 312 | 313 | foreach ($simpleServices as $id => $simpleService) { 314 | if (!\is_string($id)) { 315 | $id = $simpleService; 316 | } 317 | 318 | $container 319 | ->register($id, $simpleService) 320 | ->setPublic(false) 321 | ; 322 | } 323 | -------------------------------------------------------------------------------- /crunz: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 11 | | For the full copyright and license information, please view the LICENSE 12 | | file that was distributed with this source code. 13 | | 14 | */ 15 | 16 | if (!\defined('CRUNZ_BIN')) { 17 | \define('CRUNZ_BIN', __FILE__); 18 | } 19 | 20 | $generatePath = function (array $parts) { 21 | return \implode(DIRECTORY_SEPARATOR, $parts); 22 | }; 23 | $autoloadPaths = [ 24 | // Dependency 25 | $generatePath( 26 | [ 27 | __DIR__, 28 | '..', 29 | '..', 30 | 'autoload.php' 31 | ] 32 | ), 33 | // Vendor/Bin 34 | $generatePath( 35 | [ 36 | __DIR__, 37 | '..', 38 | 'autoload.php' 39 | ] 40 | ), 41 | // Local dev 42 | $generatePath( 43 | [ 44 | __DIR__, 45 | 'vendor', 46 | 'autoload.php' 47 | ] 48 | ), 49 | ]; 50 | 51 | $autoloadFileFound = false; 52 | 53 | foreach ($autoloadPaths as $autoloadPath) { 54 | if (\file_exists($autoloadPath)) { 55 | require_once $autoloadPath; 56 | $autoloadFileFound = true; 57 | break; 58 | } 59 | } 60 | 61 | if ($autoloadFileFound === false) { 62 | throw new RuntimeException( 63 | \sprintf( 64 | 'Unable to find "vendor/autoload.php" in "%s" paths.', 65 | \implode('", "', $autoloadPaths) 66 | ) 67 | ); 68 | } 69 | 70 | $application = new Crunz\Application('Crunz Command Line Interface', 'v3.1-dev'); 71 | $application->run(); 72 | -------------------------------------------------------------------------------- /resources/config/crunz.yml: -------------------------------------------------------------------------------- 1 | # Crunz Configuration Settings 2 | 3 | # This option defines where the task files and 4 | # directories reside. 5 | # The path is relative to this config file. 6 | # Trailing slashes will be ignored. 7 | source: tasks 8 | 9 | # The suffix is meant to target the task files inside the ":source" directory. 10 | # Please note if you change this value, you need 11 | # to make sure all the existing tasks files are renamed accordingly. 12 | suffix: Tasks.php 13 | 14 | # Timezone is used to calculate task run time 15 | # This option is very important and not setting it is deprecated 16 | # and will result in exception in 2.0 version. 17 | timezone: ~ 18 | 19 | # This option define which timezone should be used for log files 20 | # If false, system default timezone will be used 21 | # If true, the timezone in config file that is used to calculate task run time will be used 22 | timezone_log: false 23 | 24 | # By default the errors are not logged by Crunz 25 | # You may set the value to true for logging the errors 26 | log_errors: false 27 | 28 | # This is the absolute path to the errors' log file 29 | # You need to make sure you have the required permission to write to this file though. 30 | errors_log_file: ~ 31 | 32 | # By default the output is not logged as they are redirected to the 33 | # null output. 34 | # Set this to true if you want to keep the outputs 35 | log_output: false 36 | 37 | # This is the absolute path to the global output log file 38 | # The events which have dedicated log files (defined with them), won't be 39 | # logged to this file though. 40 | output_log_file: ~ 41 | 42 | # By default line breaks in logs aren't allowed. 43 | # Set the value to true to allow them. 44 | log_allow_line_breaks: false 45 | 46 | # By default empty context arrays are shown in the log. 47 | # Set the value to true to remove them. 48 | log_ignore_empty_context: false 49 | 50 | # This option determines whether the output should be emailed or not. 51 | email_output: false 52 | 53 | # This option determines whether the error messages should be emailed or not. 54 | email_errors: false 55 | 56 | # Global Swift Mailer settings 57 | mailer: 58 | # Possible values: smtp, mail, and sendmail 59 | transport: smtp 60 | recipients: 61 | sender_name: 62 | sender_email: 63 | 64 | 65 | # SMTP settings 66 | smtp: 67 | host: ~ 68 | port: ~ 69 | username: ~ 70 | password: ~ 71 | encryption: ~ 72 | -------------------------------------------------------------------------------- /src/Application.php: -------------------------------------------------------------------------------- 1 | envFlags = new EnvFlags(); 66 | 67 | $this->initializeContainer(); 68 | $this->registerDeprecationHandler(); 69 | 70 | foreach (self::COMMANDS as $commandClass) { 71 | /** @var Command $command */ 72 | $command = $this->container 73 | ->get($commandClass) 74 | ; 75 | 76 | $this->add($command); 77 | } 78 | } 79 | 80 | public function run(InputInterface $input = null, OutputInterface $output = null) 81 | { 82 | if (null === $output) { 83 | /** @var OutputInterface $outputObject */ 84 | $outputObject = $this->container 85 | ->get(OutputInterface::class); 86 | 87 | $output = $outputObject; 88 | } 89 | 90 | if (null === $input) { 91 | /** @var InputInterface $inputObject */ 92 | $inputObject = $this->container 93 | ->get(InputInterface::class); 94 | 95 | $input = $inputObject; 96 | } 97 | 98 | return parent::run($input, $output); 99 | } 100 | 101 | private function initializeContainer(): void 102 | { 103 | $containerCacheDirWritable = $this->createBaseCacheDirectory(); 104 | $isContainerDebugEnabled = $this->envFlags 105 | ->isContainerDebugEnabled(); 106 | 107 | if ($containerCacheDirWritable) { 108 | $class = 'CrunzContainer'; 109 | $baseClass = 'Container'; 110 | $cachePath = Path::create( 111 | [ 112 | $this->getContainerCacheDir(), 113 | "{$class}.php", 114 | ] 115 | ); 116 | $cache = new ConfigCache($cachePath->toString(), $isContainerDebugEnabled); 117 | 118 | if (!$cache->isFresh()) { 119 | $containerBuilder = $this->buildContainer(); 120 | $containerBuilder->compile(); 121 | 122 | $this->dumpContainer( 123 | $cache, 124 | $containerBuilder, 125 | $class, 126 | $baseClass 127 | ); 128 | } 129 | 130 | require_once $cache->getPath(); 131 | 132 | $this->container = new $class(); 133 | 134 | return; 135 | } 136 | 137 | $containerBuilder = $this->buildContainer(); 138 | $containerBuilder->compile(); 139 | 140 | $this->container = $containerBuilder; 141 | } 142 | 143 | /** 144 | * @return ContainerBuilder 145 | * 146 | * @throws \Exception 147 | */ 148 | private function buildContainer() 149 | { 150 | $containerBuilder = new ContainerBuilder(); 151 | $configDir = Path::create( 152 | [ 153 | __DIR__, 154 | '..', 155 | 'config', 156 | ] 157 | ); 158 | 159 | $phpLoader = new PhpFileLoader($containerBuilder, new FileLocator($configDir->toString())); 160 | $phpLoader->load('services.php'); 161 | 162 | return $containerBuilder; 163 | } 164 | 165 | private function dumpContainer( 166 | ConfigCache $cache, 167 | ContainerBuilder $container, 168 | string $class, 169 | string $baseClass 170 | ): void { 171 | $dumper = new PhpDumper($container); 172 | 173 | /** @var string $content */ 174 | $content = $dumper->dump( 175 | [ 176 | 'class' => $class, 177 | 'base_class' => $baseClass, 178 | 'file' => $cache->getPath(), 179 | ] 180 | ); 181 | 182 | $cache->write($content, $container->getResources()); 183 | } 184 | 185 | /** 186 | * @return bool 187 | */ 188 | private function createBaseCacheDirectory() 189 | { 190 | $baseCacheDir = $this->getBaseCacheDir(); 191 | 192 | if (!\is_dir($baseCacheDir)) { 193 | $makeDirResult = \mkdir( 194 | $this->getBaseCacheDir(), 195 | 0777, 196 | true 197 | ); 198 | 199 | return $makeDirResult 200 | && \is_dir($baseCacheDir) 201 | && \is_writable($baseCacheDir) 202 | ; 203 | } 204 | 205 | return \is_writable($baseCacheDir); 206 | } 207 | 208 | /** 209 | * @return string 210 | */ 211 | private function getBaseCacheDir() 212 | { 213 | $baseCacheDir = Path::create( 214 | [ 215 | \sys_get_temp_dir(), 216 | '.crunz', 217 | ] 218 | ); 219 | 220 | return $baseCacheDir->toString(); 221 | } 222 | 223 | /** 224 | * @return string 225 | */ 226 | private function getContainerCacheDir() 227 | { 228 | $containerCacheDir = Path::create( 229 | [ 230 | $this->getBaseCacheDir(), 231 | \get_current_user(), 232 | $this->getVersion(), 233 | ] 234 | ); 235 | 236 | return $containerCacheDir->toString(); 237 | } 238 | 239 | private function registerDeprecationHandler(): void 240 | { 241 | $isDeprecationHandlerEnabled = $this->envFlags 242 | ->isDeprecationHandlerEnabled(); 243 | 244 | if (!$isDeprecationHandlerEnabled) { 245 | return; 246 | } 247 | 248 | /** @var SymfonyStyle $io */ 249 | $io = $this->container 250 | ->get(SymfonyStyle::class); 251 | 252 | \set_error_handler( 253 | static function ( 254 | int $errorNumber, 255 | string $errorString, 256 | string $file, 257 | int $line 258 | ) use ($io): bool { 259 | $io->block( 260 | "{$errorString} File {$file}, line {$line}", 261 | 'Deprecation', 262 | 'bg=yellow;fg=black', 263 | ' ', 264 | true 265 | ); 266 | 267 | return true; 268 | }, 269 | E_USER_DEPRECATED 270 | ); 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /src/Application/Cron/CronExpressionFactoryInterface.php: -------------------------------------------------------------------------------- 1 | taskNumber = $taskNumber; 17 | } 18 | 19 | public function taskNumber(): TaskNumber 20 | { 21 | return $this->taskNumber; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Application/Query/TaskInformation/TaskInformationHandler.php: -------------------------------------------------------------------------------- 1 | timezone = $timezone; 39 | $this->configuration = $configuration; 40 | $this->taskCollection = $taskCollection; 41 | $this->taskLoader = $taskLoader; 42 | $this->scheduleFactory = $scheduleFactory; 43 | $this->cronExpressionFactory = $cronExpressionFactory; 44 | } 45 | 46 | public function handle(TaskInformation $taskInformation): TaskInformationView 47 | { 48 | $source = $this->configuration 49 | ->getSourcePath() 50 | ; 51 | /** @var \SplFileInfo[] $files */ 52 | $files = $this->taskCollection 53 | ->all($source) 54 | ; 55 | 56 | // List of schedules 57 | $schedules = $this->taskLoader 58 | ->load(...\array_values($files)) 59 | ; 60 | 61 | $timezoneForComparisons = $this->timezone 62 | ->timezoneForComparisons() 63 | ; 64 | $event = $this->scheduleFactory 65 | ->singleTask($taskInformation->taskNumber(), ...$schedules) 66 | ; 67 | 68 | $cronExpression = $this->cronExpressionFactory 69 | ->createFromString($event->getExpression()) 70 | ; 71 | $nextRunTimezone = $timezoneForComparisons; 72 | $eventProperties = $this->getEventProperties($event, ['timezone', 'preventOverlapping']); 73 | $eventTimezone = $eventProperties['timezone']; 74 | if (\is_string($eventTimezone)) { 75 | $eventTimezone = new \DateTimeZone($eventTimezone); 76 | $nextRunTimezone = $eventTimezone; 77 | } 78 | 79 | $nextRuns = $cronExpression->multipleRunDates( 80 | 5, 81 | new \DateTimeImmutable(), 82 | $nextRunTimezone 83 | ); 84 | 85 | return new TaskInformationView( 86 | $event->getCommand(), 87 | $event->description ?? '', 88 | $event->getExpression(), 89 | $eventProperties['preventOverlapping'] ?? false, 90 | $eventTimezone, 91 | $timezoneForComparisons, 92 | ...$nextRuns 93 | ); 94 | } 95 | 96 | /** 97 | * @param string[] $properties 98 | * 99 | * @return array 100 | */ 101 | private function getEventProperties(Event $event, array $properties): array 102 | { 103 | $propertiesExtractor = function () use ($properties, $event): array { 104 | $values = []; 105 | foreach ($properties as $property) { 106 | if (!\property_exists($event, $property)) { 107 | $class = \get_class($event); 108 | 109 | throw new \RuntimeException("Property '{$property}' doesn't exists in '{$class}' class."); 110 | } 111 | 112 | $values[$property] = $this->{$property}; 113 | } 114 | 115 | return $values; 116 | }; 117 | 118 | return $propertiesExtractor->bindTo($event, Event::class)(); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Application/Query/TaskInformation/TaskInformationView.php: -------------------------------------------------------------------------------- 1 | command = $command; 35 | $this->description = $description; 36 | $this->cronExpression = $cronExpression; 37 | $this->timeZone = $timeZone; 38 | $this->configTimeZone = $configTimeZone; 39 | $this->nextRuns = $nextRuns; 40 | $this->preventOverlapping = $preventOverlapping; 41 | } 42 | 43 | /** @return string|object */ 44 | public function command() 45 | { 46 | return $this->command; 47 | } 48 | 49 | public function description(): string 50 | { 51 | return $this->description; 52 | } 53 | 54 | public function cronExpression(): string 55 | { 56 | return $this->cronExpression; 57 | } 58 | 59 | public function timeZone(): ?\DateTimeZone 60 | { 61 | return $this->timeZone; 62 | } 63 | 64 | public function configTimeZone(): \DateTimeZone 65 | { 66 | return $this->configTimeZone; 67 | } 68 | 69 | /** @return \DateTimeImmutable[] */ 70 | public function nextRuns(): array 71 | { 72 | return $this->nextRuns; 73 | } 74 | 75 | public function preventOverlapping(): bool 76 | { 77 | return $this->preventOverlapping; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Application/Service/ClosureSerializerInterface.php: -------------------------------------------------------------------------------- 1 | */ 14 | private $config; 15 | /** @var ConfigurationParserInterface */ 16 | private $configurationParser; 17 | /** @var FilesystemInterface */ 18 | private $filesystem; 19 | 20 | public function __construct(ConfigurationParserInterface $configurationParser, FilesystemInterface $filesystem) 21 | { 22 | $this->configurationParser = $configurationParser; 23 | $this->filesystem = $filesystem; 24 | } 25 | 26 | /** 27 | * Return a parameter based on a key. 28 | * 29 | * @param mixed $default 30 | * 31 | * @return mixed 32 | */ 33 | public function get(string $key, $default = null) 34 | { 35 | if (null === $this->config) { 36 | $this->config = $this->configurationParser 37 | ->parseConfig(); 38 | } 39 | 40 | if (\array_key_exists($key, $this->config)) { 41 | return $this->config[$key]; 42 | } 43 | 44 | $parts = \explode('.', $key); 45 | 46 | $value = $this->config; 47 | foreach ($parts as $part) { 48 | if (!\is_array($value) || !\array_key_exists($part, $value)) { 49 | return $default; 50 | } 51 | 52 | $value = $value[$part]; 53 | } 54 | 55 | return $value; 56 | } 57 | 58 | /** 59 | * Set a parameter based on key/value. 60 | * 61 | * @param mixed $value 62 | */ 63 | public function withNewEntry(string $key, $value): ConfigurationInterface 64 | { 65 | $newConfiguration = clone $this; 66 | 67 | if (null === $newConfiguration->config) { 68 | $newConfiguration->config = $newConfiguration->configurationParser 69 | ->parseConfig(); 70 | } 71 | 72 | $parts = \explode('.', $key); 73 | 74 | if (\count($parts) > 1) { 75 | if (\array_key_exists($parts[0], $newConfiguration->config) && \is_array($newConfiguration->config[$parts[0]])) { 76 | $newConfiguration->config[$parts[0]][$parts[1]] = $value; 77 | } else { 78 | $newConfiguration->config[$parts[0]] = [$parts[1] => $value]; 79 | } 80 | } else { 81 | $newConfiguration->config[$key] = $value; 82 | } 83 | 84 | return $newConfiguration; 85 | } 86 | 87 | public function getSourcePath(): string 88 | { 89 | $sourcePath = Path::create( 90 | [ 91 | $this->filesystem 92 | ->getCwd(), 93 | $this->get('source', 'tasks'), 94 | ] 95 | ); 96 | 97 | return $sourcePath->toString(); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Configuration/ConfigurationParser.php: -------------------------------------------------------------------------------- 1 | consoleLogger = $consoleLogger; 35 | $this->configurationDefinition = $configurationDefinition; 36 | $this->definitionProcessor = $definitionProcessor; 37 | $this->fileParser = $fileParser; 38 | $this->filesystem = $filesystem; 39 | } 40 | 41 | /** {@inheritdoc} */ 42 | public function parseConfig(): array 43 | { 44 | $parsedConfig = []; 45 | $configFileParsed = false; 46 | 47 | try { 48 | $configFile = $this->configFilePath(); 49 | $parsedConfig = $this->fileParser 50 | ->parse($configFile); 51 | 52 | $configFileParsed = true; 53 | } catch (ConfigFileNotExistsException $exception) { 54 | $this->consoleLogger 55 | ->debug("Config file not found, exception message: '{$exception->getMessage()}'."); 56 | } catch (ConfigFileNotReadableException $exception) { 57 | $this->consoleLogger 58 | ->debug("Config file is not readable, exception message: '{$exception->getMessage()}'."); 59 | } 60 | 61 | if (false === $configFileParsed) { 62 | $this->consoleLogger 63 | ->verbose('Unable to find/parse config file, fallback to default values.'); 64 | } else { 65 | $this->consoleLogger 66 | ->verbose("Using config file {$configFile}."); 67 | } 68 | 69 | return $this->definitionProcessor 70 | ->processConfiguration( 71 | $this->configurationDefinition, 72 | $parsedConfig 73 | ); 74 | } 75 | 76 | /** @throws ConfigFileNotExistsException */ 77 | private function configFilePath(): string 78 | { 79 | $cwd = $this->filesystem 80 | ->getCwd(); 81 | $configPath = Path::fromStrings($cwd ?? '', ConfigGeneratorCommand::CONFIG_FILE_NAME)->toString(); 82 | $configExists = $this->filesystem 83 | ->fileExists($configPath); 84 | 85 | if ($configExists) { 86 | return $configPath; 87 | } 88 | 89 | throw new ConfigFileNotExistsException( 90 | \sprintf( 91 | 'Unable to find config file "%s".', 92 | $configPath 93 | ) 94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Configuration/ConfigurationParserInterface.php: -------------------------------------------------------------------------------- 1 | */ 10 | public function parseConfig(): array; 11 | } 12 | -------------------------------------------------------------------------------- /src/Configuration/Definition.php: -------------------------------------------------------------------------------- 1 | getRootNode(); 18 | $rootNode 19 | 20 | ->children() 21 | 22 | ->scalarNode('source') 23 | ->cannotBeEmpty() 24 | ->info('path to the tasks directory' . PHP_EOL) 25 | ->end() 26 | 27 | ->scalarNode('suffix') 28 | ->defaultValue('Tasks.php') 29 | ->info('The suffix for filenames' . PHP_EOL) 30 | ->end() 31 | 32 | ->scalarNode('timezone') 33 | ->info('Timezone used to calculate task run date') 34 | ->end() 35 | 36 | ->booleanNode('timezone_log') 37 | ->defaultFalse() 38 | ->info('Whether configured "timezone" will be used for logs') 39 | ->end() 40 | 41 | ->scalarNode('logger_factory') 42 | ->defaultValue(PsrStreamLoggerFactory::class) 43 | ->cannotBeEmpty() 44 | ->info("Class name implementing 'LoggerFactoryInterface'. Use it to provider your own logger.") 45 | ->end() 46 | 47 | ->booleanNode('log_errors') 48 | ->defaultFalse() 49 | ->info('Flag for logging errors' . PHP_EOL) 50 | ->end() 51 | 52 | ->scalarNode('errors_log_file') 53 | ->defaultValue('/dev/null') 54 | ->info('Path to errors log' . PHP_EOL) 55 | ->end() 56 | 57 | ->booleanNode('log_output') 58 | ->defaultFalse() 59 | ->info('Flag for logging output' . PHP_EOL) 60 | ->end() 61 | 62 | ->scalarNode('output_log_file') 63 | ->defaultValue('/dev/null') 64 | ->info('Path to output logs' . PHP_EOL) 65 | ->end() 66 | 67 | ->scalarNode('log_allow_line_breaks') 68 | ->defaultFalse() 69 | ->info('Flag for line breaks in logs' . PHP_EOL) 70 | ->end() 71 | 72 | ->scalarNode('log_ignore_empty_context') 73 | ->defaultFalse() 74 | ->info('Flag for empty context in logs' . PHP_EOL) 75 | ->end() 76 | 77 | ->scalarNode('email_output') 78 | ->defaultFalse() 79 | ->info('Email the event\'s output' . PHP_EOL) 80 | ->end() 81 | 82 | ->scalarNode('email_errors') 83 | ->defaultFalse() 84 | ->info('Notify by email in case of an error' . PHP_EOL) 85 | ->end() 86 | 87 | ->arrayNode('mailer') 88 | 89 | ->children() 90 | 91 | ->scalarNode('transport') 92 | ->info('The type the Swift transporter' . PHP_EOL) 93 | ->end() 94 | 95 | ->arrayNode('recipients') 96 | ->prototype('scalar')->end() 97 | ->info('List of the email recipients' . PHP_EOL) 98 | ->end() 99 | 100 | ->scalarNode('sender_name') 101 | ->info('The sender name' . PHP_EOL) 102 | ->end() 103 | 104 | ->scalarNode('sender_email') 105 | ->info('The sender email' . PHP_EOL) 106 | ->end() 107 | 108 | ->end() 109 | 110 | ->end() 111 | 112 | ->arrayNode('smtp') 113 | 114 | ->children() 115 | 116 | ->scalarNode('host') 117 | ->info('SMTP host' . PHP_EOL) 118 | ->end() 119 | 120 | ->scalarNode('port') 121 | ->info('SMTP port' . PHP_EOL) 122 | ->end() 123 | 124 | ->scalarNode('username') 125 | ->info('SMTP username' . PHP_EOL) 126 | ->end() 127 | 128 | ->scalarNode('password') 129 | ->info('SMTP password' . PHP_EOL) 130 | ->end() 131 | 132 | ->scalarNode('encryption') 133 | ->info('SMTP encryption' . PHP_EOL) 134 | ->end() 135 | 136 | ->end() 137 | 138 | ->end() 139 | 140 | ->end() 141 | ; 142 | 143 | return $treeBuilder; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/Configuration/FileParser.php: -------------------------------------------------------------------------------- 1 | yamlParser = $yamlParser; 17 | } 18 | 19 | /** 20 | * @throws ConfigFileNotExistsException 21 | * @throws ConfigFileNotReadableException 22 | * 23 | * @return array 24 | */ 25 | public function parse(string $configPath): array 26 | { 27 | if (!\file_exists($configPath)) { 28 | throw ConfigFileNotExistsException::fromFilePath($configPath); 29 | } 30 | 31 | if (!\is_readable($configPath)) { 32 | throw ConfigFileNotReadableException::fromFilePath($configPath); 33 | } 34 | 35 | $yamlParser = $this->yamlParser; 36 | $configContent = \file_get_contents($configPath); 37 | 38 | if (false === $configContent) { 39 | throw ConfigFileNotReadableException::fromFilePath($configPath); 40 | } 41 | 42 | return [$yamlParser::parse($configContent)]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Console/Command/Command.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | protected $arguments; 17 | 18 | /** 19 | * Command options. 20 | * 21 | * @var array 22 | */ 23 | protected $options; 24 | 25 | /** 26 | * Input object. 27 | * 28 | * @var \Symfony\Component\Console\Input\InputInterface 29 | */ 30 | protected $input; 31 | 32 | /** 33 | * output object. 34 | * 35 | * @var \Symfony\Component\Console\Output\OutputInterface 36 | */ 37 | protected $output; 38 | } 39 | -------------------------------------------------------------------------------- /src/Console/Command/ConfigGeneratorCommand.php: -------------------------------------------------------------------------------- 1 | timezoneProvider = $timezoneProvider; 33 | $this->symfonyFilesystem = $symfonyFilesystem; 34 | $this->filesystem = $filesystem; 35 | 36 | parent::__construct(); 37 | } 38 | 39 | /** 40 | * Configures the current command. 41 | */ 42 | protected function configure(): void 43 | { 44 | $this 45 | ->setName('publish:config') 46 | ->setDescription("Generates a config file within the project's root directory.") 47 | ->setHelp("This generates a config file in YML format within the project's root directory.") 48 | ; 49 | } 50 | 51 | /** 52 | * {@inheritdoc} 53 | */ 54 | protected function execute(InputInterface $input, OutputInterface $output) 55 | { 56 | $symfonyStyleIo = new SymfonyStyle($input, $output); 57 | $cwd = $this->filesystem 58 | ->getCwd(); 59 | $path = Path::create([$cwd, self::CONFIG_FILE_NAME])->toString(); 60 | $destination = \realpath($path) ?: $path; 61 | $configExists = $this->filesystem 62 | ->fileExists($destination) 63 | ; 64 | 65 | $output->writeln( 66 | "Destination config file: '{$destination}'.", 67 | OutputInterface::VERBOSITY_VERBOSE 68 | ); 69 | 70 | if ($configExists) { 71 | $output->writeln( 72 | "The configuration file already exists at '{$destination}'." 73 | ); 74 | 75 | return 0; 76 | } 77 | 78 | $projectRoot = $this->filesystem 79 | ->projectRootDirectory(); 80 | $srcPath = Path::fromStrings( 81 | $projectRoot, 82 | 'resources', 83 | 'config', 84 | self::CONFIG_FILE_NAME 85 | ); 86 | $src = $srcPath->toString(); 87 | $output->writeln( 88 | "Source config file: '{$src}'.", 89 | OutputInterface::VERBOSITY_VERBOSE 90 | ); 91 | $defaultTimezone = $this->askForTimezone($symfonyStyleIo); 92 | $output->writeln( 93 | "Provided timezone: '{$defaultTimezone}'.", 94 | OutputInterface::VERBOSITY_VERBOSE 95 | ); 96 | 97 | $this->updateTimezone( 98 | $destination, 99 | $src, 100 | $defaultTimezone 101 | ); 102 | 103 | $output->writeln('The configuration file was generated successfully.'); 104 | 105 | return 0; 106 | } 107 | 108 | /** 109 | * @return string 110 | */ 111 | protected function askForTimezone(SymfonyStyle $symfonyStyleIo) 112 | { 113 | $defaultTimezone = $this->timezoneProvider 114 | ->defaultTimezone() 115 | ->getName() 116 | ; 117 | $question = new Question( 118 | 'Please provide default timezone for task run date calculations', 119 | $defaultTimezone 120 | ); 121 | $question->setAutocompleterValues(\DateTimeZone::listIdentifiers()); 122 | $question->setValidator( 123 | static function ($answer) { 124 | try { 125 | new \DateTimeZone($answer); 126 | } catch (\Exception $exception) { 127 | throw new \Exception("Timezone '{$answer}' is not valid. Please provide valid timezone."); 128 | } 129 | 130 | return $answer; 131 | } 132 | ); 133 | 134 | return $symfonyStyleIo->askQuestion($question); 135 | } 136 | 137 | private function updateTimezone( 138 | string $destination, 139 | string $src, 140 | string $timezone 141 | ): void { 142 | $this->symfonyFilesystem 143 | ->dumpFile( 144 | $destination, 145 | \str_replace( 146 | 'timezone: ~', 147 | "timezone: {$timezone}", 148 | $this->filesystem 149 | ->readContent($src) 150 | ) 151 | ) 152 | ; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/Console/Command/ScheduleListCommand.php: -------------------------------------------------------------------------------- 1 | configuration = $configuration; 31 | $this->taskCollection = $taskCollection; 32 | $this->taskLoader = $taskLoader; 33 | 34 | parent::__construct(); 35 | } 36 | 37 | /** 38 | * Configures the current command. 39 | */ 40 | protected function configure(): void 41 | { 42 | $this->setName('schedule:list') 43 | ->setDescription('Displays the list of scheduled tasks.') 44 | ->setDefinition( 45 | [ 46 | new InputArgument( 47 | 'source', 48 | InputArgument::OPTIONAL, 49 | 'The source directory for collecting the tasks.', 50 | $this->configuration 51 | ->getSourcePath() 52 | ), 53 | ] 54 | ) 55 | ->setHelp('This command displays the scheduled tasks in a tabular format.'); 56 | } 57 | 58 | /** 59 | * {@inheritdoc} 60 | * 61 | * @throws WrongTaskInstanceException 62 | */ 63 | protected function execute(InputInterface $input, OutputInterface $output) 64 | { 65 | $this->options = $input->getOptions(); 66 | $this->arguments = $input->getArguments(); 67 | /** @var \SplFileInfo[] $tasks */ 68 | $tasks = $this->taskCollection 69 | ->all($this->arguments['source']); 70 | 71 | if (!\count($tasks)) { 72 | $output->writeln('No task found!'); 73 | 74 | return 0; 75 | } 76 | 77 | $table = new Table($output); 78 | $table->setHeaders( 79 | [ 80 | '#', 81 | 'Task', 82 | 'Expression', 83 | 'Command to Run', 84 | ] 85 | ); 86 | $row = 0; 87 | 88 | $schedules = $this->taskLoader 89 | ->load(...\array_values($tasks)) 90 | ; 91 | 92 | foreach ($schedules as $schedule) { 93 | $events = $schedule->events(); 94 | foreach ($events as $event) { 95 | $table->addRow( 96 | [ 97 | ++$row, 98 | $event->description, 99 | $event->getExpression(), 100 | $event->getCommandForDisplay(), 101 | ] 102 | ); 103 | } 104 | } 105 | 106 | $table->render(); 107 | 108 | return 0; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Console/Command/ScheduleRunCommand.php: -------------------------------------------------------------------------------- 1 | taskCollection = $taskCollection; 44 | $this->configuration = $configuration; 45 | $this->eventRunner = $eventRunner; 46 | $this->taskTimezone = $taskTimezone; 47 | $this->scheduleFactory = $scheduleFactory; 48 | $this->taskLoader = $taskLoader; 49 | 50 | parent::__construct(); 51 | } 52 | 53 | /** 54 | * Configures the current command. 55 | */ 56 | protected function configure(): void 57 | { 58 | $this->setName('schedule:run') 59 | ->setDescription('Starts the event runner.') 60 | ->setDefinition( 61 | [ 62 | new InputArgument( 63 | 'source', 64 | InputArgument::OPTIONAL, 65 | 'The source directory for collecting the task files.', 66 | $this->configuration 67 | ->getSourcePath() 68 | ), 69 | ] 70 | ) 71 | ->addOption( 72 | 'force', 73 | 'f', 74 | InputOption::VALUE_NONE, 75 | 'Run all tasks regardless of configured run time.' 76 | ) 77 | ->addOption( 78 | 'task', 79 | 't', 80 | InputOption::VALUE_REQUIRED, 81 | 'Which task to run. Provide task number from schedule:list command.', 82 | null 83 | ) 84 | ->setHelp('This command starts the Crunz event runner.'); 85 | } 86 | 87 | /** 88 | * {@inheritdoc} 89 | * 90 | * @throws WrongTaskInstanceException 91 | */ 92 | protected function execute(InputInterface $input, OutputInterface $output) 93 | { 94 | $this->arguments = $input->getArguments(); 95 | $this->options = $input->getOptions(); 96 | $task = $this->options['task']; 97 | /** @var \SplFileInfo[] $files */ 98 | $files = $this->taskCollection 99 | ->all($this->arguments['source']); 100 | 101 | if (!\count($files)) { 102 | $output->writeln('No task found! Please check your source path.'); 103 | 104 | return 0; 105 | } 106 | 107 | // List of schedules 108 | $schedules = $this->taskLoader 109 | ->load(...\array_values($files)) 110 | ; 111 | $tasksTimezone = $this->taskTimezone 112 | ->timezoneForComparisons() 113 | ; 114 | 115 | // Is specified task should be invoked? 116 | if (\is_string($task)) { 117 | $schedules = $this->scheduleFactory 118 | ->singleTaskSchedule(TaskNumber::fromString($task), ...$schedules); 119 | } 120 | 121 | $forceRun = \filter_var($this->options['force'] ?? false, FILTER_VALIDATE_BOOLEAN); 122 | $schedules = \array_map( 123 | static function (Schedule $schedule) use ($tasksTimezone, $forceRun) { 124 | if (false === $forceRun) { 125 | // We keep the events which are due and dismiss the rest. 126 | $schedule->events( 127 | $schedule->dueEvents( 128 | $tasksTimezone 129 | ) 130 | ); 131 | } 132 | 133 | return $schedule; 134 | }, 135 | $schedules 136 | ); 137 | $schedules = \array_filter( 138 | $schedules, 139 | static function (Schedule $schedule): bool { 140 | return \count($schedule->events()) > 0; 141 | } 142 | ); 143 | 144 | if (!\count($schedules)) { 145 | $output->writeln('No event is due!'); 146 | 147 | return 0; 148 | } 149 | 150 | // Running the events 151 | $this->eventRunner 152 | ->handle($output, $schedules) 153 | ; 154 | 155 | return 0; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/Console/Command/TaskGeneratorCommand.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | const DEFAULTS = [ 24 | 'frequency' => 'everyThirtyMinutes', 25 | 'constraint' => 'weekdays', 26 | 'in' => 'path/to/your/command', 27 | 'run' => 'command/to/execute', 28 | 'description' => 'Task description', 29 | 'type' => 'basic', 30 | ]; 31 | /** 32 | * Stub content. 33 | * 34 | * @var string 35 | */ 36 | protected $stub; 37 | /** @var ConfigurationInterface */ 38 | private $config; 39 | /** @var FilesystemInterface */ 40 | private $filesystem; 41 | 42 | public function __construct(ConfigurationInterface $configuration, FilesystemInterface $filesystem) 43 | { 44 | $this->config = $configuration; 45 | $this->filesystem = $filesystem; 46 | 47 | parent::__construct(); 48 | } 49 | 50 | /** 51 | * Configures the current command. 52 | */ 53 | protected function configure(): void 54 | { 55 | $this 56 | ->setName('make:task') 57 | ->setDescription('Generates a task file with one task.') 58 | ->setDefinition( 59 | [ 60 | new InputArgument( 61 | 'taskfile', 62 | InputArgument::REQUIRED, 63 | 'The task file name' 64 | ), 65 | new InputOption( 66 | 'frequency', 67 | 'f', 68 | InputOption::VALUE_OPTIONAL, 69 | "The task's frequency", 70 | self::DEFAULTS['frequency'] 71 | ), 72 | new InputOption( 73 | 'constraint', 74 | 'c', 75 | InputOption::VALUE_OPTIONAL, 76 | "The task's constraint", 77 | self::DEFAULTS['constraint'] 78 | ), 79 | new InputOption( 80 | 'in', 81 | 'i', 82 | InputOption::VALUE_OPTIONAL, 83 | "The command's path", 84 | self::DEFAULTS['in'] 85 | ), 86 | new InputOption( 87 | 'run', 88 | 'r', 89 | InputOption::VALUE_OPTIONAL, 90 | "The task's command", 91 | self::DEFAULTS['run'] 92 | ), 93 | new InputOption( 94 | 'description', 95 | 'd', 96 | InputOption::VALUE_OPTIONAL, 97 | "The task's description", 98 | self::DEFAULTS['description'] 99 | ), 100 | new InputOption( 101 | 'type', 102 | 't', 103 | InputOption::VALUE_OPTIONAL, 104 | 'The task type', 105 | self::DEFAULTS['type'] 106 | ), 107 | ] 108 | ) 109 | ->setHelp('This command makes a task file skeleton.'); 110 | } 111 | 112 | /** 113 | * Executes the current command. 114 | * 115 | * @return int|null null or 0 if everything went fine, or an error code 116 | */ 117 | protected function execute(InputInterface $input, OutputInterface $output) 118 | { 119 | $this->input = $input; 120 | $this->output = $output; 121 | 122 | $this->arguments = $input->getArguments(); 123 | $this->options = $input->getOptions(); 124 | $this->stub = $this->getStub(); 125 | 126 | if ($this->stub) { 127 | $this 128 | ->replaceFrequency() 129 | ->replaceConstraint() 130 | ->replaceCommand() 131 | ->replacePath() 132 | ->replaceDescription() 133 | ; 134 | } 135 | 136 | if ($this->save()) { 137 | $output->writeln('The task file generated successfully'); 138 | } else { 139 | $output->writeln('There was a problem when generating the file. Please check your command.'); 140 | } 141 | 142 | return 0; 143 | } 144 | 145 | /** 146 | * Save the generate task skeleton into a file. 147 | * 148 | * @return bool 149 | */ 150 | protected function save() 151 | { 152 | $filename = Path::create([$this->outputPath(), $this->outputFile()]); 153 | 154 | return (bool) \file_put_contents($filename->toString(), $this->stub); 155 | } 156 | 157 | /** 158 | * Ask a question. 159 | * 160 | * @param string $question 161 | * 162 | * @return ?string 163 | */ 164 | protected function ask($question) 165 | { 166 | $helper = $this->getHelper('question'); 167 | $question = new Question("{$question}"); 168 | 169 | return $helper->ask($this->input, $this->output, $question); 170 | } 171 | 172 | /** 173 | * Return the output path. 174 | * 175 | * @return string 176 | */ 177 | protected function outputPath() 178 | { 179 | $source = $this->config 180 | ->getSourcePath() 181 | ; 182 | $destination = $this->ask('Where do you want to save the file? (Press enter for the current directory)'); 183 | $outputPath = null !== $destination ? $destination : $source; 184 | 185 | if (!\file_exists($outputPath)) { 186 | \mkdir($outputPath, 0744, true); 187 | } 188 | 189 | return $outputPath; 190 | } 191 | 192 | /** 193 | * Populate the output filename. 194 | * 195 | * @return string 196 | */ 197 | protected function outputFile() 198 | { 199 | $suffix = $this->config 200 | ->get('suffix') 201 | ; 202 | 203 | return \preg_replace('/Tasks|\.php$/', '', $this->arguments['taskfile']) . $suffix; 204 | } 205 | 206 | /** 207 | * Get the task stub. 208 | * 209 | * @return string 210 | */ 211 | protected function getStub() 212 | { 213 | $projectRootDirectory = $this->filesystem 214 | ->projectRootDirectory(); 215 | $path = Path::fromStrings( 216 | $projectRootDirectory, 217 | 'src', 218 | 'Stubs', 219 | \ucfirst($this->type() . 'Task.php') 220 | ); 221 | 222 | return $this->filesystem 223 | ->readContent($path->toString()); 224 | } 225 | 226 | /** 227 | * Get the task type. 228 | * 229 | * @return string 230 | */ 231 | protected function type() 232 | { 233 | return $this->options['type']; 234 | } 235 | 236 | /** 237 | * Replace frequency. 238 | */ 239 | protected function replaceFrequency(): self 240 | { 241 | $this->stub = \str_replace('DummyFrequency', \rtrim($this->options['frequency'], '()'), $this->stub); 242 | 243 | return $this; 244 | } 245 | 246 | /** 247 | * Replace constraint. 248 | */ 249 | protected function replaceConstraint(): self 250 | { 251 | $this->stub = \str_replace('DummyConstraint', \rtrim($this->options['constraint'], '()'), $this->stub); 252 | 253 | return $this; 254 | } 255 | 256 | /** 257 | * Replace command. 258 | */ 259 | protected function replaceCommand(): self 260 | { 261 | $this->stub = \str_replace('DummyCommand', $this->options['run'], $this->stub); 262 | 263 | return $this; 264 | } 265 | 266 | /** 267 | * Replace path. 268 | */ 269 | protected function replacePath(): self 270 | { 271 | $this->stub = \str_replace('DummyPath', $this->options['in'], $this->stub); 272 | 273 | return $this; 274 | } 275 | 276 | /** 277 | * Replace description. 278 | */ 279 | protected function replaceDescription(): self 280 | { 281 | $this->stub = \str_replace('DummyDescription', $this->options['description'], $this->stub); 282 | 283 | return $this; 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/EnvFlags/EnvFlags.php: -------------------------------------------------------------------------------- 1 | 144 | */ 145 | protected $fieldsPosition = [ 146 | 'minute' => 1, 147 | 'hour' => 2, 148 | 'day' => 3, 149 | 'month' => 4, 150 | 'week' => 5, 151 | ]; 152 | 153 | /** 154 | * Indicates if the command should not overlap itself. 155 | * 156 | * @var bool 157 | */ 158 | private $preventOverlapping = false; 159 | /** @var ClockInterface */ 160 | private static $clock; 161 | /** @var ClosureSerializerInterface|null */ 162 | private static $closureSerializer = null; 163 | 164 | /** 165 | * The symfony lock factory that is used to acquire locks. If the value is null, but preventOverlapping = true 166 | * crunz falls back to filesystem locks. 167 | * 168 | * @var Factory|LockFactory|null 169 | */ 170 | private $lockFactory; 171 | /** @var string[] */ 172 | private $wholeOutput = []; 173 | /** @var Lock */ 174 | private $lock; 175 | /** @var \Closure[] */ 176 | private $errorCallbacks = []; 177 | 178 | /** 179 | * Create a new event instance. 180 | * 181 | * @param string|Closure $command 182 | * @param string|int $id 183 | */ 184 | public function __construct($id, $command) 185 | { 186 | $this->command = $command; 187 | $this->id = $id; 188 | $this->output = $this->getDefaultOutput(); 189 | } 190 | 191 | /** 192 | * Change the current working directory. 193 | * 194 | * @param string $directory 195 | * 196 | * @return self 197 | */ 198 | public function in($directory) 199 | { 200 | $this->cwd = $directory; 201 | 202 | return $this; 203 | } 204 | 205 | /** 206 | * Determine if the event's output is sent to null. 207 | * 208 | * @return bool 209 | */ 210 | public function nullOutput() 211 | { 212 | return 'NUL' === $this->output || '/dev/null' === $this->output; 213 | } 214 | 215 | /** 216 | * Build the command string. 217 | * 218 | * @return string 219 | */ 220 | public function buildCommand() 221 | { 222 | $command = ''; 223 | 224 | if ($this->cwd) { 225 | if ($this->user) { 226 | $command .= $this->sudo($this->user); 227 | } 228 | 229 | // Support changing drives in Windows 230 | $cdParameter = $this->isWindows() ? '/d ' : ''; 231 | $andSign = $this->isWindows() ? ' &' : ';'; 232 | 233 | $command .= "cd {$cdParameter}{$this->cwd}{$andSign} "; 234 | } 235 | 236 | if ($this->user) { 237 | $command .= $this->sudo($this->user); 238 | } 239 | 240 | $command .= \is_string($this->command) 241 | ? $this->command 242 | : $this->serializeClosure($this->command) 243 | ; 244 | 245 | return \trim($command, '& '); 246 | } 247 | 248 | /** 249 | * Determine whether the passed value is a closure ot not. 250 | * 251 | * @return bool 252 | */ 253 | public function isClosure() 254 | { 255 | return \is_object($this->command) && ($this->command instanceof Closure); 256 | } 257 | 258 | /** 259 | * Determine if the given event should run based on the Cron expression. 260 | * 261 | * @return bool 262 | */ 263 | public function isDue(\DateTimeZone $timeZone) 264 | { 265 | return $this->expressionPasses($timeZone) && $this->filtersPass(); 266 | } 267 | 268 | /** 269 | * Determine if the filters pass for the event. 270 | * 271 | * @return bool 272 | */ 273 | public function filtersPass() 274 | { 275 | $invoker = new Invoker(); 276 | 277 | foreach ($this->filters as $callback) { 278 | if (!$invoker->call($callback)) { 279 | return false; 280 | } 281 | } 282 | 283 | foreach ($this->rejects as $callback) { 284 | if ($invoker->call($callback)) { 285 | return false; 286 | } 287 | } 288 | 289 | return true; 290 | } 291 | 292 | /** @return string */ 293 | public function wholeOutput() 294 | { 295 | return \implode('', $this->wholeOutput); 296 | } 297 | 298 | /** 299 | * Start the event execution. 300 | * 301 | * @return int 302 | */ 303 | public function start() 304 | { 305 | $command = $this->buildCommand(); 306 | $process = Process::fromStringCommand($command); 307 | 308 | $this->setProcess($process); 309 | $this->getProcess()->start( 310 | function ($type, $content): void { 311 | $this->wholeOutput[] = $content; 312 | } 313 | ); 314 | 315 | if ($this->preventOverlapping) { 316 | $this->lock(); 317 | } 318 | 319 | /** @var int $pid */ 320 | $pid = $this->getProcess() 321 | ->getPid(); 322 | 323 | return $pid; 324 | } 325 | 326 | /** 327 | * The Cron expression representing the event's frequency. 328 | * 329 | * @throws TaskException 330 | */ 331 | public function cron(string $expression): self 332 | { 333 | /** @var string[] $parts */ 334 | $parts = \preg_split( 335 | '/\s/', 336 | $expression, 337 | -1, 338 | PREG_SPLIT_NO_EMPTY 339 | ); 340 | 341 | if (\count($parts) > 5) { 342 | throw new TaskException("Expression '{$expression}' has more than five parts and this is not allowed."); 343 | } 344 | 345 | $this->expression = $expression; 346 | 347 | return $this; 348 | } 349 | 350 | /** 351 | * Schedule the event to run hourly. 352 | */ 353 | public function hourly(): self 354 | { 355 | return $this->cron('0 * * * *'); 356 | } 357 | 358 | /** 359 | * Schedule the event to run daily. 360 | */ 361 | public function daily(): self 362 | { 363 | return $this->cron('0 0 * * *'); 364 | } 365 | 366 | /** 367 | * Schedule the event to run on a certain date. 368 | * 369 | * @param string $date 370 | * 371 | * @return $this 372 | */ 373 | public function on($date) 374 | { 375 | $parsedDate = \date_parse($date); 376 | if (false === $parsedDate) { 377 | $parsedDate = []; 378 | } 379 | 380 | $segments = \array_intersect_key($parsedDate, $this->fieldsPosition); 381 | 382 | if ($parsedDate['year']) { 383 | $this->skip(static function () use ($parsedDate) { 384 | return (int) \date('Y') !== $parsedDate['year']; 385 | }); 386 | } 387 | 388 | foreach ($segments as $key => $value) { 389 | if (false !== $value) { 390 | $this->spliceIntoPosition($this->fieldsPosition[$key], (string) $value); 391 | } 392 | } 393 | 394 | return $this; 395 | } 396 | 397 | /** 398 | * Schedule the command at a given time. 399 | * 400 | * @param string $time 401 | */ 402 | public function at($time): self 403 | { 404 | return $this->dailyAt($time); 405 | } 406 | 407 | /** 408 | * Schedule the event to run daily at a given time (10:00, 19:30, etc). 409 | * 410 | * @param string $time 411 | */ 412 | public function dailyAt($time): self 413 | { 414 | $segments = \explode(':', $time); 415 | $firstSegment = (int) $segments[0]; 416 | $secondSegment = \count($segments) > 1 417 | ? (int) $segments[1] 418 | : '0' 419 | ; 420 | 421 | return $this 422 | ->spliceIntoPosition(2, (string) $firstSegment) 423 | ->spliceIntoPosition(1, (string) $secondSegment) 424 | ; 425 | } 426 | 427 | /** 428 | * Set Working period. 429 | * 430 | * @param string $from 431 | * @param string $to 432 | * 433 | * @return self 434 | */ 435 | public function between($from, $to) 436 | { 437 | return $this->from($from) 438 | ->to($to); 439 | } 440 | 441 | /** 442 | * Check if event should be on. 443 | * 444 | * @param string $datetime 445 | * 446 | * @return self 447 | */ 448 | public function from($datetime) 449 | { 450 | return $this->skip(function () use ($datetime) { 451 | return $this->notYet($datetime); 452 | }); 453 | } 454 | 455 | /** 456 | * Check if event should be off. 457 | * 458 | * @param string $datetime 459 | * 460 | * @return self 461 | */ 462 | public function to($datetime) 463 | { 464 | return $this->skip(function () use ($datetime) { 465 | return $this->past($datetime); 466 | }); 467 | } 468 | 469 | /** 470 | * Schedule the event to run twice daily. 471 | * 472 | * @param int $first 473 | * @param int $second 474 | */ 475 | public function twiceDaily($first = 1, $second = 13): self 476 | { 477 | $hours = $first . ',' . $second; 478 | 479 | return $this 480 | ->spliceIntoPosition(1, '0') 481 | ->spliceIntoPosition(2, $hours) 482 | ; 483 | } 484 | 485 | /** 486 | * Schedule the event to run only on weekdays. 487 | */ 488 | public function weekdays(): self 489 | { 490 | return $this->spliceIntoPosition(5, '1-5'); 491 | } 492 | 493 | /** 494 | * Schedule the event to run only on Mondays. 495 | */ 496 | public function mondays(): self 497 | { 498 | return $this->days(1); 499 | } 500 | 501 | /** 502 | * Schedule the event to run only on Tuesdays. 503 | */ 504 | public function tuesdays(): self 505 | { 506 | return $this->days(2); 507 | } 508 | 509 | /** 510 | * Schedule the event to run only on Wednesdays. 511 | */ 512 | public function wednesdays(): self 513 | { 514 | return $this->days(3); 515 | } 516 | 517 | /** 518 | * Schedule the event to run only on Thursdays. 519 | */ 520 | public function thursdays(): self 521 | { 522 | return $this->days(4); 523 | } 524 | 525 | /** 526 | * Schedule the event to run only on Fridays. 527 | */ 528 | public function fridays(): self 529 | { 530 | return $this->days(5); 531 | } 532 | 533 | /** 534 | * Schedule the event to run only on Saturdays. 535 | */ 536 | public function saturdays(): self 537 | { 538 | return $this->days(6); 539 | } 540 | 541 | /** 542 | * Schedule the event to run only on Sundays. 543 | */ 544 | public function sundays(): self 545 | { 546 | return $this->days(0); 547 | } 548 | 549 | /** 550 | * Schedule the event to run weekly. 551 | */ 552 | public function weekly(): self 553 | { 554 | return $this->cron('0 0 * * 0'); 555 | } 556 | 557 | /** 558 | * Schedule the event to run weekly on a given day and time. 559 | * 560 | * @param int|string $day 561 | * @param string $time 562 | */ 563 | public function weeklyOn($day, $time = '0:0'): self 564 | { 565 | $this->dailyAt($time); 566 | 567 | return $this->spliceIntoPosition(5, (string) $day); 568 | } 569 | 570 | /** 571 | * Schedule the event to run monthly. 572 | */ 573 | public function monthly(): self 574 | { 575 | return $this->cron('0 0 1 * *'); 576 | } 577 | 578 | /** 579 | * Schedule the event to run quarterly. 580 | */ 581 | public function quarterly(): self 582 | { 583 | return $this->cron('0 0 1 */3 *'); 584 | } 585 | 586 | /** 587 | * Schedule the event to run yearly. 588 | */ 589 | public function yearly(): self 590 | { 591 | return $this->cron('0 0 1 1 *'); 592 | } 593 | 594 | /** 595 | * Set the days of the week the command should run on. 596 | * 597 | * @param mixed $days 598 | */ 599 | public function days($days): self 600 | { 601 | $days = \is_array($days) ? $days : \func_get_args(); 602 | 603 | return $this->spliceIntoPosition(5, \implode(',', $days)); 604 | } 605 | 606 | /** 607 | * Set hour for the cron job. 608 | * 609 | * @param mixed $value 610 | */ 611 | public function hour($value): self 612 | { 613 | $value = \is_array($value) ? $value : \func_get_args(); 614 | 615 | return $this->spliceIntoPosition(2, \implode(',', $value)); 616 | } 617 | 618 | /** 619 | * Set minute for the cron job. 620 | * 621 | * @param mixed $value 622 | */ 623 | public function minute($value): self 624 | { 625 | $value = \is_array($value) ? $value : \func_get_args(); 626 | 627 | return $this->spliceIntoPosition(1, \implode(',', $value)); 628 | } 629 | 630 | /** 631 | * Set hour for the cron job. 632 | * 633 | * @param mixed $value 634 | */ 635 | public function dayOfMonth($value): self 636 | { 637 | $value = \is_array($value) ? $value : \func_get_args(); 638 | 639 | return $this->spliceIntoPosition(3, \implode(',', $value)); 640 | } 641 | 642 | /** 643 | * Set hour for the cron job. 644 | * 645 | * @param mixed $value 646 | */ 647 | public function month($value): self 648 | { 649 | $value = \is_array($value) ? $value : \func_get_args(); 650 | 651 | return $this->spliceIntoPosition(4, \implode(',', $value)); 652 | } 653 | 654 | /** 655 | * Set hour for the cron job. 656 | * 657 | * @param mixed $value 658 | */ 659 | public function dayOfWeek($value): self 660 | { 661 | $value = \is_array($value) ? $value : \func_get_args(); 662 | 663 | return $this->spliceIntoPosition(5, \implode(',', $value)); 664 | } 665 | 666 | /** 667 | * Set the timezone the date should be evaluated on. 668 | * 669 | * @param \DateTimeZone|string $timezone 670 | * 671 | * @return $this 672 | */ 673 | public function timezone($timezone) 674 | { 675 | $this->timezone = $timezone; 676 | 677 | return $this; 678 | } 679 | 680 | /** 681 | * Set which user the command should run as. 682 | * 683 | * @param string $user 684 | * 685 | * @return $this 686 | */ 687 | public function user($user) 688 | { 689 | if ($this->isWindows()) { 690 | throw new NotImplementedException('Changing user on Windows is not implemented.'); 691 | } 692 | 693 | $this->user = $user; 694 | 695 | return $this; 696 | } 697 | 698 | /** 699 | * Do not allow the event to overlap each other. 700 | * 701 | * By default, the lock is acquired through file system locks. Alternatively, you can pass a symfony lock store 702 | * that will be responsible for the locking. 703 | * 704 | * @param StoreInterface|BlockingStoreInterface $store 705 | * 706 | * @return $this 707 | */ 708 | public function preventOverlapping(object $store = null) 709 | { 710 | if (null !== $store && !($store instanceof BlockingStoreInterface || $store instanceof StoreInterface)) { 711 | $legacyClass = StoreInterface::class; 712 | $newClass = BlockingStoreInterface::class; 713 | $actualClass = \get_class($store); 714 | 715 | throw new \RuntimeException( 716 | "Instance of '{$newClass}' or '{$legacyClass}' is expected, '{$actualClass}' provided" 717 | ); 718 | } 719 | 720 | $lockStore = $store ?: $this->createDefaultLockStore(); 721 | $this->preventOverlapping = true; 722 | $this->lockFactory = \class_exists(Factory::class) 723 | ? new Factory($lockStore) 724 | : new LockFactory($lockStore) 725 | ; 726 | 727 | // Skip the event if it's locked (processing) 728 | $this->skip(function () { 729 | $lock = $this->createLockObject(); 730 | $lock->acquire(); 731 | 732 | return !$lock->isAcquired(); 733 | }); 734 | 735 | $releaseCallback = function (): void { 736 | $this->releaseLock(); 737 | }; 738 | 739 | // Delete the lock file when the event is completed 740 | $this->after($releaseCallback); 741 | // Or on error 742 | $this->addErrorCallback($releaseCallback); 743 | 744 | return $this; 745 | } 746 | 747 | /** 748 | * Register a callback to further filter the schedule. 749 | * 750 | * @return $this 751 | */ 752 | public function when(Closure $callback) 753 | { 754 | $this->filters[] = $callback; 755 | 756 | return $this; 757 | } 758 | 759 | /** 760 | * Register a callback to further filter the schedule. 761 | * 762 | * @return $this 763 | */ 764 | public function skip(Closure $callback) 765 | { 766 | $this->rejects[] = $callback; 767 | 768 | return $this; 769 | } 770 | 771 | /** 772 | * Send the output of the command to a given location. 773 | * 774 | * @param string $location 775 | * @param bool $append 776 | * 777 | * @return $this 778 | */ 779 | public function sendOutputTo($location, $append = false) 780 | { 781 | $this->output = $location; 782 | 783 | $this->shouldAppendOutput = $append; 784 | 785 | return $this; 786 | } 787 | 788 | /** 789 | * Append the output of the command to a given location. 790 | * 791 | * @param string $location 792 | * 793 | * @return $this 794 | */ 795 | public function appendOutputTo($location) 796 | { 797 | return $this->sendOutputTo($location, true); 798 | } 799 | 800 | /** 801 | * Register a callback to be called before the operation. 802 | * 803 | * @return $this 804 | */ 805 | public function before(Closure $callback) 806 | { 807 | $this->beforeCallbacks[] = $callback; 808 | 809 | return $this; 810 | } 811 | 812 | /** 813 | * Register a callback to be called after the operation. 814 | * 815 | * @return $this 816 | */ 817 | public function after(Closure $callback) 818 | { 819 | return $this->then($callback); 820 | } 821 | 822 | /** 823 | * Register a callback to be called after the operation. 824 | * 825 | * @return $this 826 | */ 827 | public function then(Closure $callback) 828 | { 829 | $this->afterCallbacks[] = $callback; 830 | 831 | return $this; 832 | } 833 | 834 | /** 835 | * Set the human-friendly description of the event. 836 | * 837 | * @param string $description 838 | * 839 | * @return $this 840 | */ 841 | public function name($description) 842 | { 843 | return $this->description($description); 844 | } 845 | 846 | /** 847 | * Return the event's process. 848 | * 849 | * @return Process $process 850 | */ 851 | public function getProcess() 852 | { 853 | return $this->process; 854 | } 855 | 856 | /** 857 | * Set the human-friendly description of the event. 858 | * 859 | * @param string $description 860 | * 861 | * @return $this 862 | */ 863 | public function description($description) 864 | { 865 | $this->description = $description; 866 | 867 | return $this; 868 | } 869 | 870 | /** 871 | * Another way to the frequency of the cron job. 872 | * 873 | * @param string $unit 874 | * @param float|int|null $value 875 | */ 876 | public function every($unit = null, $value = null): self 877 | { 878 | if (null === $unit || !isset($this->fieldsPosition[$unit])) { 879 | return $this; 880 | } 881 | 882 | $value = (1 === (int) $value) ? '*' : '*/' . $value; 883 | 884 | return $this->spliceIntoPosition($this->fieldsPosition[$unit], $value) 885 | ->applyMask($unit); 886 | } 887 | 888 | /** 889 | * Return the event's command. 890 | * 891 | * @return string|int 892 | */ 893 | public function getId() 894 | { 895 | return $this->id; 896 | } 897 | 898 | /** 899 | * Get the summary of the event for display. 900 | * 901 | * @return string 902 | */ 903 | public function getSummaryForDisplay() 904 | { 905 | if (\is_string($this->description)) { 906 | return $this->description; 907 | } 908 | 909 | return $this->buildCommand(); 910 | } 911 | 912 | /** 913 | * Get the command for display. 914 | * 915 | * @return string 916 | */ 917 | public function getCommandForDisplay() 918 | { 919 | return $this->isClosure() ? 'object(Closure)' : $this->buildCommand(); 920 | } 921 | 922 | /** 923 | * Get the Cron expression for the event. 924 | * 925 | * @return string 926 | */ 927 | public function getExpression() 928 | { 929 | return $this->expression; 930 | } 931 | 932 | /** 933 | * Set the event's command. 934 | * 935 | * @param string $command 936 | * 937 | * @return $this 938 | */ 939 | public function setCommand($command) 940 | { 941 | $this->command = $command; 942 | 943 | return $this; 944 | } 945 | 946 | /** 947 | * Return the event's command. 948 | * 949 | * @return string|\Closure 950 | */ 951 | public function getCommand() 952 | { 953 | return $this->command; 954 | } 955 | 956 | /** 957 | * Return the current working directory. 958 | * 959 | * @return string 960 | */ 961 | public function getWorkingDirectory() 962 | { 963 | return $this->cwd; 964 | } 965 | 966 | /** 967 | * Return event's full output. 968 | * 969 | * @return string|null 970 | */ 971 | public function getOutputStream() 972 | { 973 | return $this->outputStream; 974 | } 975 | 976 | /** 977 | * Return all registered before callbacks. 978 | * 979 | * @return \Closure[] 980 | */ 981 | public function beforeCallbacks() 982 | { 983 | return $this->beforeCallbacks; 984 | } 985 | 986 | /** 987 | * Return all registered after callbacks. 988 | * 989 | * @return \Closure[] 990 | */ 991 | public function afterCallbacks() 992 | { 993 | return $this->afterCallbacks; 994 | } 995 | 996 | /** @return \Closure[] */ 997 | public function errorCallbacks() 998 | { 999 | return $this->errorCallbacks; 1000 | } 1001 | 1002 | /** 1003 | * If this event is prevented from overlapping, this method should be called regularly to refresh the lock. 1004 | */ 1005 | public function refreshLock(): void 1006 | { 1007 | if (!$this->preventOverlapping) { 1008 | return; 1009 | } 1010 | 1011 | $lock = $this->createLockObject(); 1012 | $remainingLifetime = $lock->getRemainingLifetime(); 1013 | 1014 | // Lock will never expire 1015 | if (null === $remainingLifetime) { 1016 | return; 1017 | } 1018 | 1019 | // Refresh 15s before lock expiration 1020 | $lockRefreshNeeded = $remainingLifetime < 15; 1021 | if ($lockRefreshNeeded) { 1022 | $lock->refresh(); 1023 | } 1024 | } 1025 | 1026 | public function everyMinute(): self 1027 | { 1028 | return $this->cron('* * * * *'); 1029 | } 1030 | 1031 | public function everyTwoMinutes(): self 1032 | { 1033 | return $this->cron('*/2 * * * *'); 1034 | } 1035 | 1036 | public function everyThreeMinutes(): self 1037 | { 1038 | return $this->cron('*/3 * * * *'); 1039 | } 1040 | 1041 | public function everyFourMinutes(): self 1042 | { 1043 | return $this->cron('*/4 * * * *'); 1044 | } 1045 | 1046 | public function everyFiveMinutes(): self 1047 | { 1048 | return $this->cron('*/5 * * * *'); 1049 | } 1050 | 1051 | public function everyTenMinutes(): self 1052 | { 1053 | return $this->cron('*/10 * * * *'); 1054 | } 1055 | 1056 | public function everyFifteenMinutes(): self 1057 | { 1058 | return $this->cron('*/15 * * * *'); 1059 | } 1060 | 1061 | public function everyThirtyMinutes(): self 1062 | { 1063 | return $this->cron('*/30 * * * *'); 1064 | } 1065 | 1066 | public function everyTwoHours(): self 1067 | { 1068 | return $this->cron('0 */2 * * *'); 1069 | } 1070 | 1071 | public function everyThreeHours(): self 1072 | { 1073 | return $this->cron('0 */3 * * *'); 1074 | } 1075 | 1076 | public function everyFourHours(): self 1077 | { 1078 | return $this->cron('0 */4 * * *'); 1079 | } 1080 | 1081 | public function everySixHours(): self 1082 | { 1083 | return $this->cron('0 */6 * * *'); 1084 | } 1085 | 1086 | /** 1087 | * Get the symfony lock object for the task. 1088 | * 1089 | * @return Lock 1090 | */ 1091 | protected function createLockObject() 1092 | { 1093 | $this->checkLockFactory(); 1094 | 1095 | if (null === $this->lock && null !== $this->lockFactory) { 1096 | $ttl = 30; 1097 | 1098 | $this->lock = $this->lockFactory 1099 | ->createLock($this->lockKey(), $ttl); 1100 | } 1101 | 1102 | return $this->lock; 1103 | } 1104 | 1105 | /** 1106 | * Release the lock after the command completed. 1107 | */ 1108 | protected function releaseLock(): void 1109 | { 1110 | $this->checkLockFactory(); 1111 | 1112 | $lock = $this->createLockObject(); 1113 | $lock->release(); 1114 | } 1115 | 1116 | /** 1117 | * Get the default output depending on the OS. 1118 | * 1119 | * @return string 1120 | */ 1121 | protected function getDefaultOutput() 1122 | { 1123 | return (DIRECTORY_SEPARATOR === '\\') ? 'NUL' : '/dev/null'; 1124 | } 1125 | 1126 | /** 1127 | * Add sudo to the command. 1128 | * 1129 | * @param string $user 1130 | * 1131 | * @return string 1132 | */ 1133 | protected function sudo($user) 1134 | { 1135 | return "sudo -u {$user} "; 1136 | } 1137 | 1138 | /** 1139 | * Convert closure to an executable command. 1140 | * 1141 | * @return string 1142 | */ 1143 | protected function serializeClosure(Closure $closure) 1144 | { 1145 | $closure = $this->closureSerializer() 1146 | ->serialize($closure) 1147 | ; 1148 | $serializedClosure = \http_build_query([$closure]); 1149 | $crunzRoot = CRUNZ_BIN; 1150 | 1151 | return PHP_BINARY . " {$crunzRoot} closure:run {$serializedClosure}"; 1152 | } 1153 | 1154 | /** 1155 | * Determine if the Cron expression passes. 1156 | * 1157 | * @return bool 1158 | */ 1159 | protected function expressionPasses(\DateTimeZone $timeZone) 1160 | { 1161 | $now = $this->getClock() 1162 | ->now(); 1163 | $now = $now->setTimezone($timeZone); 1164 | 1165 | if ($this->timezone) { 1166 | $taskTimeZone = \is_object($this->timezone) && $this->timezone instanceof \DateTimeZone 1167 | ? $this->timezone 1168 | ->getName() 1169 | : $this->timezone 1170 | ; 1171 | 1172 | $now = $now->setTimezone( 1173 | new \DateTimeZone( 1174 | $taskTimeZone 1175 | ) 1176 | ); 1177 | } 1178 | 1179 | return CronExpression::factory($this->expression)->isDue($now->format('Y-m-d H:i:s')); 1180 | } 1181 | 1182 | /** 1183 | * Check if time hasn't arrived. 1184 | * 1185 | * @param string $datetime 1186 | * 1187 | * @return bool 1188 | */ 1189 | protected function notYet($datetime) 1190 | { 1191 | return \time() < \strtotime($datetime); 1192 | } 1193 | 1194 | /** 1195 | * Check if the time has passed. 1196 | * 1197 | * @param string $datetime 1198 | * 1199 | * @return bool 1200 | */ 1201 | protected function past($datetime) 1202 | { 1203 | return \time() > \strtotime($datetime); 1204 | } 1205 | 1206 | /** 1207 | * Splice the given value into the given position of the expression. 1208 | * 1209 | * @param int $position 1210 | * @param string $value 1211 | */ 1212 | protected function spliceIntoPosition($position, $value): self 1213 | { 1214 | $segments = \explode(' ', $this->expression); 1215 | 1216 | $segments[$position - 1] = $value; 1217 | 1218 | return $this->cron(\implode(' ', $segments)); 1219 | } 1220 | 1221 | /** 1222 | * Mask a cron expression. 1223 | * 1224 | * @param string $unit 1225 | * 1226 | * @return self 1227 | */ 1228 | protected function applyMask($unit) 1229 | { 1230 | $cron = \explode(' ', $this->expression); 1231 | $mask = ['0', '0', '1', '1', '*', '*']; 1232 | $fpos = $this->fieldsPosition[$unit] - 1; 1233 | 1234 | \array_splice($cron, 0, $fpos, \array_slice($mask, 0, $fpos)); 1235 | 1236 | return $this->cron(\implode(' ', $cron)); 1237 | } 1238 | 1239 | /** 1240 | * Lock the event. 1241 | */ 1242 | protected function lock(): void 1243 | { 1244 | $lock = $this->createLockObject(); 1245 | $lock->acquire(); 1246 | } 1247 | 1248 | private function addErrorCallback(Closure $closure): void 1249 | { 1250 | $this->errorCallbacks[] = $closure; 1251 | } 1252 | 1253 | /** 1254 | * Set the event's process. 1255 | */ 1256 | private function setProcess(Process $process): void 1257 | { 1258 | $this->process = $process; 1259 | } 1260 | 1261 | /** 1262 | * @return FlockStore 1263 | * 1264 | * @throws Exception\CrunzException 1265 | */ 1266 | private function createDefaultLockStore() 1267 | { 1268 | try { 1269 | $lockPath = Path::create( 1270 | [ 1271 | \sys_get_temp_dir(), 1272 | '.crunz', 1273 | ] 1274 | ); 1275 | 1276 | $store = new FlockStore($lockPath->toString()); 1277 | } catch (InvalidArgumentException $exception) { 1278 | // Fallback to system temp dir 1279 | $lockPath = Path::create([\sys_get_temp_dir()]); 1280 | $store = new FlockStore($lockPath->toString()); 1281 | } 1282 | 1283 | return $store; 1284 | } 1285 | 1286 | private function lockKey(): string 1287 | { 1288 | if ($this->isClosure()) { 1289 | /** @var \Closure $closure */ 1290 | $closure = $this->command; 1291 | $command = $this->closureSerializer() 1292 | ->closureCode($closure) 1293 | ; 1294 | } else { 1295 | $command = $this->buildCommand(); 1296 | } 1297 | 1298 | return 'crunz-' . \md5($command); 1299 | } 1300 | 1301 | private function checkLockFactory(): void 1302 | { 1303 | if (null === $this->lockFactory) { 1304 | throw new \BadMethodCallException( 1305 | 'No lock factory. Please call preventOverlapping() first.' 1306 | ); 1307 | } 1308 | } 1309 | 1310 | private function getClock(): ClockInterface 1311 | { 1312 | if (null === self::$clock) { 1313 | self::$clock = new Clock(); 1314 | } 1315 | 1316 | return self::$clock; 1317 | } 1318 | 1319 | private function closureSerializer(): ClosureSerializerInterface 1320 | { 1321 | if (null === self::$closureSerializer) { 1322 | self::$closureSerializer = new OpisClosureSerializer(); 1323 | } 1324 | 1325 | return self::$closureSerializer; 1326 | } 1327 | 1328 | private function isWindows(): bool 1329 | { 1330 | $osCode = \mb_substr( 1331 | PHP_OS, 1332 | 0, 1333 | 3 1334 | ); 1335 | 1336 | return 'WIN' === $osCode; 1337 | } 1338 | } 1339 | -------------------------------------------------------------------------------- /src/EventRunner.php: -------------------------------------------------------------------------------- 1 | invoker = $invoker; 45 | $this->mailer = $mailer; 46 | $this->configuration = $configuration; 47 | $this->loggerFactory = $loggerFactory; 48 | $this->httpClient = $httpClient; 49 | $this->consoleLogger = $consoleLogger; 50 | } 51 | 52 | /** @param Schedule[] $schedules */ 53 | public function handle(OutputInterface $output, array $schedules = []): void 54 | { 55 | $this->schedules = $schedules; 56 | $this->output = $output; 57 | 58 | foreach ($this->schedules as $schedule) { 59 | $this->consoleLogger 60 | ->debug("Invoke Schedule's ping before"); 61 | 62 | $this->pingBefore($schedule); 63 | 64 | // Running the before-callbacks of the current schedule 65 | $this->invoke($schedule->beforeCallbacks()); 66 | 67 | $events = $schedule->events(); 68 | foreach ($events as $event) { 69 | $this->start($event); 70 | } 71 | } 72 | 73 | // Watch events until they are finished 74 | $this->manageStartedEvents(); 75 | } 76 | 77 | protected function start(Event $event): void 78 | { 79 | $this->logger = $this->loggerFactory 80 | ->create() 81 | ; 82 | 83 | // if sendOutputTo or appendOutputTo have been specified 84 | if (!$event->nullOutput()) { 85 | // if sendOutputTo then truncate the log file if it exists 86 | if (!$event->shouldAppendOutput) { 87 | $f = @\fopen($event->output, 'r+'); 88 | if (false !== $f) { 89 | \ftruncate($f, 0); 90 | \fclose($f); 91 | } 92 | } 93 | // Create an instance of the Logger specific to the event 94 | $event->logger = $this->loggerFactory->createEvent($event->output); 95 | } 96 | 97 | $this->consoleLogger 98 | ->debug("Invoke Event's ping before."); 99 | 100 | $this->pingBefore($event); 101 | 102 | // Running the before-callbacks 103 | $event->outputStream = ($this->invoke($event->beforeCallbacks())); 104 | $event->start(); 105 | } 106 | 107 | protected function manageStartedEvents(): void 108 | { 109 | while ($this->schedules) { 110 | foreach ($this->schedules as $scheduleKey => $schedule) { 111 | $events = $schedule->events(); 112 | // 10% chance that refresh will be called 113 | $refreshLocks = (\mt_rand(1, 100) <= 10); 114 | 115 | /** @var Event $event */ 116 | foreach ($events as $eventKey => $event) { 117 | if ($refreshLocks) { 118 | $event->refreshLock(); 119 | } 120 | 121 | $proc = $event->getProcess(); 122 | if ($proc->isRunning()) { 123 | continue; 124 | } 125 | 126 | $runStatus = ''; 127 | 128 | if ($proc->isSuccessful()) { 129 | $this->consoleLogger 130 | ->debug("Invoke Event's ping after."); 131 | $this->pingAfter($event); 132 | 133 | $runStatus = 'success'; 134 | 135 | $event->outputStream .= $event->wholeOutput(); 136 | $event->outputStream .= $this->invoke($event->afterCallbacks()); 137 | 138 | $this->handleOutput($event); 139 | } else { 140 | $runStatus = 'fail'; 141 | 142 | // Invoke error callbacks 143 | $this->invoke($event->errorCallbacks()); 144 | // Calling registered error callbacks with an instance of $event as argument 145 | $this->invoke($schedule->errorCallbacks(), [$event]); 146 | $this->handleError($event); 147 | } 148 | 149 | $id = $event->description ?: $event->getId(); 150 | 151 | $this->consoleLogger 152 | ->debug("Task ${id} status: {$runStatus}."); 153 | 154 | // Dismiss the event if it's finished 155 | $schedule->dismissEvent($eventKey); 156 | } 157 | 158 | // If there's no event left for the Schedule instance, 159 | // run the schedule's after-callbacks and remove 160 | // the Schedule from list of active schedules. zzzwwscxqqqAAAQ11 161 | if (!\count($schedule->events())) { 162 | $this->consoleLogger 163 | ->debug("Invoke Schedule's ping after."); 164 | 165 | $this->pingAfter($schedule); 166 | $this->invoke($schedule->afterCallbacks()); 167 | unset($this->schedules[$scheduleKey]); 168 | } 169 | } 170 | 171 | \usleep(250000); 172 | } 173 | } 174 | 175 | /** 176 | * @param \Closure[] $callbacks 177 | * @param array $parameters 178 | * 179 | * @return string 180 | */ 181 | protected function invoke(array $callbacks = [], array $parameters = []) 182 | { 183 | $output = ''; 184 | foreach ($callbacks as $callback) { 185 | // Invoke the callback with buffering enabled 186 | $output .= $this->invoker->call($callback, $parameters, true); 187 | } 188 | 189 | return $output; 190 | } 191 | 192 | protected function handleOutput(Event $event): void 193 | { 194 | $logged = false; 195 | $logOutput = $this->configuration 196 | ->get('log_output') 197 | ; 198 | 199 | if (!$event->nullOutput()) { 200 | $event->logger->info($this->formatEventOutput($event)); 201 | $logged = true; 202 | } 203 | 204 | if ($logOutput && !$logged) { 205 | $this->logger() 206 | ->info($this->formatEventOutput($event)) 207 | ; 208 | $logged = true; 209 | } 210 | 211 | if (!$logged) { 212 | $this->display($event->getOutputStream()); 213 | } 214 | 215 | $emailOutput = $this->configuration 216 | ->get('email_output') 217 | ; 218 | if ($emailOutput && !empty($event->getOutputStream())) { 219 | $this->mailer->send( 220 | 'Crunz: output for event: ' . (($event->description) ? $event->description : $event->getId()), 221 | $this->formatEventOutput($event) 222 | ); 223 | } 224 | } 225 | 226 | protected function handleError(Event $event): void 227 | { 228 | $logErrors = $this->configuration 229 | ->get('log_errors') 230 | ; 231 | $emailErrors = $this->configuration 232 | ->get('email_errors') 233 | ; 234 | 235 | if ($logErrors) { 236 | $this->logger() 237 | ->error($this->formatEventError($event)) 238 | ; 239 | } else { 240 | $output = $event->wholeOutput(); 241 | 242 | $this->output 243 | ->write("{$output}"); 244 | } 245 | 246 | // Send error as email as configured 247 | if ($emailErrors) { 248 | $this->mailer->send( 249 | 'Crunz: reporting error for event:' . (($event->description) ? $event->description : $event->getId()), 250 | $this->formatEventError($event) 251 | ); 252 | } 253 | } 254 | 255 | /** @return string */ 256 | protected function formatEventOutput(Event $event) 257 | { 258 | return $event->description 259 | . '(' 260 | . $event->getCommandForDisplay() 261 | . ') ' 262 | . PHP_EOL 263 | . PHP_EOL 264 | . $event->outputStream 265 | . PHP_EOL; 266 | } 267 | 268 | /** @return string */ 269 | protected function formatEventError(Event $event) 270 | { 271 | return $event->description 272 | . '(' 273 | . $event->getCommandForDisplay() 274 | . ') ' 275 | . PHP_EOL 276 | . $event->wholeOutput() 277 | . PHP_EOL; 278 | } 279 | 280 | /** @param string|null $output */ 281 | protected function display($output): void 282 | { 283 | $this->output 284 | ->write(\is_string($output) ? $output : '') 285 | ; 286 | } 287 | 288 | private function pingBefore(PingableInterface $schedule): void 289 | { 290 | if (!$schedule->hasPingBefore()) { 291 | $this->consoleLogger 292 | ->debug('There is no ping before url.'); 293 | 294 | return; 295 | } 296 | 297 | $this->httpClient 298 | ->ping($schedule->getPingBeforeUrl()); 299 | } 300 | 301 | private function pingAfter(PingableInterface $schedule): void 302 | { 303 | if (!$schedule->hasPingAfter()) { 304 | $this->consoleLogger 305 | ->debug('There is no ping after url.'); 306 | 307 | return; 308 | } 309 | 310 | $this->httpClient 311 | ->ping($schedule->getPingAfterUrl()); 312 | } 313 | 314 | private function logger(): Logger 315 | { 316 | if (null === $this->logger) { 317 | $this->logger = $this->loggerFactory 318 | ->create() 319 | ; 320 | } 321 | 322 | return $this->logger; 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /src/Exception/CrunzException.php: -------------------------------------------------------------------------------- 1 | toString()); 47 | $ignored[$path->toString()] = ''; 48 | } 49 | 50 | $directoryIterator = new \RecursiveDirectoryIterator($directoryPath, \FilesystemIterator::SKIP_DOTS); 51 | $recursiveIterator = new \RecursiveIteratorIterator( 52 | $directoryIterator, 53 | \RecursiveIteratorIterator::CHILD_FIRST 54 | ); 55 | 56 | /** @var \SplFileInfo $path */ 57 | foreach ($recursiveIterator as $path) { 58 | if (\array_key_exists($path->getPathname(), $ignored)) { 59 | ++$ignoredCount; 60 | 61 | continue; 62 | } 63 | 64 | $path->isDir() && !$path->isLink() 65 | ? \rmdir($path->getPathname()) 66 | : \unlink($path->getPathname()) 67 | ; 68 | } 69 | 70 | if (0 === $ignoredCount) { 71 | \rmdir($directoryPath); 72 | } 73 | } 74 | 75 | /** {@inheritdoc} */ 76 | public function dumpFile($filePath, $content): void 77 | { 78 | $directory = \pathinfo($filePath, \PATHINFO_DIRNAME); 79 | $this->createDirectory($directory); 80 | 81 | \file_put_contents($filePath, $content); 82 | } 83 | 84 | /** {@inheritdoc} */ 85 | public function createDirectory($directoryPath): void 86 | { 87 | if ($this->fileExists($directoryPath)) { 88 | return; 89 | } 90 | 91 | $created = \mkdir( 92 | $directoryPath, 93 | 0770, 94 | true 95 | ); 96 | 97 | if (!$created && !\is_dir($directoryPath)) { 98 | throw new \RuntimeException("Directory '{$directoryPath}' was not created."); 99 | } 100 | } 101 | 102 | /** 103 | * @param string $sourceFile 104 | * @param string $targetFile 105 | */ 106 | public function copy($sourceFile, $targetFile): void 107 | { 108 | \copy($sourceFile, $targetFile); 109 | } 110 | 111 | /** {@inheritdoc} */ 112 | public function projectRootDirectory() 113 | { 114 | if (null === $this->projectRootDir) { 115 | $dir = $rootDir = \dirname(__DIR__); 116 | $path = Path::fromStrings($dir, 'composer.json'); 117 | 118 | while (!\file_exists($path->toString())) { 119 | if ($dir === \dirname($dir)) { 120 | return $this->projectRootDir = $rootDir; 121 | } 122 | $dir = \dirname($dir); 123 | $path = Path::fromStrings($dir, 'composer.json'); 124 | } 125 | 126 | $this->projectRootDir = $dir; 127 | } 128 | 129 | return $this->projectRootDir; 130 | } 131 | 132 | /** 133 | * @param string $filePath 134 | * 135 | * @return string 136 | */ 137 | public function readContent($filePath) 138 | { 139 | if (!$this->fileExists($filePath)) { 140 | throw new \RuntimeException("File '{$filePath}' doesn't exists."); 141 | } 142 | 143 | $content = \file_get_contents($filePath); 144 | 145 | if (false === $content) { 146 | throw new \RuntimeException("Unable to get contents of file '{$filePath}'."); 147 | } 148 | 149 | return $content; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/Filesystem/FilesystemInterface.php: -------------------------------------------------------------------------------- 1 | toString(), $directoryIteratorFlags); 19 | $recursiveIterator = new \RecursiveIteratorIterator($directoryIterator); 20 | 21 | $regexIterator = new \RegexIterator( 22 | $recursiveIterator, 23 | "/^.+{$quotedSuffix}$/i", 24 | \RecursiveRegexIterator::GET_MATCH 25 | ); 26 | 27 | /** @var \SplFileInfo[] $files */ 28 | $files = \array_map( 29 | static function (array $file) { 30 | return new \SplFileInfo(\reset($file)); 31 | }, 32 | \iterator_to_array($regexIterator) 33 | ); 34 | 35 | return $files; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Finder/FinderInterface.php: -------------------------------------------------------------------------------- 1 | streamHttpClient = $streamHttpClient; 26 | $this->curlHttpClient = $curlHttpClient; 27 | $this->consoleLogger = $consoleLogger; 28 | } 29 | 30 | /** 31 | * @param string $url 32 | * 33 | * @throws HttpClientException 34 | */ 35 | public function ping($url): void 36 | { 37 | $httpClient = $this->chooseHttpClient(); 38 | $httpClient->ping($url); 39 | } 40 | 41 | /** 42 | * @return HttpClientInterface 43 | * 44 | * @throws HttpClientException 45 | */ 46 | private function chooseHttpClient() 47 | { 48 | if (null !== $this->httpClient) { 49 | return $this->httpClient; 50 | } 51 | 52 | $this->consoleLogger 53 | ->debug('Choosing HttpClient implementation.'); 54 | 55 | if (\function_exists('curl_exec')) { 56 | $this->httpClient = $this->curlHttpClient; 57 | 58 | $this->consoleLogger 59 | ->debug('cURL available, use CurlHttpClient.'); 60 | 61 | return $this->httpClient; 62 | } 63 | 64 | if ('1' === \ini_get('allow_url_fopen')) { 65 | $this->httpClient = $this->streamHttpClient; 66 | 67 | $this->consoleLogger 68 | ->debug("'allow_url_fopen' enabled, use StreamHttpClient"); 69 | 70 | return $this->httpClient; 71 | } 72 | 73 | $this->consoleLogger 74 | ->debug('Choosing HttpClient implementation failed.'); 75 | 76 | throw new HttpClientException( 77 | "Unable to choose HttpClient. Enable cURL extension (preffered) or turn on 'allow_url_fopen' in php.ini." 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/HttpClient/HttpClientException.php: -------------------------------------------------------------------------------- 1 | httpClient = $httpClient; 19 | $this->logger = $logger; 20 | } 21 | 22 | /** 23 | * {@inheritdoc} 24 | */ 25 | public function ping($url): void 26 | { 27 | $this->logger 28 | ->verbose("Trying to ping {$url}."); 29 | 30 | $this->httpClient 31 | ->ping($url); 32 | 33 | $this->logger 34 | ->verbose("Pinging url: {$url} was successful."); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/HttpClient/StreamHttpClient.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'user_agent' => 'Crunz StreamHttpClient', 20 | 'timeout' => 5, 21 | ], 22 | ] 23 | ); 24 | $resource = @\fopen( 25 | $url, 26 | 'rb', 27 | false, 28 | $context 29 | ); 30 | 31 | if (false === $resource) { 32 | $error = \error_get_last(); 33 | $errorMessage = $error['message'] ?? 'Unknown error'; 34 | 35 | throw new HttpClientException("Ping failed with message: \"{$errorMessage}\"."); 36 | } 37 | 38 | \fclose($resource); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Infrastructure/Dragonmantank/CronExpression/DragonmantankCronExpression.php: -------------------------------------------------------------------------------- 1 | innerCronExpression = $innerCronExpression; 18 | } 19 | 20 | /** {@inheritdoc} */ 21 | public function multipleRunDates(int $total, \DateTimeImmutable $now, ?\DateTimeZone $timeZone = null): array 22 | { 23 | $timeZoneNow = null !== $timeZone 24 | ? $now->setTimezone($timeZone) 25 | : $now 26 | ; 27 | 28 | $dates = $this->innerCronExpression 29 | ->getMultipleRunDates($total, $timeZoneNow) 30 | ; 31 | 32 | return \array_map( 33 | static function (\DateTime $runDate): \DateTimeImmutable { 34 | return \DateTimeImmutable::createFromMutable($runDate); 35 | }, 36 | $dates 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Infrastructure/Dragonmantank/CronExpression/DragonmantankCronExpressionFactory.php: -------------------------------------------------------------------------------- 1 | extractWrapper($serializedClosure); 25 | 26 | return $wrapper->getClosure(); 27 | } 28 | 29 | public function closureCode(\Closure $closure): string 30 | { 31 | $reflector = new ReflectionClosure($closure); 32 | 33 | return $reflector->getCode(); 34 | } 35 | 36 | private function extractWrapper(string $serializedClosure): SerializableClosure 37 | { 38 | return \unserialize( 39 | $serializedClosure, 40 | ['allowed_classes' => true] 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Infrastructure/Psr/Logger/EnabledLoggerDecorator.php: -------------------------------------------------------------------------------- 1 | decoratedLogger = $decoratedLogger; 20 | $this->configuration = $configuration; 21 | } 22 | 23 | public function log($level, $message, array $context = []): void 24 | { 25 | $loggingEnabled = true; 26 | switch ($level) { 27 | case LogLevel::INFO: 28 | $loggingEnabled = $this->configuration 29 | ->get('log_output') 30 | ; 31 | 32 | break; 33 | case LogLevel::ERROR: 34 | $loggingEnabled = $this->configuration 35 | ->get('log_errors') 36 | ; 37 | 38 | break; 39 | } 40 | 41 | if (false === $loggingEnabled) { 42 | return; 43 | } 44 | 45 | $this->decoratedLogger 46 | ->log( 47 | $level, 48 | $message, 49 | $context 50 | ) 51 | ; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Infrastructure/Psr/Logger/PsrStreamLogger.php: -------------------------------------------------------------------------------- 1 | outputStreamPath = $outputStreamPath ?? ''; 45 | $this->errorStreamPath = $errorStreamPath ?? ''; 46 | $this->ignoreEmptyContext = $ignoreEmptyContext; 47 | $this->timezoneLog = $timezoneLog; 48 | $this->allowLineBreaks = $allowLineBreaks; 49 | $this->timezone = $timezone; 50 | $this->clock = $clock; 51 | } 52 | 53 | public function __destruct() 54 | { 55 | $this->closeStream($this->outputHandler); 56 | $this->closeStream($this->errorHandler); 57 | } 58 | 59 | /** {@inheritdoc} */ 60 | public function log( 61 | $level, 62 | $message, 63 | array $context = [] 64 | ): void { 65 | switch ($level) { 66 | case LogLevel::INFO: 67 | $resource = $this->createInfoHandler(); 68 | 69 | break; 70 | case LogLevel::ERROR: 71 | $resource = $this->createErrorHandler(); 72 | 73 | break; 74 | default: 75 | $resource = null; 76 | } 77 | 78 | if (null === $resource) { 79 | return; 80 | } 81 | 82 | $date = $this->formatDate(); 83 | $levelFormatted = \mb_strtoupper($level); 84 | $extraString = $this->formatContext([]); 85 | $contextString = $this->formatContext($context); 86 | $formattedMessage = $this->replaceNewlines($message); 87 | $record = "[{$date}] crunz.{$levelFormatted}: {$formattedMessage} {$extraString} {$contextString}"; 88 | 89 | \fwrite($resource, $record . PHP_EOL); 90 | } 91 | 92 | /** @return resource */ 93 | private function createInfoHandler() 94 | { 95 | if (null === $this->outputHandler) { 96 | $this->outputHandler = $this->initializeHandler($this->outputStreamPath); 97 | } 98 | 99 | return $this->outputHandler; 100 | } 101 | 102 | /** @return resource */ 103 | private function createErrorHandler() 104 | { 105 | if (null === $this->errorHandler) { 106 | $this->errorHandler = $this->initializeHandler($this->errorStreamPath); 107 | } 108 | 109 | return $this->errorHandler; 110 | } 111 | 112 | /** @return resource */ 113 | private function initializeHandler(string $path) 114 | { 115 | if ('' === $path) { 116 | throw new CrunzException('Stream path cannot be empty.'); 117 | } 118 | 119 | $directory = $this->dirFromStream($path); 120 | if (null !== $directory) { 121 | if (\is_file($directory)) { 122 | throw new CrunzException( 123 | "Unable to create directory '{$directory}', file at this path already exists." 124 | ); 125 | } 126 | 127 | if (!\file_exists($directory)) { 128 | \mkdir( 129 | $directory, 130 | 0777, 131 | true 132 | ); 133 | } 134 | 135 | if (!\is_dir($directory)) { 136 | throw new CrunzException("Unable to create directory '{$directory}'."); 137 | } 138 | } 139 | 140 | $handler = \fopen($path, 'ab'); 141 | if (false === $handler) { 142 | throw new CrunzException("Unable to open stream for path: '{$path}'."); 143 | } 144 | 145 | return $handler; 146 | } 147 | 148 | /** @param resource|null $stream */ 149 | private function closeStream($stream): void 150 | { 151 | if (!\is_resource($stream)) { 152 | return; 153 | } 154 | 155 | \fclose($stream); 156 | } 157 | 158 | private function dirFromStream(string $stream): ?string 159 | { 160 | $pos = \mb_strpos($stream, '://'); 161 | if (false === $pos) { 162 | return \dirname($stream); 163 | } 164 | 165 | if (0 === \mb_strpos($stream, 'file://')) { 166 | return \dirname( 167 | \mb_substr( 168 | $stream, 169 | 7 170 | ) 171 | ); 172 | } 173 | 174 | return null; 175 | } 176 | 177 | /** @param array $data */ 178 | private function formatContext(array $data): string 179 | { 180 | if ($this->ignoreEmptyContext && empty($data)) { 181 | return ''; 182 | } 183 | 184 | $encodedData = \json_encode($data); 185 | 186 | if (false === $encodedData) { 187 | throw new CrunzException('Unable to encode context data.'); 188 | } 189 | 190 | return $encodedData; 191 | } 192 | 193 | private function formatDate(): string 194 | { 195 | $now = $this->clock 196 | ->now() 197 | ; 198 | 199 | if ($this->timezoneLog) { 200 | $now = $now->setTimezone($this->timezone); 201 | } 202 | 203 | return $now->format(self::DATE_FORMAT); 204 | } 205 | 206 | private function replaceNewlines(string $message): string 207 | { 208 | if ($this->allowLineBreaks) { 209 | if (0 === \mb_strpos($message, '{')) { 210 | return \str_replace( 211 | ['\r', '\n'], 212 | ["\r", "\n"], 213 | $message 214 | ); 215 | } 216 | 217 | return $message; 218 | } 219 | 220 | return \str_replace( 221 | [ 222 | "\r\n", 223 | "\r", 224 | "\n", 225 | ], 226 | ' ', 227 | $message 228 | ); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/Infrastructure/Psr/Logger/PsrStreamLoggerFactory.php: -------------------------------------------------------------------------------- 1 | timezoneProvider = $timezoneProvider; 23 | $this->clock = $clock; 24 | } 25 | 26 | public function create(ConfigurationInterface $configuration): LoggerInterface 27 | { 28 | $timezone = $this->timezoneProvider 29 | ->timezoneForComparisons() 30 | ; 31 | 32 | return new EnabledLoggerDecorator( 33 | new PsrStreamLogger( 34 | $timezone, 35 | $this->clock, 36 | $configuration->get('output_log_file'), 37 | $configuration->get('errors_log_file'), 38 | $configuration->get('log_ignore_empty_context'), 39 | $configuration->get('timezone_log'), 40 | $configuration->get('log_allow_line_breaks') 41 | ), 42 | $configuration 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Invoker.php: -------------------------------------------------------------------------------- 1 | $parameters 15 | * 16 | * @return mixed 17 | */ 18 | public function call($closure, array $parameters = [], $buffer = false) 19 | { 20 | if ($buffer) { 21 | \ob_start(); 22 | } 23 | 24 | $rslt = \call_user_func_array($closure, $parameters); 25 | 26 | if ($buffer) { 27 | return \ob_get_clean(); 28 | } 29 | 30 | return $rslt; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Logger/ConsoleLogger.php: -------------------------------------------------------------------------------- 1 | symfonyStyle = $symfonyStyle; 17 | } 18 | 19 | /** 20 | * @param string $message 21 | */ 22 | public function normal($message): void 23 | { 24 | $this->write($message, self::VERBOSITY_NORMAL); 25 | } 26 | 27 | /** 28 | * @param string $message 29 | */ 30 | public function verbose($message): void 31 | { 32 | $this->write($message, self::VERBOSITY_VERBOSE); 33 | } 34 | 35 | /** 36 | * @param string $message 37 | */ 38 | public function veryVerbose($message): void 39 | { 40 | $this->write($message, self::VERBOSITY_VERY_VERBOSE); 41 | } 42 | 43 | /** 44 | * Detailed debug information. 45 | * 46 | * @param string $message 47 | */ 48 | public function debug($message): void 49 | { 50 | $this->write($message, self::VERBOSITY_DEBUG); 51 | } 52 | 53 | /** 54 | * @param string $message 55 | * @param int $verbosity 56 | */ 57 | private function write($message, $verbosity): void 58 | { 59 | $ioVerbosity = $this->symfonyStyle 60 | ->getVerbosity(); 61 | 62 | if ($ioVerbosity >= $verbosity) { 63 | $this->symfonyStyle 64 | ->writeln($message); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Logger/ConsoleLoggerInterface.php: -------------------------------------------------------------------------------- 1 | psrLogger = $psrLogger; 16 | } 17 | 18 | /** 19 | * Log any output if output logging is enabled. 20 | */ 21 | public function info(string $message): void 22 | { 23 | $this->log($message, 'info'); 24 | } 25 | 26 | /** 27 | * Log the error is error logging is enabled. 28 | */ 29 | public function error(string $message): void 30 | { 31 | $this->log($message, 'error'); 32 | } 33 | 34 | private function log(string $content, string $level): void 35 | { 36 | $this->psrLogger 37 | ->log($level, $content) 38 | ; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Logger/LoggerFactory.php: -------------------------------------------------------------------------------- 1 | configuration = $configuration; 37 | $this->timezoneProvider = $timezoneProvider; 38 | $this->clock = $clock; 39 | $this->consoleLogger = $consoleLogger; 40 | } 41 | 42 | public function create(): Logger 43 | { 44 | $this->initializeLoggerFactory(); 45 | $configuration = $this->configuration; 46 | $innerLogger = $this->loggerFactory 47 | ->create($configuration) 48 | ; 49 | 50 | return new Logger($innerLogger); 51 | } 52 | 53 | public function createEvent(string $output): Logger 54 | { 55 | $this->initializeLoggerFactory(); 56 | $eventConfiguration = $this->configuration->withNewEntry('output_log_file', $output); 57 | $innerLogger = $this->loggerFactory 58 | ->create($eventConfiguration) 59 | ; 60 | 61 | return new Logger($innerLogger); 62 | } 63 | 64 | private function initializeLoggerFactory(): void 65 | { 66 | if (null === $this->loggerFactory) { 67 | $timezoneLog = $this->configuration 68 | ->get('timezone_log') 69 | ; 70 | 71 | if ($timezoneLog) { 72 | $timezone = $this->timezoneProvider 73 | ->timezoneForComparisons() 74 | ; 75 | 76 | $this->consoleLogger 77 | ->veryVerbose("Timezone for 'timezone_log': '{$timezone->getName()}'") 78 | ; 79 | } 80 | 81 | $this->loggerFactory = $this->createLoggerFactory( 82 | $this->configuration, 83 | $this->timezoneProvider, 84 | $this->clock 85 | ); 86 | } 87 | } 88 | 89 | private function createLoggerFactory( 90 | ConfigurationInterface $configuration, 91 | Timezone $timezoneProvider, 92 | ClockInterface $clock 93 | ): LoggerFactoryInterface { 94 | $params = []; 95 | $loggerFactoryClass = $configuration->get('logger_factory'); 96 | 97 | $this->consoleLogger 98 | ->veryVerbose("Class for 'logger_factory': '{$loggerFactoryClass}'.") 99 | ; 100 | 101 | if (!\class_exists($loggerFactoryClass)) { 102 | throw new CrunzException("Class '{$loggerFactoryClass}' does not exists."); 103 | } 104 | 105 | $isPsrStreamLoggerFactory = \is_a( 106 | $loggerFactoryClass, 107 | PsrStreamLoggerFactory::class, 108 | true 109 | ); 110 | if ($isPsrStreamLoggerFactory) { 111 | $params[] = $timezoneProvider; 112 | $params[] = $clock; 113 | } 114 | 115 | return new $loggerFactoryClass(...$params); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Mailer.php: -------------------------------------------------------------------------------- 1 | configuration = $configuration; 24 | } 25 | 26 | /** 27 | * Send an email. 28 | * 29 | * @throws MailerException 30 | */ 31 | public function send(string $subject, string $message): void 32 | { 33 | $this->getMailer() 34 | ->send( 35 | $this->getMessage($subject, $message) 36 | ) 37 | ; 38 | } 39 | 40 | /** 41 | * Return the proper mailer. 42 | * 43 | * @throws MailerException 44 | */ 45 | private function getMailer(): SymfonyMailer 46 | { 47 | // If the mailer has already been defined via the constructor, return it. 48 | if ($this->mailer) { 49 | return $this->mailer; 50 | } 51 | 52 | // Get the proper transporter 53 | switch ($this->config('mailer.transport')) { 54 | case 'smtp': 55 | $transport = $this->getSmtpTransport(); 56 | 57 | break; 58 | 59 | case 'mail': 60 | throw new MailerException( 61 | "'mail' transport is no longer supported, please use 'smtp' or 'sendmail' transport." 62 | ); 63 | 64 | default: 65 | $transport = $this->getSendMailTransport(); 66 | } 67 | 68 | $this->mailer = new SymfonyMailer($transport); 69 | 70 | return $this->mailer; 71 | } 72 | 73 | private function getSmtpTransport(): Transport\TransportInterface 74 | { 75 | $host = $this->config('smtp.host'); 76 | $port = $this->config('smtp.port'); 77 | $encryption = \filter_var($this->config('smtp.encryption') ?? true, FILTER_VALIDATE_BOOLEAN); 78 | $user = $this->config('smtp.username'); 79 | $password = $this->config('smtp.password'); 80 | $encryptionString = $encryption 81 | ? 1 82 | : 0 83 | ; 84 | $userPart = null !== $user && null !== $password 85 | ? "{$user}:{$password}@" 86 | : '' 87 | ; 88 | 89 | $dsn = "smtp://{$userPart}{$host}:{$port}?verifyPeer={$encryptionString}"; 90 | 91 | return Transport::fromDsn($dsn); 92 | } 93 | 94 | private function getSendMailTransport(): Transport\TransportInterface 95 | { 96 | $dsn = 'sendmail://default'; 97 | 98 | return Transport::fromDsn($dsn); 99 | } 100 | 101 | private function getMessage(string $subject, string $message): Email 102 | { 103 | $from = new Address($this->config('mailer.sender_email'), $this->config('mailer.sender_name')); 104 | $messageObject = new Email(); 105 | $messageObject 106 | ->from($from) 107 | ->subject($subject) 108 | ->text($message) 109 | ; 110 | foreach ($this->config('mailer.recipients') ?? [] as $recipient) { 111 | $messageObject->addTo($recipient); 112 | } 113 | 114 | return $messageObject; 115 | } 116 | 117 | /** @return mixed */ 118 | private function config(string $key) 119 | { 120 | return $this->configuration 121 | ->get($key) 122 | ; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Output/OutputFactory.php: -------------------------------------------------------------------------------- 1 | input = $input; 19 | } 20 | 21 | public function createOutput(): OutputInterface 22 | { 23 | $input = $this->input; 24 | $output = new ConsoleOutput(); 25 | 26 | if (true === $input->hasParameterOption(['--quiet', '-q'])) { 27 | $output->setVerbosity(OutputInterface::VERBOSITY_QUIET); 28 | } elseif ($input->hasParameterOption('-vvv') || $input->hasParameterOption('--verbose=3') || 3 === $input->getParameterOption('--verbose')) { 29 | $output->setVerbosity(OutputInterface::VERBOSITY_DEBUG); 30 | } elseif ($input->hasParameterOption('-vv') || $input->hasParameterOption('--verbose=2') || 2 === $input->getParameterOption('--verbose')) { 31 | $output->setVerbosity(OutputInterface::VERBOSITY_VERY_VERBOSE); 32 | } elseif ($input->hasParameterOption('-v') || $input->hasParameterOption('--verbose=1') || $input->hasParameterOption('--verbose') || $input->getParameterOption('--verbose')) { 33 | $output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE); 34 | } 35 | 36 | return $output; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Path/Path.php: -------------------------------------------------------------------------------- 1 | path = $path; 17 | } 18 | 19 | /** 20 | * @param string[] $parts 21 | * 22 | * @return Path 23 | * 24 | * @throws CrunzException 25 | */ 26 | public static function create(array $parts): self 27 | { 28 | if (0 === \count($parts)) { 29 | throw new CrunzException('At least one part expected.'); 30 | } 31 | 32 | $normalizedPath = \str_replace( 33 | DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, 34 | DIRECTORY_SEPARATOR, 35 | \implode(DIRECTORY_SEPARATOR, $parts) 36 | ); 37 | 38 | return new self($normalizedPath); 39 | } 40 | 41 | /** 42 | * @return Path 43 | * 44 | * @throws CrunzException 45 | */ 46 | public static function fromStrings(string ...$parts): self 47 | { 48 | return self::create($parts); 49 | } 50 | 51 | public function toString(): string 52 | { 53 | return $this->path; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Pinger/PingableException.php: -------------------------------------------------------------------------------- 1 | checkUrl($url); 20 | 21 | $this->pingBeforeUrl = $url; 22 | 23 | return $this; 24 | } 25 | 26 | /** 27 | * {@inheritdoc} 28 | */ 29 | public function hasPingBefore() 30 | { 31 | return '' !== $this->pingBeforeUrl; 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function thenPing($url) 38 | { 39 | $this->checkUrl($url); 40 | 41 | $this->pingAfterUrl = $url; 42 | 43 | return $this; 44 | } 45 | 46 | /** 47 | * {@inheritdoc} 48 | */ 49 | public function hasPingAfter() 50 | { 51 | return '' !== $this->pingAfterUrl; 52 | } 53 | 54 | /** 55 | * {@inheritdoc} 56 | */ 57 | public function getPingBeforeUrl() 58 | { 59 | if (!$this->hasPingBefore()) { 60 | throw new PingableException('PingBeforeUrl is empty.'); 61 | } 62 | 63 | return $this->pingBeforeUrl; 64 | } 65 | 66 | /** 67 | * {@inheritdoc} 68 | */ 69 | public function getPingAfterUrl() 70 | { 71 | if (!$this->hasPingAfter()) { 72 | throw new PingableException('PingAfterUrl is empty.'); 73 | } 74 | 75 | return $this->pingAfterUrl; 76 | } 77 | 78 | /** 79 | * @param string $url 80 | * 81 | * @throws PingableException 82 | */ 83 | private function checkUrl($url): void 84 | { 85 | if (!\is_string($url)) { 86 | $type = \gettype($url); 87 | throw new PingableException("Url must be of type string, '{$type}' given."); 88 | } 89 | 90 | if ('' === $url) { 91 | throw new PingableException('Url cannot be empty.'); 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Process/Process.php: -------------------------------------------------------------------------------- 1 | process = $process; 19 | } 20 | 21 | public static function fromStringCommand(string $command, ?string $cwd = null): self 22 | { 23 | $process = SymfonyProcess::fromShellCommandline($command, $cwd); 24 | 25 | return new self($process); 26 | } 27 | 28 | /** @param callable|null $callback */ 29 | public function start($callback = null): void 30 | { 31 | $this->process 32 | ->start($callback); 33 | } 34 | 35 | public function wait(): void 36 | { 37 | $this->process 38 | ->wait(); 39 | } 40 | 41 | public function startAndWait(): void 42 | { 43 | $this->process 44 | ->start(); 45 | $this->process 46 | ->wait(); 47 | } 48 | 49 | /** @param array $env */ 50 | public function setEnv(array $env): void 51 | { 52 | $this->process 53 | ->setEnv($env); 54 | } 55 | 56 | public function getPid(): ?int 57 | { 58 | return $this->process 59 | ->getPid(); 60 | } 61 | 62 | public function isRunning(): bool 63 | { 64 | return $this->process 65 | ->isRunning(); 66 | } 67 | 68 | public function isSuccessful(): bool 69 | { 70 | return $this->process 71 | ->isSuccessful(); 72 | } 73 | 74 | public function getOutput(): string 75 | { 76 | return $this->process 77 | ->getOutput(); 78 | } 79 | 80 | public function errorOutput(): string 81 | { 82 | return $this->process 83 | ->getErrorOutput(); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/ProcessUtils.php: -------------------------------------------------------------------------------- 1 | 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Crunz; 17 | 18 | use Symfony\Component\Process\Exception\InvalidArgumentException; 19 | use Symfony\Component\Process\Process; 20 | 21 | /** 22 | * ProcessUtils is a bunch of utility methods. 23 | * 24 | * This class contains static methods only and is not meant to be instantiated. 25 | * 26 | * @author Martin Hasoň 27 | * 28 | * @todo Remove this class as it is deprecated in Symfony 3.3 29 | */ 30 | class ProcessUtils 31 | { 32 | /** 33 | * This class should not be instantiated. 34 | */ 35 | private function __construct() 36 | { 37 | } 38 | 39 | /** 40 | * Escapes a string to be used as a shell argument. 41 | * 42 | * @param string $argument The argument that will be escaped 43 | * 44 | * @return string The escaped argument 45 | */ 46 | public static function escapeArgument($argument) 47 | { 48 | //Fix for PHP bug #43784 escapeshellarg removes % from given string 49 | //Fix for PHP bug #49446 escapeshellarg doesn't work on Windows 50 | //@see https://bugs.php.net/bug.php?id=43784 51 | //@see https://bugs.php.net/bug.php?id=49446 52 | if ('\\' === DIRECTORY_SEPARATOR) { 53 | if ('' === $argument) { 54 | return \escapeshellarg($argument); 55 | } 56 | 57 | $escapedArgument = ''; 58 | $quote = false; 59 | /** @var string[] $parts */ 60 | $parts = \preg_split('/(")/', $argument, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE); 61 | 62 | foreach ($parts as $part) { 63 | if ('"' === $part) { 64 | $escapedArgument .= '\\"'; 65 | } elseif (self::isSurroundedBy($part, '%')) { 66 | // Avoid environment variable expansion 67 | $escapedArgument .= '^%"' . \mb_substr($part, 1, -1) . '"^%'; 68 | } else { 69 | // escape trailing backslash 70 | if ('\\' === \mb_substr($part, -1)) { 71 | $part .= '\\'; 72 | } 73 | $quote = true; 74 | $escapedArgument .= $part; 75 | } 76 | } 77 | if ($quote) { 78 | $escapedArgument = '"' . $escapedArgument . '"'; 79 | } 80 | 81 | return $escapedArgument; 82 | } 83 | 84 | return "'" . \str_replace("'", "'\\''", $argument) . "'"; 85 | } 86 | 87 | /** 88 | * Validates and normalizes a Process input. 89 | * 90 | * @param string $caller The name of method call that validates the input 91 | * @param mixed $input The input to validate 92 | * 93 | * @return mixed The validated input 94 | * 95 | * @throws InvalidArgumentException In case the input is not valid 96 | */ 97 | public static function validateInput($caller, $input) 98 | { 99 | if (null !== $input) { 100 | if (\is_resource($input)) { 101 | return $input; 102 | } 103 | if (\is_string($input)) { 104 | return $input; 105 | } 106 | if (\is_scalar($input)) { 107 | return (string) $input; 108 | } 109 | if ($input instanceof Process) { 110 | return $input->getIterator($input::ITER_SKIP_ERR); 111 | } 112 | if ($input instanceof \Iterator) { 113 | return $input; 114 | } 115 | if ($input instanceof \Traversable) { 116 | return new \IteratorIterator($input); 117 | } 118 | 119 | throw new InvalidArgumentException(\sprintf('%s only accepts strings, Traversable objects or stream resources.', $caller)); 120 | } 121 | 122 | return $input; 123 | } 124 | 125 | private static function isSurroundedBy(string $arg, string $char): bool 126 | { 127 | return 2 < \mb_strlen($arg) && $char === $arg[0] && $char === $arg[\mb_strlen($arg) - 1]; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Schedule.php: -------------------------------------------------------------------------------- 1 | compileParameters($parameters); 54 | } 55 | 56 | $this->events[] = $event = new Event($this->id(), $command); 57 | 58 | return $event; 59 | } 60 | 61 | /** 62 | * Register a callback to be called before the operation. 63 | * 64 | * @return $this 65 | */ 66 | public function before(\Closure $callback) 67 | { 68 | $this->beforeCallbacks[] = $callback; 69 | 70 | return $this; 71 | } 72 | 73 | /** 74 | * Register a callback to be called after the operation. 75 | * 76 | * @return $this 77 | */ 78 | public function after(\Closure $callback) 79 | { 80 | return $this->then($callback); 81 | } 82 | 83 | /** 84 | * Register a callback to be called after the operation. 85 | * 86 | * @return $this 87 | */ 88 | public function then(\Closure $callback) 89 | { 90 | $this->afterCallbacks[] = $callback; 91 | 92 | return $this; 93 | } 94 | 95 | /** 96 | * Register a callback to call in case of an error. 97 | * 98 | * @return $this 99 | */ 100 | public function onError(\Closure $callback) 101 | { 102 | $this->errorCallbacks[] = $callback; 103 | 104 | return $this; 105 | } 106 | 107 | /** 108 | * Return all registered before callbacks. 109 | * 110 | * @return \Closure[] 111 | */ 112 | public function beforeCallbacks() 113 | { 114 | return $this->beforeCallbacks; 115 | } 116 | 117 | /** 118 | * Return all registered after callbacks. 119 | * 120 | * @return \Closure[] 121 | */ 122 | public function afterCallbacks() 123 | { 124 | return $this->afterCallbacks; 125 | } 126 | 127 | /** 128 | * Return all registered error callbacks. 129 | * 130 | * @return \Closure[] 131 | */ 132 | public function errorCallbacks() 133 | { 134 | return $this->errorCallbacks; 135 | } 136 | 137 | /** 138 | * Get or set the events of the schedule object. 139 | * 140 | * @param Event[] $events 141 | * 142 | * @return Event[] 143 | */ 144 | public function events(array $events = null) 145 | { 146 | if (null !== $events) { 147 | return $this->events = $events; 148 | } 149 | 150 | return $this->events; 151 | } 152 | 153 | /** 154 | * Get all of the events on the schedule that are due. 155 | * 156 | * @return Event[] 157 | */ 158 | public function dueEvents(\DateTimeZone $timeZone) 159 | { 160 | return \array_filter( 161 | $this->events, 162 | static function (Event $event) use ($timeZone) { 163 | return $event->isDue($timeZone); 164 | } 165 | ); 166 | } 167 | 168 | /** 169 | * Dismiss an event after it is finished. 170 | * 171 | * @param int $key 172 | * 173 | * @return $this 174 | */ 175 | public function dismissEvent($key) 176 | { 177 | unset($this->events[$key]); 178 | 179 | return $this; 180 | } 181 | 182 | /** 183 | * Generate a unique task id. 184 | * 185 | * @return string 186 | */ 187 | protected function id() 188 | { 189 | while (true) { 190 | $id = \uniqid('crunz', true); 191 | if (!\array_key_exists($id, $this->events)) { 192 | return $id; 193 | } 194 | } 195 | } 196 | 197 | /** 198 | * Compile parameters for a command. 199 | * 200 | * @param string[] $parameters 201 | * 202 | * @return string 203 | */ 204 | protected function compileParameters(array $parameters) 205 | { 206 | return \implode( 207 | ' ', 208 | \array_map( 209 | function ($value, $key) { 210 | return \is_numeric($key) ? $value : "{$key}=" . (\is_numeric($value) ? $value : ProcessUtils::escapeArgument($value)); 211 | }, 212 | $parameters, 213 | \array_keys($parameters) 214 | ) 215 | ); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/Schedule/ScheduleFactory.php: -------------------------------------------------------------------------------- 1 | singleTask($taskNumber, ...$schedules); 22 | 23 | $schedule = new Schedule(); 24 | $schedule->events([$event]); 25 | 26 | return [$schedule]; 27 | } 28 | 29 | /** @throws TaskNotExistException */ 30 | public function singleTask(TaskNumber $taskNumber, Schedule ...$schedules): Event 31 | { 32 | $events = \array_map( 33 | static function (Schedule $schedule) { 34 | return $schedule->events(); 35 | }, 36 | $schedules 37 | ); 38 | 39 | $flattenEvents = \array_merge(...$events); 40 | 41 | if (!isset($flattenEvents[$taskNumber->asArrayIndex()])) { 42 | $tasksCount = \count($flattenEvents); 43 | throw new TaskNotExistException( 44 | "Task with id '{$taskNumber->asInt()}' was not found. Last task id is '{$tasksCount}'." 45 | ); 46 | } 47 | 48 | return $flattenEvents[$taskNumber->asArrayIndex()]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Stubs/BasicTask.php: -------------------------------------------------------------------------------- 1 | run('DummyCommand'); 19 | $task 20 | ->description('DummyDescription') 21 | ->in('DummyPath') 22 | ->preventOverlapping() 23 | ->DummyFrequency() 24 | ->DummyConstraint() 25 | ; 26 | 27 | return $scheduler; 28 | -------------------------------------------------------------------------------- /src/Task/Collection.php: -------------------------------------------------------------------------------- 1 | configuration = $configuration; 27 | $this->finder = $finder; 28 | $this->consoleLogger = $consoleLogger; 29 | } 30 | 31 | /** 32 | * @return \SplFileInfo[] 33 | */ 34 | public function all(string $source): iterable 35 | { 36 | $this->consoleLogger 37 | ->debug("Task source path '${source}'"); 38 | 39 | if (!\file_exists($source)) { 40 | return []; 41 | } 42 | 43 | $suffix = $this->configuration 44 | ->get('suffix') 45 | ; 46 | 47 | $this->consoleLogger 48 | ->debug("Task finder suffix: '{$suffix}'"); 49 | 50 | $realPath = \realpath($source); 51 | if (false !== $realPath) { 52 | $this->consoleLogger 53 | ->verbose("Realpath for '{$source}' is '{$realPath}'"); 54 | } else { 55 | $this->consoleLogger 56 | ->verbose("Realpath resolve for '{$source}' failed."); 57 | } 58 | 59 | $tasks = $this->finder 60 | ->find(Path::fromStrings($source), $suffix) 61 | ; 62 | $tasksCount = \count($tasks); 63 | 64 | $this->consoleLogger 65 | ->debug("Found {$tasksCount} task(s) at path '{$source}'"); 66 | 67 | return $tasks; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Task/Loader.php: -------------------------------------------------------------------------------- 1 | loadSchedule($file); 22 | if (!$schedule instanceof Schedule) { 23 | throw WrongTaskInstanceException::fromFilePath($file, $schedule); 24 | } 25 | 26 | $schedules[] = $schedule; 27 | } 28 | 29 | return $schedules; 30 | } 31 | 32 | /** @return Schedule|mixed */ 33 | private function loadSchedule(\SplFileInfo $file) 34 | { 35 | return require $file->getRealPath(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Task/LoaderInterface.php: -------------------------------------------------------------------------------- 1 | number = $number; 27 | } 28 | 29 | /** 30 | * @param string $value 31 | * 32 | * @return TaskNumber 33 | * 34 | * @throws WrongTaskNumberException 35 | */ 36 | public static function fromString($value) 37 | { 38 | if (!\is_string($value)) { 39 | throw new WrongTaskNumberException('Passed task number is not string.'); 40 | } 41 | 42 | if (!\is_numeric($value)) { 43 | throw new WrongTaskNumberException("Task number '{$value}' is not numeric."); 44 | } 45 | 46 | $number = (int) $value; 47 | 48 | return new self($number); 49 | } 50 | 51 | /** 52 | * @return int 53 | */ 54 | public function asInt() 55 | { 56 | return $this->number; 57 | } 58 | 59 | /** 60 | * @return int 61 | */ 62 | public function asArrayIndex() 63 | { 64 | return $this->number - 1; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Task/Timezone.php: -------------------------------------------------------------------------------- 1 | configuration = $configuration; 25 | $this->consoleLogger = $consoleLogger; 26 | } 27 | 28 | /** @throws EmptyTimezoneException */ 29 | public function timezoneForComparisons(): \DateTimeZone 30 | { 31 | if (null !== $this->timezoneForComparisons) { 32 | return $this->timezoneForComparisons; 33 | } 34 | 35 | $newTimezone = $this->configuration 36 | ->get('timezone') 37 | ; 38 | 39 | $this->consoleLogger 40 | ->debug("Timezone from config: '{$newTimezone}'."); 41 | 42 | if (empty($newTimezone)) { 43 | throw new EmptyTimezoneException('Timezone must be configured. Please add it to your config file.'); 44 | } 45 | 46 | $this->consoleLogger 47 | ->debug("Timezone for comparisons: '{$newTimezone}'."); 48 | 49 | $this->timezoneForComparisons = new \DateTimeZone($newTimezone); 50 | 51 | return $this->timezoneForComparisons; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Task/WrongTaskInstanceException.php: -------------------------------------------------------------------------------- 1 | getRealPath(); 20 | 21 | return new self( 22 | "Task at path '{$path}' returned '{$type}', but '{$expectedInstance}' instance is required." 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Timezone/Provider.php: -------------------------------------------------------------------------------- 1 | closureSerializer = $closureSerializer; 21 | 22 | parent::__construct(); 23 | } 24 | 25 | /** 26 | * Configures the current command. 27 | */ 28 | protected function configure(): void 29 | { 30 | $this 31 | ->setName('closure:run') 32 | ->setDescription('Executes a closure as a process.') 33 | ->setDefinition( 34 | [ 35 | new InputArgument( 36 | 'closure', 37 | InputArgument::REQUIRED, 38 | 'The closure to run' 39 | ), 40 | ] 41 | ) 42 | ->setHelp('This command executes a closure as a separate process.') 43 | ->setHidden(true) 44 | ; 45 | } 46 | 47 | /** {@inheritdoc} */ 48 | protected function execute(InputInterface $input, OutputInterface $output): ?int 49 | { 50 | $args = []; 51 | /** @var string $closure */ 52 | $closure = $input->getArgument('closure'); 53 | \parse_str($closure, $args); 54 | $closure = $this->closureSerializer 55 | ->unserialize($args[0] ?? '') 56 | ; 57 | 58 | \call_user_func_array($closure, []); 59 | 60 | return 0; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/UserInterface/Cli/DebugTaskCommand.php: -------------------------------------------------------------------------------- 1 | taskInformationHandler = $taskInformationHandler; 30 | 31 | parent::__construct(); 32 | } 33 | 34 | protected function configure(): void 35 | { 36 | $this 37 | ->setDescription('Shows all information about task') 38 | ->addArgument( 39 | 'taskNumber', 40 | InputArgument::REQUIRED, 41 | 'Task number from schedule:list command' 42 | ) 43 | ->setName(self::$defaultName) 44 | ; 45 | } 46 | 47 | protected function execute(InputInterface $input, OutputInterface $output): int 48 | { 49 | /** @var string|null $rawTaskNumber */ 50 | $rawTaskNumber = $input->getArgument('taskNumber'); 51 | $taskNumber = TaskNumber::fromString((string) $rawTaskNumber); 52 | $taskInformationView = $this->taskInformationHandler 53 | ->handle(new TaskInformation($taskNumber)) 54 | ; 55 | 56 | $table = $this->createTable($taskInformationView, $output, $taskNumber); 57 | $table->render(); 58 | 59 | return 0; 60 | } 61 | 62 | private function createTable( 63 | TaskInformationView $taskInformation, 64 | OutputInterface $output, 65 | TaskNumber $taskNumber 66 | ): Table { 67 | $command = $taskInformation->command(); 68 | $timeZone = $taskInformation->timeZone(); 69 | $configTimeZone = $taskInformation->configTimeZone(); 70 | $runDates = \array_map( 71 | static function (\DateTimeImmutable $netRunDate): string { 72 | return $netRunDate->format('Y-m-d H:i:s e'); 73 | }, 74 | $taskInformation->nextRuns() 75 | ); 76 | 77 | $table = new Table($output); 78 | $table->setHeaders( 79 | [ 80 | new TableCell( 81 | "Debug information for task '{$taskNumber->asInt()}'", 82 | ['colspan' => 2] 83 | ), 84 | ] 85 | ); 86 | $table->addRows( 87 | [ 88 | [ 89 | 'Command to run', 90 | \is_object($command) 91 | ? \get_class($command) 92 | : $command, 93 | ], 94 | [ 95 | 'Description', 96 | $taskInformation->description(), 97 | ], 98 | [ 99 | 'Prevent overlapping', 100 | $taskInformation->preventOverlapping() 101 | ? 'Yes' 102 | : 'No', 103 | ], 104 | new TableSeparator(), 105 | [ 106 | 'Cron expression', 107 | $taskInformation->cronExpression(), 108 | ], 109 | [ 110 | 'Comparisons timezone', 111 | null !== $timeZone 112 | ? "{$timeZone->getName()} (from task)" 113 | : "{$configTimeZone->getName()} (from config)", 114 | ], 115 | new TableSeparator(), 116 | [new TableCell('Example run dates', ['colspan' => 2])], 117 | ] 118 | ); 119 | 120 | $i = 1; 121 | foreach ($runDates as $date) { 122 | $table->addRow( 123 | [ 124 | "#{$i}", 125 | $date, 126 | ] 127 | ); 128 | ++$i; 129 | } 130 | 131 | return $table; 132 | } 133 | } 134 | --------------------------------------------------------------------------------