Scheduler.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. <?php
  2. /**
  3. * @package Grav\Common\Scheduler
  4. * @author Originally based on peppeocchi/php-cron-scheduler modified for Grav integration
  5. * @copyright Copyright (c) 2015 - 2023 Trilby Media, LLC. All rights reserved.
  6. * @license MIT License; see LICENSE file for details.
  7. */
  8. namespace Grav\Common\Scheduler;
  9. use DateTime;
  10. use Grav\Common\Filesystem\Folder;
  11. use Grav\Common\Grav;
  12. use Grav\Common\Utils;
  13. use InvalidArgumentException;
  14. use Symfony\Component\Process\PhpExecutableFinder;
  15. use Symfony\Component\Process\Process;
  16. use RocketTheme\Toolbox\File\YamlFile;
  17. use function is_callable;
  18. use function is_string;
  19. /**
  20. * Class Scheduler
  21. * @package Grav\Common\Scheduler
  22. */
  23. class Scheduler
  24. {
  25. /** @var Job[] The queued jobs. */
  26. private $jobs = [];
  27. /** @var Job[] */
  28. private $saved_jobs = [];
  29. /** @var Job[] */
  30. private $executed_jobs = [];
  31. /** @var Job[] */
  32. private $failed_jobs = [];
  33. /** @var Job[] */
  34. private $jobs_run = [];
  35. /** @var array */
  36. private $output_schedule = [];
  37. /** @var array */
  38. private $config;
  39. /** @var string */
  40. private $status_path;
  41. /**
  42. * Create new instance.
  43. */
  44. public function __construct()
  45. {
  46. $config = Grav::instance()['config']->get('scheduler.defaults', []);
  47. $this->config = $config;
  48. $this->status_path = Grav::instance()['locator']->findResource('user-data://scheduler', true, true);
  49. if (!file_exists($this->status_path)) {
  50. Folder::create($this->status_path);
  51. }
  52. }
  53. /**
  54. * Load saved jobs from config/scheduler.yaml file
  55. *
  56. * @return $this
  57. */
  58. public function loadSavedJobs()
  59. {
  60. $this->saved_jobs = [];
  61. $saved_jobs = (array) Grav::instance()['config']->get('scheduler.custom_jobs', []);
  62. foreach ($saved_jobs as $id => $j) {
  63. $args = $j['args'] ?? [];
  64. $id = Grav::instance()['inflector']->hyphenize($id);
  65. $job = $this->addCommand($j['command'], $args, $id);
  66. if (isset($j['at'])) {
  67. $job->at($j['at']);
  68. }
  69. if (isset($j['output'])) {
  70. $mode = isset($j['output_mode']) && $j['output_mode'] === 'append';
  71. $job->output($j['output'], $mode);
  72. }
  73. if (isset($j['email'])) {
  74. $job->email($j['email']);
  75. }
  76. // store in saved_jobs
  77. $this->saved_jobs[] = $job;
  78. }
  79. return $this;
  80. }
  81. /**
  82. * Get the queued jobs as background/foreground
  83. *
  84. * @param bool $all
  85. * @return array
  86. */
  87. public function getQueuedJobs($all = false)
  88. {
  89. $background = [];
  90. $foreground = [];
  91. foreach ($this->jobs as $job) {
  92. if ($all || $job->getEnabled()) {
  93. if ($job->runInBackground()) {
  94. $background[] = $job;
  95. } else {
  96. $foreground[] = $job;
  97. }
  98. }
  99. }
  100. return [$background, $foreground];
  101. }
  102. /**
  103. * Get all jobs if they are disabled or not as one array
  104. *
  105. * @return Job[]
  106. */
  107. public function getAllJobs()
  108. {
  109. [$background, $foreground] = $this->loadSavedJobs()->getQueuedJobs(true);
  110. return array_merge($background, $foreground);
  111. }
  112. /**
  113. * Get a specific Job based on id
  114. *
  115. * @param string $jobid
  116. * @return Job|null
  117. */
  118. public function getJob($jobid)
  119. {
  120. $all = $this->getAllJobs();
  121. foreach ($all as $job) {
  122. if ($jobid == $job->getId()) {
  123. return $job;
  124. }
  125. }
  126. return null;
  127. }
  128. /**
  129. * Queues a PHP function execution.
  130. *
  131. * @param callable $fn The function to execute
  132. * @param array $args Optional arguments to pass to the php script
  133. * @param string|null $id Optional custom identifier
  134. * @return Job
  135. */
  136. public function addFunction(callable $fn, $args = [], $id = null)
  137. {
  138. $job = new Job($fn, $args, $id);
  139. $this->queueJob($job->configure($this->config));
  140. return $job;
  141. }
  142. /**
  143. * Queue a raw shell command.
  144. *
  145. * @param string $command The command to execute
  146. * @param array $args Optional arguments to pass to the command
  147. * @param string|null $id Optional custom identifier
  148. * @return Job
  149. */
  150. public function addCommand($command, $args = [], $id = null)
  151. {
  152. $job = new Job($command, $args, $id);
  153. $this->queueJob($job->configure($this->config));
  154. return $job;
  155. }
  156. /**
  157. * Run the scheduler.
  158. *
  159. * @param DateTime|null $runTime Optional, run at specific moment
  160. * @param bool $force force run even if not due
  161. */
  162. public function run(DateTime $runTime = null, $force = false)
  163. {
  164. $this->loadSavedJobs();
  165. [$background, $foreground] = $this->getQueuedJobs(false);
  166. $alljobs = array_merge($background, $foreground);
  167. if (null === $runTime) {
  168. $runTime = new DateTime('now');
  169. }
  170. // Star processing jobs
  171. foreach ($alljobs as $job) {
  172. if ($job->isDue($runTime) || $force) {
  173. $job->run();
  174. $this->jobs_run[] = $job;
  175. }
  176. }
  177. // Finish handling any background jobs
  178. foreach ($background as $job) {
  179. $job->finalize();
  180. }
  181. // Store states
  182. $this->saveJobStates();
  183. }
  184. /**
  185. * Reset all collected data of last run.
  186. *
  187. * Call before run() if you call run() multiple times.
  188. *
  189. * @return $this
  190. */
  191. public function resetRun()
  192. {
  193. // Reset collected data of last run
  194. $this->executed_jobs = [];
  195. $this->failed_jobs = [];
  196. $this->output_schedule = [];
  197. return $this;
  198. }
  199. /**
  200. * Get the scheduler verbose output.
  201. *
  202. * @param string $type Allowed: text, html, array
  203. * @return string|array The return depends on the requested $type
  204. */
  205. public function getVerboseOutput($type = 'text')
  206. {
  207. switch ($type) {
  208. case 'text':
  209. return implode("\n", $this->output_schedule);
  210. case 'html':
  211. return implode('<br>', $this->output_schedule);
  212. case 'array':
  213. return $this->output_schedule;
  214. default:
  215. throw new InvalidArgumentException('Invalid output type');
  216. }
  217. }
  218. /**
  219. * Remove all queued Jobs.
  220. *
  221. * @return $this
  222. */
  223. public function clearJobs()
  224. {
  225. $this->jobs = [];
  226. return $this;
  227. }
  228. /**
  229. * Helper to get the full Cron command
  230. *
  231. * @return string
  232. */
  233. public function getCronCommand()
  234. {
  235. $command = $this->getSchedulerCommand();
  236. return "(crontab -l; echo \"* * * * * {$command} 1>> /dev/null 2>&1\") | crontab -";
  237. }
  238. /**
  239. * @param string|null $php
  240. * @return string
  241. */
  242. public function getSchedulerCommand($php = null)
  243. {
  244. $phpBinaryFinder = new PhpExecutableFinder();
  245. $php = $php ?? $phpBinaryFinder->find();
  246. $command = 'cd ' . str_replace(' ', '\ ', GRAV_ROOT) . ';' . $php . ' bin/grav scheduler';
  247. return $command;
  248. }
  249. /**
  250. * Helper to determine if cron job is setup
  251. * 0 - Crontab Not found
  252. * 1 - Crontab Found
  253. * 2 - Error
  254. *
  255. * @return int
  256. */
  257. public function isCrontabSetup()
  258. {
  259. $process = new Process(['crontab', '-l']);
  260. $process->run();
  261. if ($process->isSuccessful()) {
  262. $output = $process->getOutput();
  263. $command = str_replace('/', '\/', $this->getSchedulerCommand('.*'));
  264. $full_command = '/^(?!#).* .* .* .* .* ' . $command . '/m';
  265. return preg_match($full_command, $output) ? 1 : 0;
  266. }
  267. $error = $process->getErrorOutput();
  268. return Utils::startsWith($error, 'crontab: no crontab') ? 0 : 2;
  269. }
  270. /**
  271. * Get the Job states file
  272. *
  273. * @return YamlFile
  274. */
  275. public function getJobStates()
  276. {
  277. return YamlFile::instance($this->status_path . '/status.yaml');
  278. }
  279. /**
  280. * Save job states to statys file
  281. *
  282. * @return void
  283. */
  284. private function saveJobStates()
  285. {
  286. $now = time();
  287. $new_states = [];
  288. foreach ($this->jobs_run as $job) {
  289. if ($job->isSuccessful()) {
  290. $new_states[$job->getId()] = ['state' => 'success', 'last-run' => $now];
  291. $this->pushExecutedJob($job);
  292. } else {
  293. $new_states[$job->getId()] = ['state' => 'failure', 'last-run' => $now, 'error' => $job->getOutput()];
  294. $this->pushFailedJob($job);
  295. }
  296. }
  297. $saved_states = $this->getJobStates();
  298. $saved_states->save(array_merge($saved_states->content(), $new_states));
  299. }
  300. /**
  301. * Try to determine who's running the process
  302. *
  303. * @return false|string
  304. */
  305. public function whoami()
  306. {
  307. $process = new Process(['whoami']);
  308. $process->run();
  309. if ($process->isSuccessful()) {
  310. return trim($process->getOutput());
  311. }
  312. return $process->getErrorOutput();
  313. }
  314. /**
  315. * Queue a job for execution in the correct queue.
  316. *
  317. * @param Job $job
  318. * @return void
  319. */
  320. private function queueJob(Job $job)
  321. {
  322. $this->jobs[] = $job;
  323. // Store jobs
  324. }
  325. /**
  326. * Add an entry to the scheduler verbose output array.
  327. *
  328. * @param string $string
  329. * @return void
  330. */
  331. private function addSchedulerVerboseOutput($string)
  332. {
  333. $now = '[' . (new DateTime('now'))->format('c') . '] ';
  334. $this->output_schedule[] = $now . $string;
  335. // Print to stdoutput in light gray
  336. // echo "\033[37m{$string}\033[0m\n";
  337. }
  338. /**
  339. * Push a succesfully executed job.
  340. *
  341. * @param Job $job
  342. * @return Job
  343. */
  344. private function pushExecutedJob(Job $job)
  345. {
  346. $this->executed_jobs[] = $job;
  347. $command = $job->getCommand();
  348. $args = $job->getArguments();
  349. // If callable, log the string Closure
  350. if (is_callable($command)) {
  351. $command = is_string($command) ? $command : 'Closure';
  352. }
  353. $this->addSchedulerVerboseOutput("<green>Success</green>: <white>{$command} {$args}</white>");
  354. return $job;
  355. }
  356. /**
  357. * Push a failed job.
  358. *
  359. * @param Job $job
  360. * @return Job
  361. */
  362. private function pushFailedJob(Job $job)
  363. {
  364. $this->failed_jobs[] = $job;
  365. $command = $job->getCommand();
  366. // If callable, log the string Closure
  367. if (is_callable($command)) {
  368. $command = is_string($command) ? $command : 'Closure';
  369. }
  370. $output = trim($job->getOutput());
  371. $this->addSchedulerVerboseOutput("<red>Error</red>: <white>{$command}</white> → <normal>{$output}</normal>");
  372. return $job;
  373. }
  374. }