webconsole 6 rokov pred
rodič
commit
9e892206b9

+ 6 - 0
.gitignore

@@ -0,0 +1,6 @@
+.idea
+vendor
+logs
+cron.out
+composer.phar
+composer.lock

+ 74 - 0
CHANGELOG.md

@@ -0,0 +1,74 @@
+## 3.3.1
+
+* Added support of swiftmailer version 6
+
+## 3.3.0
+
+* Allow user to provide a callable in the `schedule` key that returns boolean (#77)
+
+## 3.2.2
+
+* Fix bug on Windows where jobby failed if php is not in path (#73)
+* Add getConfig() and getJobs() helper methods (#72)
+
+## 3.2.1
+
+* Fix default output (to null device) on Windows (#65)
+
+## 3.2.0
+
+* Cast cron expression to string before passing to CronExpression (which allows
+  us to use, e.g., https://github.com/garethellis36/crontab-schedule-generator) (#63)
+* Remove job schedule check from jobby jobs (keepin' it DRY!) (#62)
+
+## 3.1.0
+
+* Determine if job should run in main process, not jobby jobs (#45)
+* Get tests passing on Windows (#61)
+
+## 3.0.2
+
+* Fix bug where if an error happens during the command's execution, it will be silent (#53)
+
+## 3.0.1
+
+* Support Symfony 3 components (#49)
+* Support `phar`-based jobs (#48)
+* Update how closure jobs are serialized (#46)
+* `BackgroundJob` class can now be overridden with `jobClass` config option (#44)
+* Project updates (#43)
+  * PSR-4 autoloading
+  * PSR-2 Code styling, short array syntax, and single quotes
+  * Composer
+    * Updated dependencies to allow minor releases
+    * Removed composer.lock (It's not needed for libraries and general practice
+      is to ignore it.)
+  * Travis
+    * Updated to builtin composer and caching vendor dirs (should be faster now)
+    * Added PHP 7.0 and HHVM. (looks like the bug with 7.0 is fixed in the dev
+      branch of superclosure, once they tag a release, we can make not allowed
+      to fail)
+  * Simplified PHPUnit config
+* Support for spaces in log file (#39)
+* Adds support for running background processes on the same version of PHP
+  that's jobby is currently running (prevents errors in cases where there is
+  more than one installed version of php or is running jobby with a different
+  version of the php default version installed) (#37)
+
+## 2.2.0
+
+* Support PDO-based jobby jobs (#34)
+
+## 2.1.0
+
+* PHP 5.4 is required.
+* Updated external libraries, in special [SuperClosure](https://github.com/jeremeamia/super_closure)
+from [1.0.1](https://github.com/jeremeamia/super_closure/releases/tag/1.0.1) to 
+[2.1.0](https://github.com/jeremeamia/super_closure/releases/tag/2.1.0). SuperClosure is used within jobby for executing
+[Closures](http://php.net/manual/de/class.closure.php) as cron-tasks. As SuperClosure itself has
+backward incompatible changes from 1.x to 2.x 
+(see [PHP SuperClosure v2.0-alpha1](https://github.com/jeremeamia/super_closure/releases/tag/2.0-alpha1)), 
+jobby inherits this breaking changes. 
+See [UPGRADE-2.1](https://github.com/hellogerard/jobby/blob/master/UPGRADE-2.1.md) for upgrade-hints.
+See [Pull request #31](https://github.com/hellogerard/jobby/pull/31) for details.
+

+ 20 - 0
LICENSE

@@ -0,0 +1,20 @@
+The MIT License
+
+Copyright (c) 2012 Gerard Sychay - http://blog.straylightrun.net - hellogerard@gmail.com
+ 
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+ 
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+ 
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ 219 - 0
README.md

@@ -0,0 +1,219 @@
+# Jobby, a PHP cron job manager #
+[![Total Downloads](https://img.shields.io/packagist/dt/hellogerard/jobby.svg)](https://packagist.org/packages/hellogerard/jobby)
+[![Latest Version](https://img.shields.io/packagist/v/hellogerard/jobby.svg)](https://packagist.org/packages/hellogerard/jobby)
+[![Build Status](https://img.shields.io/travis/jobbyphp/jobby.svg)](https://travis-ci.org/jobbyphp/jobby)
+[![MIT License](https://img.shields.io/packagist/l/hellogerard/jobby.svg)](https://github.com/jobbyphp/jobby/blob/master/LICENSE)
+
+Install the master jobby cron job, and it will manage all your offline tasks. Add jobs without modifying crontab.
+Jobby can handle logging, locking, error emails and more.
+
+**NEW REPO:** We have moved `jobby` to a Github org. Please update your remotes to `https://github.com/jobbyphp/jobby.git`.
+
+## Features ##
+
+- Maintain one master crontab job.
+- Jobs run via PHP, so you can run them under any programmatic conditions.
+- Use ordinary crontab schedule syntax (powered by the excellent [`cron-expression`](<https://github.com/mtdowling/cron-expression>)).
+- Run only one copy of a job at a given time.
+- Send email whenever a job exits with an error status. 
+- Run job as another user, if crontab user has `sudo` privileges.
+- Run only on certain hostnames (handy in webfarms).
+- Theoretical Windows support (but not ever tested)
+
+## Getting Started ##
+
+### Installation ###
+
+The recommended way to install Jobby is through [Composer](http://getcomposer.org):
+```
+$ composer require hellogerard/jobby
+```
+
+Then add the following line to your (or whomever's) crontab:
+```
+* * * * * cd /path/to/project && php jobby.php 1>> /dev/null 2>&1
+```
+
+After Jobby installs, you can copy an example file to the project root.
+```
+$ cp vendor/hellogerard/jobby/resources/jobby.php .
+```
+
+### Running a job ###
+
+```php
+<?php 
+
+// Ensure you have included composer's autoloader  
+require_once __DIR__ . '/vendor/autoload.php';
+
+// Create a new instance of Jobby
+$jobby = new Jobby\Jobby();
+
+// Every job has a name
+$jobby->add('CommandExample', [
+
+    // Run a shell command
+    'command'  => 'ls',
+
+    // Ordinary crontab schedule format is supported.
+    // This schedule runs every hour.
+    'schedule' => '0 * * * *',
+
+]);
+
+$jobby->run();
+```
+
+## Examples ##
+
+### Logging ###
+
+```php
+<?php
+
+/* ... */
+
+$jobby->add('LoggingExample', [
+    
+    'command'  => 'ls',
+    'schedule' => '0 * * * *',
+    
+    // Stdout and stderr is sent to the specified file
+    'output'   => 'logs/command.log',
+
+]);
+
+/* ... */
+```
+
+### Disabling a command ###
+
+```php
+<?php
+
+/* ... */
+
+$jobby->add('DisabledExample', [
+    
+    'command'  => 'ls',
+    'schedule' => '0 * * * *',
+    
+    // You can turn off a job by setting 'enabled' to false
+    'enabled'  => false,
+
+]);
+
+/* ... */
+```
+
+### Running closures ###
+
+```php
+<?php
+
+/* ... */
+
+$jobby->add('ClosureCommandExample', [
+    
+     // Use the 'closure' key
+     // instead of 'command'
+    'closure'  => function() {
+        echo "I'm a function!\n";
+        return true;
+    },
+    
+    'schedule' => '0 * * * *',
+
+]);
+
+/* ... */
+```
+
+### Using a DateTime ###
+
+```php
+<?php
+
+/* ... */
+
+$jobby->add('DateTimeExample', [
+    
+    'command'  => 'ls',
+    
+    // Use a DateTime string in
+    // the format Y-m-d H:i:s
+    'schedule' => '2017-05-03 17:15:00',
+
+]);
+
+/* ... */
+```
+
+### Using a Custom Scheduler ###
+
+```php
+<?php
+
+/* ... */
+
+$jobby->add('Example', [
+    
+    'command'  => 'ls',
+    
+    // Use any callable that returns
+    // a boolean stating whether
+    // to run the job or not
+    'schedule' => function() {
+        // Run on even minutes
+        return date('i') % 2 === 0;
+    },
+
+]);
+
+/* ... */
+```
+
+## Supported Options ##
+
+Each job requires these:
+
+Key       | Type    | Description
+:-------- | :------ | :---------------------------------------------------------------------------------------------------------
+schedule  | string  | Crontab schedule format (`man -s 5 crontab`) or DateTime format (`Y-m-d H:i:s`) or callable (`function(): Bool { /* ... */ }`)
+command   | string  | The shell command to run (exclusive-or with `closure`)
+closure   | Closure | The anonymous PHP function to run (exclusive-or with `command`)
+
+
+The options listed below can be applied to an individual job or globally through the `Jobby` constructor. 
+Global options will be used as default values, and individual jobs can override them.
+
+Option         | Type      | Default                             | Description
+:------------- | :-------- | :---------------------------------- | :-------------------------------------------------------- 
+runAs          | string    | null                                | Run as this user, if crontab user has `sudo` privileges
+debug          | boolean   | false                               | Send `jobby` internal messages to 'debug.log'
+_**Filtering**_|           |                                     | _**Options to determine whether the job should run or not**_ 
+environment    | string    | null or `getenv('APPLICATION_ENV')` | Development environment for this job
+runOnHost      | string    | `gethostname()`                     | Run jobs only on this hostname
+maxRuntime     | integer   | null                                | Maximum execution time for this job (in seconds)
+enabled        | boolean   | true                                | Run this job at scheduled times
+haltDir        | string    | null                                | A job will not run if this directory contains a file bearing the job's name 
+_**Logging**_  |           |                                     | _**Options for logging**_
+output         | string    | /dev/null                           | Redirect `stdout` and `stderr` to this file
+dateFormat     | string    | Y-m-d H:i:s                         | Format for dates on `jobby` log messages
+_**Mailing**_  |           |                                     | _**Options for emailing errors**_
+recipients     | string    | null                                | Comma-separated string of email addresses
+mailer         | string    | sendmail                            | Email method: _sendmail_ or _smtp_ or _mail_
+smtpHost       | string    | null                                | SMTP host, if `mailer` is smtp
+smtpPort       | integer   | 25                                  | SMTP port, if `mailer` is smtp
+smtpUsername   | string    | null                                | SMTP user, if `mailer` is smtp
+smtpPassword   | string    | null                                | SMTP password, if `mailer` is smtp
+smtpSecurity   | string    | null                                | SMTP security option: _ssl_ or _tls_, if `mailer` is smtp
+smtpSender     | string    | jobby@&lt;hostname&gt;              | The sender and from addresses used in SMTP notices
+smtpSenderName | string    | Jobby                               | The name used in the from field for SMTP messages
+
+## Credits ##
+
+Developed before, but since inspired by [whenever](<https://github.com/javan/whenever>).
+
+[Support this project](https://cash.me/$hellogerard)

+ 31 - 0
UPGRADE-2.1.md

@@ -0,0 +1,31 @@
+# UPGRADE FROM 2.0 to 2.1
+
+As [SuperClosure](https://github.com/jeremeamia/super_closure) was updated from 1.x to 2.x 
+(see [ChangeLog](https://github.com/hellogerard/jobby/blob/master/CHANGELOG)), some of your
+[Closures](http://php.net/manual/de/class.closure.php) might not be useable within jobby any more.
+
+The change is the way, SuperClosure handles scoped Closures now.
+
+    class SomeClass
+    {
+        function someFunction()
+        {
+            // $fn is a "scoped" Closure. The scope is "SomeClass".
+            $fn = function (...) {...};
+        }
+    }
+    
+In SuperClosure 2.x, scoped Closures are bound to the class the Closure is defined in.
+
+In the example shown above, *$fn* has the scope "SomeClass". When *$fn* is unserialized, **the scope has
+to be available**. In this example, "SomeClass" has to be autoloadable (or on the include_path, 
+required/included before unserialization, ...), otherwise unserialization fails.
+In most cases, your scope should be available when unserializing, so there is no problem. 
+
+If your scope is not available, you have the chance to declare your Closure *static*, to have your 
+Closure unserializable:
+
+    $fn = static function (...) {...};
+    
+If your scope is not available when unserializing, and your Closure depends on the scope, you will
+ have to refactor your code to fit the named requirements.

+ 20 - 0
bin/run-job

@@ -0,0 +1,20 @@
+#!/usr/bin/env php
+<?php
+
+if (file_exists(__DIR__ . '/../vendor/autoload.php')) {
+    require_once __DIR__ . '/../vendor/autoload.php';
+} else {
+    require_once __DIR__ . '/../../../autoload.php';
+}
+
+parse_str($argv[2], $config);
+
+$cls = $config['jobClass'];
+
+if (!is_a($cls, 'Jobby\BackgroundJob', true)) {
+    throw new Jobby\Exception('"jobClass" needs to be an instanceof Jobby\BackgroundJob');
+}
+
+/** @var \Jobby\BackgroundJob $job */
+$job = new $cls($argv[1], $config);
+$job->run();

+ 38 - 0
composer.json

@@ -0,0 +1,38 @@
+{
+  "name": "jrtk/jobby",
+  "homepage": "https://github.com/jobbyphp/jobby",
+  "license": "MIT",
+  "description": "Manage all your cron jobs without modifying crontab. Handles locking, logging, error emails, and more.",
+  "authors": [
+    {
+      "name": "Gerard Sychay",
+      "email": "hellogerard@gmail.com",
+      "homepage": "https://github.com/hellogerard"
+    },
+    {
+      "name": "Michael Contento",
+      "homepage": "https://github.com/michaelcontento"
+    }
+  ],
+  "require": {
+    "php": ">=5.4",
+    "mtdowling/cron-expression": "^1.0",
+    "swiftmailer/swiftmailer": "^5.4|^6.0",
+    "jeremeamia/superclosure": "^2.2",
+    "symfony/process": "^2.7|^3.0"
+  },
+  "require-dev": {
+    "phpunit/phpunit": "^4.6",
+    "symfony/filesystem": "^2.7|^3.0"
+  },
+  "autoload": {
+    "psr-4": {
+      "Jobby\\": "src"
+    }
+  },
+  "autoload-dev": {
+    "psr-4": {
+      "Jobby\\Tests\\": "tests"
+    }
+  }
+}

+ 23 - 0
phpunit.xml

@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<phpunit backupGlobals="false"
+         backupStaticAttributes="false"
+         colors="true"
+         convertErrorsToExceptions="true"
+         convertNoticesToExceptions="true"
+         convertWarningsToExceptions="true"
+         processIsolation="false"
+         stopOnFailure="false"
+         syntaxCheck="false"
+         bootstrap="vendor/autoload.php">
+
+    <testsuite name="Jobby">
+        <directory>tests</directory>
+    </testsuite>
+
+    <filter>
+        <whitelist>
+            <directory>src</directory>
+        </whitelist>
+    </filter>
+</phpunit>

+ 104 - 0
resources/jobby-pdo.php

@@ -0,0 +1,104 @@
+<?php
+
+//
+// This script demonstrates how to use jobby with a PDO-backend, which is used to
+// save the jobby-cronjob/jobbies configuration.
+//
+// Adapt this file to your needs, copy it to your project-root,
+// and add this line to your crontab file:
+//
+// * * * * * cd /path/to/project && php jobby-pdo.php 1>> /dev/null 2>&1
+//
+
+require_once __DIR__ . '/../vendor/autoload.php';
+
+// The table, which shall contain the cronjob-configuration(s).
+$dbhJobbiesTableName = 'jobbies';
+
+/*
+ * For demo-purposes, an in-memory SQLite database is used.
+ *
+ * !!! REPLACE WITH YOUR OWN DATASOURCE!!!
+ */
+$dbh = new PDO('sqlite::memory:');
+$dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+
+/*
+ * Setup a test-fixture, having two jobs, first one is a system-cmd (date), second one is a Closure
+ * (which is saved to pdo-database).
+ */
+
+$dbh->exec("
+CREATE TABLE IF NOT EXISTS `$dbhJobbiesTableName`
+(`name` VARCHAR(255) NOT NULL ,
+ `command` TEXT NOT NULL ,
+ `schedule` VARCHAR(255) NOT NULL ,
+ `mailer` VARCHAR(255) NULL DEFAULT 'sendmail' ,
+ `maxRuntime` INT UNSIGNED NULL ,
+ `smtpHost` VARCHAR(255) NULL ,
+ `smtpPort` SMALLINT UNSIGNED NULL ,
+ `smtpUsername` VARCHAR(255) NULL ,
+ `smtpPassword` VARCHAR(255) NULL ,
+ `smtpSender` VARCHAR(255) NULL DEFAULT 'jobby@localhost' ,
+ `smtpSenderName` VARCHAR(255) NULL DEFAULT 'Jobby' ,
+ `smtpSecurity` VARCHAR(20) NULL ,
+ `runAs` VARCHAR(255) NULL ,
+ `environment` TEXT NULL ,
+ `runOnHost` VARCHAR(255) NULL ,
+ `output` VARCHAR(255) NULL ,
+ `dateFormat` VARCHAR(100) NULL DEFAULT 'Y-m-d H:i:s' ,
+ `enabled` BOOLEAN NULL DEFAULT TRUE ,
+ `haltDir` VARCHAR(255) NULL , `debug` BOOLEAN NULL DEFAULT FALSE ,
+ PRIMARY KEY (`name`)
+)
+");
+
+$insertCronJobConfiguration = $dbh->prepare("
+INSERT INTO `$dbhJobbiesTableName`
+ (`name`,`command`,`schedule`,`output`)
+ VALUES
+ (:name,:command,:schedule,:output)
+");
+// First demo-job - print "date" to logs/command-pdo.log.
+$insertCronJobConfiguration->execute(
+    ['CommandExample', 'date', '* * * * *', 'logs/command-pdo.log']
+);
+// Second demo-job - a Closure which does some php::echo(). The Closure is saved to PDO-backend, too.
+$secondJobFn = function() {
+    echo "I'm a function (" . date('Y-m-d H:i:s') . ')!' . PHP_EOL;
+    return true;
+};
+$serializer = new SuperClosure\Serializer();
+
+$secondJobFnSerialized = $serializer->serialize($secondJobFn);
+$insertCronJobConfiguration->execute(
+    ['ClosureExample', $secondJobFnSerialized, '* * * * *', 'logs/closure-pdo.log']
+);
+
+/*
+ * Examples are now set up, and saved to PDO-backend.
+ *
+ * Now, fetch all jobbies from PDO-backend and run them.
+ */
+
+$jobbiesStmt = $dbh->query("SELECT * FROM `$dbhJobbiesTableName`");
+$jobbies = $jobbiesStmt->fetchAll(PDO::FETCH_ASSOC);
+
+$jobby = new \Jobby\Jobby();
+
+foreach ($jobbies as $job) {
+    // Filter out each value, which is not set (for example, "maxRuntime" is not defined in the job).
+    $job = array_filter($job);
+
+    try {
+        $job['closure'] = $serializer->unserialize($job['command']);
+        unset($job['command']);
+    } catch (SuperClosure\Exception\ClosureUnserializationException $e) {
+    }
+
+    $jobName = $job['name'];
+    unset($job['name']);
+    $jobby->add($jobName, $job);
+}
+
+$jobby->run();

+ 30 - 0
resources/jobby.php

@@ -0,0 +1,30 @@
+<?php
+
+//
+// Add this line to your crontab file:
+//
+// * * * * * cd /path/to/project && php jobby.php 1>> /dev/null 2>&1
+//
+
+require_once __DIR__ . '/../vendor/autoload.php';
+
+$jobby = new \Jobby\Jobby();
+
+$jobby->add('CommandExample', array(
+    'command' => 'ls',
+    'schedule' => '* * * * *',
+    'output' => 'logs/command.log',
+    'enabled' => true,
+));
+
+$jobby->add('ClosureExample', array(
+    'command' => function() {
+        echo "I'm a function!\n";
+        return true;
+    },
+    'schedule' => '* * * * *',
+    'output' => 'logs/closure.log',
+    'enabled' => true,
+));
+
+$jobby->run();

+ 295 - 0
src/BackgroundJob.php

@@ -0,0 +1,295 @@
+<?php
+
+namespace Jobby;
+
+use Cron\CronExpression;
+
+class BackgroundJob
+{
+    use SerializerTrait;
+
+    /**
+     * @var Helper
+     */
+    protected $helper;
+
+    /**
+     * @var string
+     */
+    protected $job;
+
+    /**
+     * @var string
+     */
+    protected $tmpDir;
+
+    /**
+     * @var array
+     */
+    protected $config;
+
+    /**
+     * @param string $job
+     * @param array  $config
+     * @param Helper $helper
+     */
+    public function __construct($job, array $config, Helper $helper = null)
+    {
+        $this->job = $job;
+        $this->config = $config + [
+            'recipients'     => null,
+            'mailer'         => null,
+            'maxRuntime'     => null,
+            'smtpHost'       => null,
+            'smtpPort'       => null,
+            'smtpUsername'   => null,
+            'smtpPassword'   => null,
+            'smtpSender'     => null,
+            'smtpSenderName' => null,
+            'smtpSecurity'   => null,
+            'runAs'          => null,
+            'environment'    => null,
+            'runOnHost'      => null,
+            'output'         => null,
+            'dateFormat'     => null,
+            'enabled'        => null,
+            'haltDir'        => null,
+            'debug'          => null,
+        ];
+
+        $this->helper = $helper ?: new Helper();
+
+        $this->tmpDir = $this->helper->getTempDir();
+    }
+	public function run()
+    {
+
+        if (!$this->shouldRun()) {
+            return;
+        }
+
+        try {
+            if (isset($this->config['closure'])) {
+                $this->runFunction();
+            } else {
+                $this->runFile();
+            }
+        } catch (InfoException $e) {
+            $this->log('INFO: ' . $e->getMessage());
+        } catch (Exception $e) {
+            $this->log('ERROR: ' . $e->getMessage());
+            //$this->mail($e->getMessage());
+        }
+
+    }
+	
+    public function run_old()
+    {
+        $lockFile = $this->getLockFile();
+
+        try {
+            $this->checkMaxRuntime($lockFile);
+        } catch (Exception $e) {
+            $this->log('ERROR: ' . $e->getMessage());
+            $this->mail($e->getMessage());
+
+            return;
+        }
+
+        if (!$this->shouldRun()) {
+            return;
+        }
+
+        $lockAcquired = false;
+        try {
+            $this->helper->acquireLock($lockFile);
+            $lockAcquired = true;
+
+            if (isset($this->config['closure'])) {
+                $this->runFunction();
+            } else {
+                $this->runFile();
+            }
+        } catch (InfoException $e) {
+            $this->log('INFO: ' . $e->getMessage());
+        } catch (Exception $e) {
+            $this->log('ERROR: ' . $e->getMessage());
+            $this->mail($e->getMessage());
+        }
+
+        if ($lockAcquired) {
+            $this->helper->releaseLock($lockFile);
+
+            // remove log file if empty
+            $logfile = $this->getLogfile();
+            if (is_file($logfile) && filesize($logfile) <= 0) {
+                unlink($logfile);
+            }
+        }
+    }
+
+    /**
+     * @return array
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * @param string $lockFile
+     *
+     * @throws Exception
+     */
+    protected function checkMaxRuntime($lockFile)
+    {
+        $maxRuntime = $this->config['maxRuntime'];
+        if ($maxRuntime === null) {
+            return;
+        }
+
+        if ($this->helper->getPlatform() === Helper::WINDOWS) {
+            throw new Exception('"maxRuntime" is not supported on Windows');
+        }
+
+        $runtime = $this->helper->getLockLifetime($lockFile);
+        if ($runtime < $maxRuntime) {
+            return;
+        }
+
+        throw new Exception("MaxRuntime of $maxRuntime secs exceeded! Current runtime: $runtime secs");
+    }
+
+    /**
+     * @param string $message
+     */
+    protected function mail($message)
+    {
+        if (empty($this->config['recipients'])) {
+            return;
+        }
+
+        $this->helper->sendMail(
+            $this->job,
+            $this->config,
+            $message
+        );
+    }
+
+    /**
+     * @return string
+     */
+    protected function getLogfile()
+    {
+        if ($this->config['output'] === null) {
+            return false;
+        }
+
+        $logfile = $this->config['output'];
+
+        $logs = dirname($logfile);
+        if (!file_exists($logs)) {
+            mkdir($logs, 0755, true);
+        }
+
+        return $logfile;
+    }
+
+    /**
+     * @return string
+     */
+    protected function getLockFile()
+    {
+        $tmp = $this->tmpDir;
+        $job = $this->helper->escape($this->job);
+
+        if (!empty($this->config['environment'])) {
+            $env = $this->helper->escape($this->config['environment']);
+
+            return "$tmp/$env-$job.lck";
+        } else {
+            return "$tmp/$job.lck";
+        }
+    }
+
+    /**
+     * @return bool
+     */
+    protected function shouldRun()
+    {
+        if (!$this->config['enabled']) {
+            return false;
+        }
+
+        if (($haltDir = $this->config['haltDir']) !== null) {
+            if (file_exists($haltDir . DIRECTORY_SEPARATOR . $this->job)) {
+                return false;
+            }
+        }
+
+        $host = $this->helper->getHost();
+        if (strcasecmp($this->config['runOnHost'], $host) != 0) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * @param string $message
+     */
+    protected function log($message)
+    {
+        $now = date($this->config['dateFormat'], $_SERVER['REQUEST_TIME']);
+
+        if ($logfile = $this->getLogfile()) {
+            file_put_contents($logfile, "[$now] $message\n", FILE_APPEND);
+        }
+    }
+
+    protected function runFunction()
+    {
+        $command = $this->getSerializer()->unserialize($this->config['closure']);
+
+        ob_start();
+        try {
+            $retval = $command();
+        } catch (\Throwable $e) {
+            echo "Error! " . $e->getMessage() . "\n";
+        }
+        $content = ob_get_contents();
+        if ($logfile = $this->getLogfile()) {
+            file_put_contents($this->getLogfile(), $content, FILE_APPEND);
+        }
+        ob_end_clean();
+
+        if ($retval !== true) {
+            throw new Exception("Closure did not return true! Returned:\n" . print_r($retval, true));
+        }
+    }
+
+    protected function runFile()
+    {
+        // If job should run as another user, we must be on *nix and
+        // must have sudo privileges.
+        $isUnix = ($this->helper->getPlatform() === Helper::UNIX);
+        $useSudo = '';
+
+        if ($isUnix) {
+            $runAs = $this->config['runAs'];
+            $isRoot = (posix_getuid() === 0);
+            if (!empty($runAs) && $isRoot) {
+                $useSudo = "sudo -u $runAs";
+            }
+        }
+
+        // Start execution. Run in foreground (will block).
+        $command = $this->config['command'];
+        $logfile = $this->getLogfile() ?: $this->helper->getSystemNullDevice();
+        exec("$useSudo $command 1>> \"$logfile\" 2>&1", $dummy, $retval);
+
+        if ($retval !== 0) {
+            throw new Exception("Job exited with status '$retval'.");
+        }
+    }
+}

+ 7 - 0
src/Exception.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Jobby;
+
+class Exception extends \Exception
+{
+}

+ 252 - 0
src/Helper.php

@@ -0,0 +1,252 @@
+<?php
+namespace Jobby;
+
+class Helper
+{
+    /**
+     * @var int
+     */
+    const UNIX = 0;
+
+    /**
+     * @var int
+     */
+    const WINDOWS = 1;
+
+    /**
+     * @var resource[]
+     */
+    private $lockHandles = [];
+
+    /**
+     * @var \Swift_Mailer
+     */
+    private $mailer;
+
+    /**
+     * @param \Swift_Mailer $mailer
+     */
+    public function __construct(\Swift_Mailer $mailer = null)
+    {
+        $this->mailer = $mailer;
+    }
+
+    /**
+     * @param string $job
+     * @param array  $config
+     * @param string $message
+     *
+     * @return \Swift_Message
+     */
+    public function sendMail($job, array $config, $message)
+    {
+        $host = $this->getHost();
+        $body = <<<EOF
+$message
+
+You can find its output in {$config['output']} on $host.
+
+Best,
+jobby@$host
+EOF;
+        $mail = new \Swift_Message();
+        $mail->setTo(explode(',', $config['recipients']));
+        $mail->setSubject("[$host] '{$job}' needs some attention!");
+        $mail->setBody($body);
+        $mail->setFrom([$config['smtpSender'] => $config['smtpSenderName']]);
+        $mail->setSender($config['smtpSender']);
+
+        $mailer = $this->getCurrentMailer($config);
+        $mailer->send($mail);
+
+        return $mail;
+    }
+
+    /**
+     * @param array $config
+     *
+     * @return \Swift_Mailer
+     */
+    private function getCurrentMailer(array $config)
+    {
+        if ($this->mailer !== null) {
+            return $this->mailer;
+        }
+
+        $swiftVersion = (int) explode('.', \Swift::VERSION)[0];
+
+        if ($config['mailer'] === 'smtp') {
+            $transport = \Swift_SmtpTransport::newInstance(
+                $config['smtpHost'],
+                $config['smtpPort'],
+                $config['smtpSecurity']
+            );
+            $transport->setUsername($config['smtpUsername']);
+            $transport->setPassword($config['smtpPassword']);
+        } elseif ($swiftVersion < 6 && $config['mailer'] === 'mail') {
+            $transport = \Swift_MailTransport::newInstance();
+        } else {
+            $transport = \Swift_SendmailTransport::newInstance();
+        }
+
+        return \Swift_Mailer::newInstance($transport);
+    }
+
+    /**
+     * @param string $lockFile
+     *
+     * @throws Exception
+     * @throws InfoException
+     */
+    public function acquireLock($lockFile)
+    {
+        if (array_key_exists($lockFile, $this->lockHandles)) {
+            throw new Exception("Lock already acquired (Lockfile: $lockFile).");
+        }
+
+        if (!file_exists($lockFile) && !touch($lockFile)) {
+            throw new Exception("Unable to create file (File: $lockFile).");
+        }
+
+        $fh = fopen($lockFile, 'rb+');
+        if ($fh === false) {
+            throw new Exception("Unable to open file (File: $lockFile).");
+        }
+
+        $attempts = 5;
+        while ($attempts > 0) {
+            if (flock($fh, LOCK_EX | LOCK_NB)) {
+                $this->lockHandles[$lockFile] = $fh;
+                ftruncate($fh, 0);
+                fwrite($fh, getmypid());
+
+                return;
+            }
+            usleep(250);
+            --$attempts;
+        }
+
+        throw new InfoException("Job is still locked (Lockfile: $lockFile)!");
+    }
+
+    /**
+     * @param string $lockFile
+     *
+     * @throws Exception
+     */
+    public function releaseLock($lockFile)
+    {
+        if (!array_key_exists($lockFile, $this->lockHandles)) {
+            throw new Exception("Lock NOT held - bug? Lockfile: $lockFile");
+        }
+
+        if ($this->lockHandles[$lockFile]) {
+            ftruncate($this->lockHandles[$lockFile], 0);
+            flock($this->lockHandles[$lockFile], LOCK_UN);
+        }
+
+        unset($this->lockHandles[$lockFile]);
+    }
+
+    /**
+     * @param string $lockFile
+     *
+     * @return int
+     */
+    public function getLockLifetime($lockFile)
+    {
+        if (!file_exists($lockFile)) {
+            return 0;
+        }
+
+        $pid = file_get_contents($lockFile);
+        if (empty($pid)) {
+            return 0;
+        }
+
+        if (!posix_kill((int) $pid, 0)) {
+            return 0;
+        }
+
+        $stat = stat($lockFile);
+
+        return (time() - $stat['mtime']);
+    }
+
+    /**
+     * @return string
+     */
+    public function getTempDir()
+    {
+        // @codeCoverageIgnoreStart
+        if (function_exists('sys_get_temp_dir')) {
+            $tmp = sys_get_temp_dir();
+        } elseif (!empty($_SERVER['TMP'])) {
+            $tmp = $_SERVER['TMP'];
+        } elseif (!empty($_SERVER['TEMP'])) {
+            $tmp = $_SERVER['TEMP'];
+        } elseif (!empty($_SERVER['TMPDIR'])) {
+            $tmp = $_SERVER['TMPDIR'];
+        } else {
+            $tmp = getcwd();
+        }
+        // @codeCoverageIgnoreEnd
+
+        return $tmp;
+    }
+
+    /**
+     * @return string
+     */
+    public function getHost()
+    {
+        return php_uname('n');
+    }
+
+    /**
+     * @return string|null
+     */
+    public function getApplicationEnv()
+    {
+        return isset($_SERVER['APPLICATION_ENV']) ? $_SERVER['APPLICATION_ENV'] : null;
+    }
+
+    /**
+     * @return int
+     */
+    public function getPlatform()
+    {
+        if (strncasecmp(PHP_OS, 'Win', 3) === 0) {
+            // @codeCoverageIgnoreStart
+            return self::WINDOWS;
+            // @codeCoverageIgnoreEnd
+        }
+
+        return self::UNIX;
+    }
+
+    /**
+     * @param string $input
+     *
+     * @return string
+     */
+    public function escape($input)
+    {
+        $input = strtolower($input);
+        $input = preg_replace('/[^a-z0-9_. -]+/', '', $input);
+        $input = trim($input);
+        $input = str_replace(' ', '_', $input);
+        $input = preg_replace('/_{2,}/', '_', $input);
+
+        return $input;
+    }
+
+    public function getSystemNullDevice()
+    {
+        $platform = $this->getPlatform();
+        if ($platform === self::UNIX) {
+            return '/dev/null';
+        }
+        return 'NUL';
+    }
+}

+ 7 - 0
src/InfoException.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Jobby;
+
+class InfoException extends Exception
+{
+}

+ 228 - 0
src/Jobby.php

@@ -0,0 +1,228 @@
+<?php
+
+namespace Jobby;
+
+use Closure;
+use SuperClosure\SerializableClosure;
+use Symfony\Component\Process\PhpExecutableFinder;
+
+class Jobby
+{
+    use SerializerTrait;
+
+    /**
+     * @var array
+     */
+    protected $config = [];
+
+    /**
+     * @var string
+     */
+    protected $script;
+
+    /**
+     * @var array
+     */
+    protected $jobs = [];
+
+    /**
+     * @var Helper
+     */
+    protected $helper;
+
+    /**
+     * @param array $config
+     */
+    public function __construct(array $config = [])
+    {
+        $this->setConfig($this->getDefaultConfig());
+        $this->setConfig($config);
+
+        $this->script = realpath(__DIR__ . '/../bin/run-job');
+    }
+
+    /**
+     * @return Helper
+     */
+    protected function getHelper()
+    {
+        if ($this->helper === null) {
+            $this->helper = new Helper();
+        }
+
+        return $this->helper;
+    }
+
+    /**
+     * @return array
+     */
+    public function getDefaultConfig()
+    {
+        return [
+            'jobClass'       => 'Jobby\BackgroundJob',
+            'recipients'     => null,
+            'mailer'         => 'sendmail',
+            'maxRuntime'     => null,
+            'smtpHost'       => null,
+            'smtpPort'       => 25,
+            'smtpUsername'   => null,
+            'smtpPassword'   => null,
+            'smtpSender'     => 'jobby@' . $this->getHelper()->getHost(),
+            'smtpSenderName' => 'jobby',
+            'smtpSecurity'   => null,
+            'runAs'          => null,
+            'environment'    => $this->getHelper()->getApplicationEnv(),
+            'runOnHost'      => $this->getHelper()->getHost(),
+            'output'         => null,
+            'dateFormat'     => 'Y-m-d H:i:s',
+            'enabled'        => true,
+            'haltDir'        => null,
+            'debug'          => false,
+        ];
+    }
+
+    /**
+     * @param array
+     */
+    public function setConfig(array $config)
+    {
+        $this->config = array_merge($this->config, $config);
+    }
+
+    /**
+     * @return array
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * @return array
+     */
+    public function getJobs()
+    {
+        return $this->jobs;
+    }
+
+    /**
+     * Add a job.
+     *
+     * @param string $job
+     * @param array  $config
+     *
+     * @throws Exception
+     */
+    public function add($job, array $config)
+    {
+        if (empty($config['schedule'])) {
+            throw new Exception("'schedule' is required for '$job' job");
+        }
+
+        if (!(isset($config['command']) xor isset($config['closure']))) {
+            throw new Exception("Either 'command' or 'closure' is required for '$job' job");
+        }
+
+        if (isset($config['command']) &&
+            (
+                $config['command'] instanceof Closure ||
+                $config['command'] instanceof SerializableClosure
+            )
+        ) {
+            $config['closure'] = $config['command'];
+            unset($config['command']);
+
+            if ($config['closure'] instanceof SerializableClosure) {
+                $config['closure'] = $config['closure']->getClosure();
+            }
+        }
+
+        $config = array_merge($this->config, $config);
+        $this->jobs[$job] = $config;
+    }
+
+    /**
+     * Run all jobs.
+     */
+    public function run()
+    {
+        $isUnix = ($this->helper->getPlatform() === Helper::UNIX);
+
+        if ($isUnix && !extension_loaded('posix')) {
+            throw new Exception('posix extension is required');
+        }
+
+        $scheduleChecker = new ScheduleChecker();
+        foreach ($this->jobs as $job => $config) {
+            if (!$scheduleChecker->isDue($config['schedule'])) {
+                continue;
+            }
+            if ($isUnix) {
+                $this->runUnix($job, $config);
+            } else {
+                $this->runWindows($job, $config);
+            }
+        }
+    }
+
+    /**
+     * @param string $job
+     * @param array  $config
+     */
+    protected function runUnix($job, array $config)
+    {
+        $command = $this->getExecutableCommand($job, $config);
+        $binary = $this->getPhpBinary();
+
+        $output = $config['debug'] ? 'debug.log' : '/dev/null';
+        exec("$binary $command 1> $output 2>&1 &");
+    }
+
+    // @codeCoverageIgnoreStart
+    /**
+     * @param string $job
+     * @param array  $config
+     */
+    protected function runWindows($job, array $config)
+    {
+        // Run in background (non-blocking). From
+        // http://us3.php.net/manual/en/function.exec.php#43834
+        $binary = $this->getPhpBinary();
+
+        $command = $this->getExecutableCommand($job, $config);
+        pclose(popen("start \"blah\" /B \"$binary\" $command", "r"));
+    }
+    // @codeCoverageIgnoreEnd
+
+    /**
+     * @param string $job
+     * @param array  $config
+     *
+     * @return string
+     */
+    protected function getExecutableCommand($job, array $config)
+    {
+        if (isset($config['closure'])) {
+            $config['closure'] = $this->getSerializer()->serialize($config['closure']);
+        }
+
+        if (strpos(__DIR__, 'phar://') === 0) {
+            //$script = __DIR__ . DIRECTORY_SEPARATOR . 'BackgroundJob.php';
+            //return sprintf(' -r \'define("JOBBY_RUN_JOB",1);include("%s");\' "%s" "%s"', $script, $job, http_build_query($config));
+			$script = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'bin/run-job';
+            return sprintf(' -r \'define("JOBBY_RUN_JOB",1);include("%s");\' "%s" "%s"', $script, $job, http_build_query($config));
+        }
+
+        return sprintf('"%s" "%s" "%s"', $this->script, $job, http_build_query($config));
+    }
+
+    /**
+     * @return false|string
+     */
+    protected function getPhpBinary()
+    {
+        $executableFinder = new PhpExecutableFinder();
+
+        return $executableFinder->find();
+    }
+}

+ 26 - 0
src/ScheduleChecker.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace Jobby;
+
+use Cron\CronExpression;
+
+class ScheduleChecker
+{
+    /**
+     * @param string|callable $schedule
+     * @return bool
+     */
+    public function isDue($schedule)
+    {
+        if (is_callable($schedule)) {
+            return call_user_func($schedule);
+        }
+
+        $dateTime = \DateTime::createFromFormat('Y-m-d H:i:s', $schedule);
+        if ($dateTime !== false) {
+            return $dateTime->format('Y-m-d H:i') == (date('Y-m-d H:i'));
+        }
+
+        return CronExpression::factory((string)$schedule)->isDue();
+    }
+}

+ 25 - 0
src/SerializerTrait.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace Jobby;
+
+use SuperClosure\Serializer;
+
+trait SerializerTrait
+{
+    /**
+     * @var Serializer
+     */
+    protected $serializer;
+
+    /**
+     * @return Serializer
+     */
+    protected function getSerializer()
+    {
+        if ($this->serializer === null) {
+            $this->serializer = new Serializer();
+        }
+
+        return $this->serializer;
+    }
+}

+ 374 - 0
tests/BackgroundJobTest.php

@@ -0,0 +1,374 @@
+<?php
+
+namespace Jobby\Tests;
+
+use Jobby\BackgroundJob;
+use Jobby\Helper;
+use Jobby\SerializerTrait;
+use Symfony\Component\Filesystem\Filesystem;
+
+/**
+ * @coversDefaultClass Jobby\BackgroundJob
+ */
+class BackgroundJobTest extends \PHPUnit_Framework_TestCase
+{
+    use SerializerTrait;
+
+    const JOB_NAME = 'name';
+
+    /**
+     * @var string
+     */
+    private $logFile;
+
+    /**
+     * @var Helper
+     */
+    private $helper;
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function setUp()
+    {
+        $this->logFile = __DIR__ . '/_files/BackgroundJobTest.log';
+        if (file_exists($this->logFile)) {
+            unlink($this->logFile);
+        }
+
+        $this->helper = new Helper();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function tearDown()
+    {
+        if (file_exists($this->logFile)) {
+            unlink($this->logFile);
+        }
+    }
+
+    public function runProvider()
+    {
+        $echo = function () {
+            echo 'test';
+
+            return true;
+        };
+        $uid = function () {
+            echo getmyuid();
+
+            return true;
+        };
+        $job = ['closure' => $echo];
+
+        return [
+            'diabled, not run'       => [$job + ['enabled' => false], ''],
+            'normal job, run'         => [$job, 'test'],
+            'wrong host, not run'    => [$job + ['runOnHost' => 'something that does not match'], ''],
+            'current user, run,'     => [['closure' => $uid], getmyuid()],
+        ];
+    }
+
+    /**
+     * @covers ::getConfig
+     */
+    public function testGetConfig()
+    {
+        $job = new BackgroundJob('test job',[]);
+        $this->assertInternalType('array',$job->getConfig());
+    }
+
+    /**
+     * @dataProvider runProvider
+     *
+     * @covers ::run
+     *
+     * @param array  $config
+     * @param string $expectedOutput
+     */
+    public function testRun($config, $expectedOutput)
+    {
+        $this->runJob($config);
+
+        $this->assertEquals($expectedOutput, $this->getLogContent());
+    }
+
+    /**
+     * @covers ::runFile
+     */
+    public function testInvalidCommand()
+    {
+        $this->runJob(['command' => 'invalid-command']);
+
+        $this->assertContains('invalid-command', $this->getLogContent());
+
+        if ($this->helper->getPlatform() === Helper::UNIX) {
+            $this->assertContains('not found', $this->getLogContent());
+            $this->assertContains(
+                "ERROR: Job exited with status '127'",
+                $this->getLogContent()
+            );
+        } else {
+            $this->assertContains(
+                'not recognized as an internal or external command',
+                $this->getLogContent()
+            );
+        }
+    }
+
+    /**
+     * @covers ::runFunction
+     */
+    public function testClosureNotReturnTrue()
+    {
+        $this->runJob(
+            [
+                'closure' => function () {
+                    return false;
+                },
+            ]
+        );
+
+        $this->assertContains(
+            'ERROR: Closure did not return true! Returned:',
+            $this->getLogContent()
+        );
+    }
+
+    /**
+     * @covers ::getLogFile
+     */
+    public function testHideStdOutByDefault()
+    {
+        ob_start();
+        $this->runJob(
+            [
+                'closure' => function () {
+                    echo 'foo bar';
+                },
+                'output'  => null,
+            ]
+        );
+        $content = ob_get_contents();
+        ob_end_clean();
+
+        $this->assertEmpty($content);
+    }
+
+    /**
+     * @covers ::getLogFile
+     */
+    public function testShouldCreateLogFolder()
+    {
+        $logfile = dirname($this->logFile) . '/foo/bar.log';
+        $this->runJob(
+            [
+                'closure' => function () {
+                    echo 'foo bar';
+                },
+                'output'  => $logfile,
+            ]
+        );
+
+        $dirExists = file_exists(dirname($logfile));
+        $isDir = is_dir(dirname($logfile));
+
+        unlink($logfile);
+        rmdir(dirname($logfile));
+
+        $this->assertTrue($dirExists);
+        $this->assertTrue($isDir);
+    }
+
+    /**
+     * @covers ::mail
+     */
+    public function testNotSendMailOnMissingRecipients()
+    {
+        $helper = $this->getMock('Jobby\Helper', ['sendMail']);
+        $helper->expects($this->never())
+            ->method('sendMail')
+        ;
+
+        $this->runJob(
+            [
+                'closure'    => function () {
+                    return false;
+                },
+                'recipients' => '',
+            ],
+            $helper
+        );
+    }
+
+    /**
+     * @covers ::mail
+     */
+    public function testMailShouldTriggerHelper()
+    {
+        $helper = $this->getMock('Jobby\Helper', ['sendMail']);
+        $helper->expects($this->once())
+            ->method('sendMail')
+        ;
+
+        $this->runJob(
+            [
+                'closure'    => function () {
+                    return false;
+                },
+                'recipients' => 'test@example.com',
+            ],
+            $helper
+        );
+    }
+
+    /**
+     * @covers ::checkMaxRuntime
+     */
+    public function testCheckMaxRuntime()
+    {
+        if ($this->helper->getPlatform() !== Helper::UNIX) {
+            $this->markTestSkipped("'maxRuntime' is not supported on Windows");
+        }
+
+        $helper = $this->getMock('Jobby\Helper', ['getLockLifetime']);
+        $helper->expects($this->once())
+            ->method('getLockLifetime')
+            ->will($this->returnValue(0))
+        ;
+
+        $this->runJob(
+            [
+                'command'    => 'true',
+                'maxRuntime' => 1,
+            ],
+            $helper
+        );
+
+        $this->assertEmpty($this->getLogContent());
+    }
+
+    /**
+     * @covers ::checkMaxRuntime
+     */
+    public function testCheckMaxRuntimeShouldFailIsExceeded()
+    {
+        if ($this->helper->getPlatform() !== Helper::UNIX) {
+            $this->markTestSkipped("'maxRuntime' is not supported on Windows");
+        }
+
+        $helper = $this->getMock('Jobby\Helper', ['getLockLifetime']);
+        $helper->expects($this->once())
+            ->method('getLockLifetime')
+            ->will($this->returnValue(2))
+        ;
+
+        $this->runJob(
+            [
+                'command'    => 'true',
+                'maxRuntime' => 1,
+            ],
+            $helper
+        );
+
+        $this->assertContains(
+            'MaxRuntime of 1 secs exceeded! Current runtime: 2 secs',
+            $this->getLogContent()
+        );
+    }
+
+    /**
+     * @dataProvider haltDirProvider
+     * @covers       ::shouldRun
+     *
+     * @param bool $createFile
+     * @param bool $jobRuns
+     */
+    public function testHaltDir($createFile, $jobRuns)
+    {
+        $dir = __DIR__ . '/_files';
+        $file = $dir . '/' . static::JOB_NAME;
+
+        $fs = new Filesystem();
+
+        if ($createFile) {
+            $fs->touch($file);
+        }
+
+        $this->runJob(
+            [
+                'haltDir' => $dir,
+                'closure' => function () {
+                    echo 'test';
+
+                    return true;
+                },
+            ]
+        );
+
+        if ($createFile) {
+            $fs->remove($file);
+        }
+
+        $content = $this->getLogContent();
+        $this->assertEquals($jobRuns, is_string($content) && !empty($content));
+    }
+
+    public function haltDirProvider()
+    {
+        return [
+            [true, false],
+            [false, true],
+        ];
+    }
+
+    /**
+     * @param array  $config
+     * @param Helper $helper
+     */
+    private function runJob(array $config, Helper $helper = null)
+    {
+        $config = $this->getJobConfig($config);
+
+        $job = new BackgroundJob(self::JOB_NAME, $config, $helper);
+        $job->run();
+    }
+
+    /**
+     * @param array $config
+     *
+     * @return array
+     */
+    private function getJobConfig(array $config)
+    {
+        $helper = new Helper();
+
+        if (isset($config['closure'])) {
+            $config['closure'] = $this->getSerializer()->serialize($config['closure']);
+        }
+
+        return array_merge(
+            [
+                'enabled'    => 1,
+                'haltDir'    => null,
+                'runOnHost'  => $helper->getHost(),
+                'dateFormat' => 'Y-m-d H:i:s',
+                'schedule'   => '* * * * *',
+                'output'     => $this->logFile,
+                'maxRuntime' => null,
+                'runAs'      => null,
+            ],
+            $config
+        );
+    }
+
+    /**
+     * @return string
+     */
+    private function getLogContent()
+    {
+        return @file_get_contents($this->logFile);
+    }
+}

+ 17 - 0
tests/ExceptionTest.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace Jobby\Tests;
+
+use Jobby\Exception;
+
+/**
+ * @covers Jobby\Exception
+ */
+class ExceptionTest extends \PHPUnit_Framework_TestCase
+{
+    public function testInheritsBaseException()
+    {
+        $e = new Exception();
+        $this->assertTrue($e instanceof \Exception);
+    }
+}

+ 330 - 0
tests/HelperTest.php

@@ -0,0 +1,330 @@
+<?php
+
+namespace Jobby\Tests;
+
+use Jobby\Helper;
+use Jobby\Jobby;
+
+/**
+ * @coversDefaultClass Jobby\Helper
+ */
+class HelperTest extends \PHPUnit_Framework_TestCase
+{
+    /**
+     * @var Helper
+     */
+    private $helper;
+
+    /**
+     * @var string
+     */
+    private $tmpDir;
+
+    /**
+     * @var string
+     */
+    private $lockFile;
+
+    /**
+     * @var string
+     */
+    private $copyOfLockFile;
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function setUp()
+    {
+        $this->helper = new Helper();
+        $this->tmpDir = $this->helper->getTempDir();
+        $this->lockFile = $this->tmpDir . '/test.lock';
+        $this->copyOfLockFile = $this->tmpDir . "/test.lock.copy";
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function tearDown()
+    {
+        unset($_SERVER['APPLICATION_ENV']);
+    }
+
+    /**
+     * @param string $input
+     * @param string $expected
+     *
+     * @dataProvider dataProviderTestEscape
+     */
+    public function testEscape($input, $expected)
+    {
+        $actual = $this->helper->escape($input);
+        $this->assertEquals($expected, $actual);
+    }
+
+    /**
+     * @return array
+     */
+    public function dataProviderTestEscape()
+    {
+        return [
+            ['lower', 'lower'],
+            ['UPPER', 'upper'],
+            ['0123456789', '0123456789'],
+            ['with    spaces', 'with_spaces'],
+            ['invalid!@#$%^&*()chars', 'invalidchars'],
+            ['._-', '._-'],
+        ];
+    }
+
+    /**
+     * @covers ::getPlatform
+     */
+    public function testGetPlatform()
+    {
+        $actual = $this->helper->getPlatform();
+        $this->assertContains($actual, [Helper::UNIX, Helper::WINDOWS]);
+    }
+
+    /**
+     * @covers ::getPlatform
+     */
+    public function testPlatformConstants()
+    {
+        $this->assertNotEquals(Helper::UNIX, Helper::WINDOWS);
+    }
+
+    /**
+     * @covers ::acquireLock
+     * @covers ::releaseLock
+     */
+    public function testAquireAndReleaseLock()
+    {
+        $this->helper->acquireLock($this->lockFile);
+        $this->helper->releaseLock($this->lockFile);
+        $this->helper->acquireLock($this->lockFile);
+        $this->helper->releaseLock($this->lockFile);
+    }
+
+    /**
+     * @covers ::acquireLock
+     * @covers ::releaseLock
+     */
+    public function testLockFileShouldContainCurrentPid()
+    {
+        $this->helper->acquireLock($this->lockFile);
+
+        //on Windows, file locking is mandatory not advisory, so you can't do file_get_contents on a locked file
+        //therefore, we need to make a copy of the lock file in order to read its contents
+        if ($this->helper->getPlatform() === Helper::WINDOWS) {
+            copy($this->lockFile, $this->copyOfLockFile);
+            $lockFile = $this->copyOfLockFile;
+        } else {
+            $lockFile = $this->lockFile;
+        }
+
+        $this->assertEquals(getmypid(), file_get_contents($lockFile));
+
+        $this->helper->releaseLock($this->lockFile);
+        $this->assertEmpty(file_get_contents($this->lockFile));
+    }
+
+    /**
+     * @covers ::getLockLifetime
+     */
+    public function testLockLifetimeShouldBeZeroIfFileDoesNotExists()
+    {
+        unlink($this->lockFile);
+        $this->assertFalse(file_exists($this->lockFile));
+        $this->assertEquals(0, $this->helper->getLockLifetime($this->lockFile));
+    }
+
+    /**
+     * @covers ::getLockLifetime
+     */
+    public function testLockLifetimeShouldBeZeroIfFileIsEmpty()
+    {
+        file_put_contents($this->lockFile, '');
+        $this->assertEquals(0, $this->helper->getLockLifetime($this->lockFile));
+    }
+
+    /**
+     * @covers ::getLockLifetime
+     */
+    public function testLockLifetimeShouldBeZeroIfItContainsAInvalidPid()
+    {
+        if ($this->helper->getPlatform() === Helper::WINDOWS) {
+            $this->markTestSkipped("Test relies on posix_ functions");
+        }
+
+        file_put_contents($this->lockFile, 'invalid-pid');
+        $this->assertEquals(0, $this->helper->getLockLifetime($this->lockFile));
+    }
+
+    /**
+     * @covers ::getLockLifetime
+     */
+    public function testGetLocklifetime()
+    {
+        if ($this->helper->getPlatform() === Helper::WINDOWS) {
+            $this->markTestSkipped("Test relies on posix_ functions");
+        }
+
+        $this->helper->acquireLock($this->lockFile);
+
+        $this->assertEquals(0, $this->helper->getLockLifetime($this->lockFile));
+        sleep(1);
+        $this->assertEquals(1, $this->helper->getLockLifetime($this->lockFile));
+        sleep(1);
+        $this->assertEquals(2, $this->helper->getLockLifetime($this->lockFile));
+
+        $this->helper->releaseLock($this->lockFile);
+    }
+
+    /**
+     * @covers ::releaseLock
+     * @expectedException \Jobby\Exception
+     */
+    public function testReleaseNonExistin()
+    {
+        $this->helper->releaseLock($this->lockFile);
+    }
+
+    /**
+     * @covers ::acquireLock
+     * @expectedException \Jobby\InfoException
+     */
+    public function testExceptionIfAquireFails()
+    {
+        $fh = fopen($this->lockFile, 'r+');
+        $this->assertTrue(is_resource($fh));
+
+        $res = flock($fh, LOCK_EX | LOCK_NB);
+        $this->assertTrue($res);
+
+        $this->helper->acquireLock($this->lockFile);
+    }
+
+    /**
+     * @covers ::acquireLock
+     * @expectedException \Jobby\Exception
+     */
+    public function testAquireLockShouldFailOnSecondTry()
+    {
+        $this->helper->acquireLock($this->lockFile);
+        $this->helper->acquireLock($this->lockFile);
+    }
+
+    /**
+     * @covers ::getTempDir
+     */
+    public function testGetTempDir()
+    {
+        $valid = [sys_get_temp_dir(), getcwd()];
+        foreach (['TMP', 'TEMP', 'TMPDIR'] as $key) {
+            if (!empty($_SERVER[$key])) {
+                $valid[] = $_SERVER[$key];
+            }
+        }
+
+        $actual = $this->helper->getTempDir();
+        $this->assertContains($actual, $valid);
+    }
+
+    /**
+     * @covers ::getApplicationEnv
+     */
+    public function testGetApplicationEnv()
+    {
+        $_SERVER['APPLICATION_ENV'] = 'foo';
+
+        $actual = $this->helper->getApplicationEnv();
+        $this->assertEquals('foo', $actual);
+    }
+
+    /**
+     * @covers ::getApplicationEnv
+     */
+    public function testGetApplicationEnvShouldBeNullIfUndefined()
+    {
+        $actual = $this->helper->getApplicationEnv();
+        $this->assertNull($actual);
+    }
+
+    /**
+     * @covers ::getHost
+     */
+    public function testGetHostname()
+    {
+        $actual = $this->helper->getHost();
+        $this->assertContains($actual, [gethostname(), php_uname('n')]);
+    }
+
+    /**
+     * @covers ::sendMail
+     * @covers ::getCurrentMailer
+     */
+    public function testSendMail()
+    {
+        $mailer = $this->getSwiftMailerMock();
+        $mailer->expects($this->once())
+            ->method('send')
+        ;
+
+        $jobby = new Jobby();
+        $config = $jobby->getDefaultConfig();
+        $config['output'] = 'output message';
+        $config['recipients'] = 'a@a.com,b@b.com';
+
+        $helper = new Helper($mailer);
+        $mail = $helper->sendMail('job', $config, 'message');
+
+        $host = $helper->getHost();
+        $email = "jobby@$host";
+        $this->assertContains('job', $mail->getSubject());
+        $this->assertContains("[$host]", $mail->getSubject());
+        $this->assertEquals(1, count($mail->getFrom()));
+        $this->assertEquals('jobby', current($mail->getFrom()));
+        $this->assertEquals($email, current(array_keys($mail->getFrom())));
+        $this->assertEquals($email, current(array_keys($mail->getSender())));
+        $this->assertContains($config['output'], $mail->getBody());
+        $this->assertContains('message', $mail->getBody());
+    }
+
+    /**
+     * @return \Swift_Mailer
+     */
+    private function getSwiftMailerMock()
+    {
+        $nullTransport = new \Swift_NullTransport();
+
+        return $this->getMock('Swift_Mailer', [], [$nullTransport]);
+    }
+
+    /**
+     * @return void
+     */
+    public function testItReturnsTheCorrectNullSystemDeviceForUnix()
+    {
+        /** @var Helper $helper */
+        $helper = $this->getMock("\\Jobby\\Helper", ["getPlatform"]);
+        $helper->expects($this->once())
+            ->method("getPlatform")
+            ->willReturn(Helper::UNIX);
+
+        $this->assertEquals("/dev/null", $helper->getSystemNullDevice());
+    }
+
+    /**
+     * @return void
+     */
+    public function testItReturnsTheCorrectNullSystemDeviceForWindows()
+    {
+        /** @var Helper $helper */
+        $helper = $this->getMock("\\Jobby\\Helper", ["getPlatform"]);
+        $helper->expects($this->once())
+               ->method("getPlatform")
+               ->willReturn(Helper::WINDOWS);
+
+        $this->assertEquals("NUL", $helper->getSystemNullDevice());
+    }
+}

+ 371 - 0
tests/JobbyTest.php

@@ -0,0 +1,371 @@
+<?php
+
+namespace Jobby\Tests;
+
+use Jobby\Helper;
+use Jobby\Jobby;
+use SuperClosure\SerializableClosure;
+
+/**
+ * @coversDefaultClass Jobby\Jobby
+ */
+class JobbyTest extends \PHPUnit_Framework_TestCase
+{
+    /**
+     * @var string
+     */
+    private $logFile;
+
+    /**
+     * @var Helper
+     */
+    private $helper;
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function setUp()
+    {
+        $this->logFile = __DIR__ . '/_files/JobbyTest.log';
+        if (file_exists($this->logFile)) {
+            unlink($this->logFile);
+        }
+        
+        $this->helper = new Helper();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function tearDown()
+    {
+        if (file_exists($this->logFile)) {
+            unlink($this->logFile);
+        }
+    }
+
+    /**
+     * @covers ::add
+     * @covers ::run
+     */
+    public function testShell()
+    {
+        $jobby = new Jobby();
+        $jobby->add(
+            'HelloWorldShell',
+            [
+                'command'  => 'php ' . __DIR__ . '/_files/helloworld.php',
+                'schedule' => '* * * * *',
+                'output'   => $this->logFile,
+            ]
+        );
+        $jobby->run();
+
+        // Job runs asynchronously, so wait a bit
+        sleep($this->getSleepTime());
+
+        $this->assertEquals('Hello World!', $this->getLogContent());
+    }
+
+    /**
+     * @return void
+     */
+    public function testBackgroundProcessIsNotSpawnedIfJobIsNotDueToBeRun()
+    {
+        $hour = date("H", strtotime("+1 hour"));
+        $jobby = new Jobby();
+        $jobby->add(
+            'HelloWorldShell',
+            [
+                'command'  => 'php ' . __DIR__ . '/_files/helloworld.php',
+                'schedule' => "* {$hour} * * *",
+                'output'   => $this->logFile,
+            ]
+        );
+        $jobby->run();
+
+        // Job runs asynchronously, so wait a bit
+        sleep($this->getSleepTime());
+
+        $this->assertFalse(
+            file_exists($this->logFile),
+            "Failed to assert that log file doesn't exist and that background process did not spawn"
+        );
+    }
+
+    /**
+     * @covers ::add
+     * @covers ::run
+     */
+    public function testSuperClosure()
+    {
+        $fn = static function () {
+            echo 'Another function!';
+
+            return true;
+        };
+
+        $jobby = new Jobby();
+        $jobby->add(
+            'HelloWorldClosure',
+            [
+                'command'  => new SerializableClosure($fn),
+                'schedule' => '* * * * *',
+                'output'   => $this->logFile,
+            ]
+        );
+        $jobby->run();
+
+        // Job runs asynchronously, so wait a bit
+        sleep($this->getSleepTime());
+
+        $this->assertEquals('Another function!', $this->getLogContent());
+    }
+
+    /**
+     * @covers ::add
+     * @covers ::run
+     */
+    public function testClosure()
+    {
+        $jobby = new Jobby();
+        $jobby->add(
+            'HelloWorldClosure',
+            [
+                'command'  => static function () {
+                    echo 'A function!';
+
+                    return true;
+                },
+                'schedule' => '* * * * *',
+                'output'   => $this->logFile,
+            ]
+        );
+        $jobby->run();
+
+        // Job runs asynchronously, so wait a bit
+        sleep($this->getSleepTime());
+
+        $this->assertEquals('A function!', $this->getLogContent());
+    }
+
+    /**
+     * @covers ::add
+     * @covers ::run
+     */
+    public function testShouldRunAllJobsAdded()
+    {
+        $jobby = new Jobby(['output' => $this->logFile]);
+        $jobby->add(
+            'job-1',
+            [
+                'schedule' => '* * * * *',
+                'command'  => static function () {
+                    echo 'job-1';
+
+                    return true;
+                },
+            ]
+        );
+        $jobby->add(
+            'job-2',
+            [
+                'schedule' => '* * * * *',
+                'command'  => static function () {
+                    echo 'job-2';
+
+                    return true;
+                },
+            ]
+        );
+        $jobby->run();
+
+        // Job runs asynchronously, so wait a bit
+        sleep($this->getSleepTime());
+
+        $this->assertContains('job-1', $this->getLogContent());
+        $this->assertContains('job-2', $this->getLogContent());
+    }
+
+    /**
+     * This is the same test as testClosure but (!) we use the default
+     * options to set the output file.
+     */
+    public function testDefaultOptionsShouldBeMerged()
+    {
+        $jobby = new Jobby(['output' => $this->logFile]);
+        $jobby->add(
+            'HelloWorldClosure',
+            [
+                'command'  => static function () {
+                    echo "A function!";
+
+                    return true;
+                },
+                'schedule' => '* * * * *',
+            ]
+        );
+        $jobby->run();
+
+        // Job runs asynchronously, so wait a bit
+        sleep($this->getSleepTime());
+
+        $this->assertEquals('A function!', $this->getLogContent());
+    }
+
+    /**
+     * @covers ::getDefaultConfig
+     */
+    public function testDefaultConfig()
+    {
+        $jobby = new Jobby();
+        $config = $jobby->getDefaultConfig();
+
+        $this->assertNull($config['recipients']);
+        $this->assertEquals('sendmail', $config['mailer']);
+        $this->assertNull($config['runAs']);
+        $this->assertNull($config['output']);
+        $this->assertEquals('Y-m-d H:i:s', $config['dateFormat']);
+        $this->assertTrue($config['enabled']);
+        $this->assertFalse($config['debug']);
+    }
+
+    /**
+     * @covers ::setConfig
+     * @covers ::getConfig
+     */
+    public function testSetConfig()
+    {
+        $jobby = new Jobby();
+        $oldCfg = $jobby->getConfig();
+
+        $jobby->setConfig(['dateFormat' => 'foo bar']);
+        $newCfg = $jobby->getConfig();
+
+        $this->assertEquals(count($oldCfg), count($newCfg));
+        $this->assertEquals('foo bar', $newCfg['dateFormat']);
+    }
+
+    /**
+     * @covers ::getJobs
+     */
+    public function testGetJobs()
+    {
+        $jobby = new Jobby();
+        $this->assertCount(0,$jobby->getJobs());
+        
+        $jobby->add(
+            'test job1',
+            [
+                'command' => 'test',
+                'schedule' => '* * * * *'
+            ]
+        );
+
+        $jobby->add(
+            'test job2',
+            [
+                'command' => 'test',
+                'schedule' => '* * * * *'
+            ]
+        );
+
+        $this->assertCount(2,$jobby->getJobs());
+    }
+
+    /**
+     * @covers ::add
+     * @expectedException \Jobby\Exception
+     */
+    public function testExceptionOnMissingJobOptionCommand()
+    {
+        $jobby = new Jobby();
+
+        $jobby->add(
+            'should fail',
+            [
+                'schedule' => '* * * * *',
+            ]
+        );
+    }
+
+    /**
+     * @covers ::add
+     * @expectedException \Jobby\Exception
+     */
+    public function testExceptionOnMissingJobOptionSchedule()
+    {
+        $jobby = new Jobby();
+
+        $jobby->add(
+            'should fail',
+            [
+                'command' => static function () {
+                },
+            ]
+        );
+    }
+
+    /**
+     * @covers ::run
+     * @covers ::runWindows
+     * @covers ::runUnix
+     */
+    public function testShouldRunJobsAsync()
+    {
+        $jobby = new Jobby();
+        $jobby->add(
+            'HelloWorldClosure',
+            [
+                'command'  => function () {
+                    return true;
+                },
+                'schedule' => '* * * * *',
+            ]
+        );
+
+        $timeStart = microtime();
+        $jobby->run();
+        $duration = microtime() - $timeStart;
+
+        $this->assertLessThan(0.5, $duration);
+    }
+
+    public function testShouldFailIfMaxRuntimeExceeded()
+    {
+        if ($this->helper->getPlatform() === Helper::WINDOWS) {
+            $this->markTestSkipped("'maxRuntime' is not supported on Windows");
+        }
+
+        $jobby = new Jobby();
+        $jobby->add(
+            'slow job',
+            [
+                'command'    => 'sleep 4',
+                'schedule'   => '* * * * *',
+                'maxRuntime' => 1,
+                'output'     => $this->logFile,
+            ]
+        );
+
+        $jobby->run();
+        sleep(2);
+        $jobby->run();
+        sleep(2);
+
+        $this->assertContains('ERROR: MaxRuntime of 1 secs exceeded!', $this->getLogContent());
+    }
+
+    /**
+     * @return string
+     */
+    private function getLogContent()
+    {
+        return file_get_contents($this->logFile);
+    }
+
+    private function getSleepTime()
+    {
+        return $this->helper->getPlatform() === Helper::UNIX ? 1 : 2;
+    }
+}

+ 81 - 0
tests/ScheduleCheckerTest.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace Jobby\Tests;
+
+use Jobby\ScheduleChecker;
+use PHPUnit_Framework_TestCase;
+
+class ScheduleCheckerTest extends PHPUnit_Framework_TestCase
+{
+    /**
+     * @var ScheduleChecker
+     */
+    private $scheduleChecker;
+
+    /**
+     * @return void
+     */
+    protected function setUp()
+    {
+        parent::setUp();
+
+        $this->scheduleChecker = new ScheduleChecker();
+    }
+
+    /**
+     * @return void
+     */
+    public function test_it_can_detect_a_due_job_from_a_datetime_string()
+    {
+        $this->assertTrue($this->scheduleChecker->isDue(date('Y-m-d H:i:s')));
+    }
+
+    /**
+     * @return void
+     */
+    public function test_it_can_detect_a_non_due_job_from_a_datetime_string()
+    {
+        $this->assertFalse($this->scheduleChecker->isDue(date('Y-m-d H:i:s', strtotime('tomorrow'))));
+    }
+
+    /**
+     * @return void
+     */
+    public function test_it_can_detect_a_due_job_from_a_cron_expression()
+    {
+        $this->assertTrue($this->scheduleChecker->isDue("* * * * *"));
+    }
+
+    /**
+     * @return void
+     */
+    public function test_it_can_detect_a_non_due_job_from_a_cron_expression()
+    {
+        $hour = date("H", strtotime('+1 hour'));
+        $this->assertFalse($this->scheduleChecker->isDue("* {$hour} * * *"));
+    }
+
+    /**
+     * @return void
+     */
+    public function test_it_can_use_a_closure_to_detect_a_due_job()
+    {
+        $this->assertTrue(
+            $this->scheduleChecker->isDue(function() {
+                return true;
+            })
+        );
+    }
+
+    /**
+     * @return void
+     */
+    public function test_it_can_use_a_closure_to_detect_a_non_due_job()
+    {
+        $this->assertFalse(
+            $this->scheduleChecker->isDue(function() {
+                return false;
+            })
+        );
+    }
+}

+ 19 - 0
tests/SerializerTraitTest.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace Jobby\Tests;
+
+class SerializerTraitTest extends \PHPUnit_Framework_TestCase
+{
+    public function testGetSerializer()
+    {
+        $mock = $this->getObjectForTrait('Jobby\SerializerTrait');
+        $method = new \ReflectionMethod($mock, 'getSerializer');
+        $method->setAccessible(true);
+
+        $serializer = $method->invoke($mock);
+        $this->assertInstanceOf('\SuperClosure\Serializer', $serializer);
+
+        $serializer2 = $method->invoke($mock);
+        $this->assertSame($serializer, $serializer2);
+    }
+}

+ 3 - 0
tests/_files/helloworld.php

@@ -0,0 +1,3 @@
+<?php
+
+echo "Hello World!";