commit ed98d5b7c82837f45650b080ae6b4dabe1c447cd Author: Matt Button Date: Fri Dec 24 15:52:03 2010 +0000 Initial import diff --git a/README.md b/README.md new file mode 100644 index 0000000..76cf35b --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Minion + +Minion is a module for the Kohana framework which allows you to run various tasks from the cli. + +## Getting Started + +First off, download and enable the module in your bootstrap + +Then you can run minion like so: + + php index.php --uri=minion/{task} + +To view a list of minion tasks, run + + php index.php --uri=minion/help + +To view help for a specific minion task run + + php index.php --uri=minion/help/{task} + +For security reasons Minion will only run from the cli. Attempting to access it over http will cause +a `Request_Exception` to be thrown. + +## Writing your own tasks + +All minion tasks must be located in `classes/minion/task/`. They can be in any module, thus allowing you to +ship custom minion tasks with your own module / product. + +Each task must extend the abstract class `Minion_Task` and implement `Minion_Task::get_config_options()` and `Minion_Task::execute()`. +See `Minion_Task` for more details. + +## Documentation + +Code should be commented well enough not to need documentation, and minion can extract a class' doccomment to use +as documentation on the cli. + +## Testing + +This module is unittested using the [unittest module](http://github.com/kohana/unittest) diff --git a/classes/controller/minion.php b/classes/controller/minion.php new file mode 100644 index 0000000..fa6a46f --- /dev/null +++ b/classes/controller/minion.php @@ -0,0 +1,114 @@ + + **/ +class Controller_Minion extends Controller +{ + /** + * Prevent Minion from being run over http + */ + public function before() + { + if( ! Kohana::$is_cli) + { + throw new Request_Exception("Minion can only be ran from the cli"); + } + + return parent::before(); + } + + /** + * Prints out the help for a specific task + * + */ + public function action_help() + { + $tasks = Minion_Util::compile_task_list(Kohana::list_files('classes/minion/task')); + $task = $this->request->param('task'); + $view = NULL; + + if(empty($task)) + { + $view = new View('minion/help/list'); + + $view->tasks = $tasks; + } + else + { + $class = Minion_Util::convert_task_to_class_name($task); + + if( ! class_exists($class)) + { + echo View::factory('minion/help/error') + ->set('error', 'Task "'.$task.'" does not exist'); + + exit(1); + } + + $inspector = new ReflectionClass($class); + + list($description, $tags) = Minion_Util::parse_doccomment($inspector->getDocComment()); + + $view = View::factory('minion/help/task') + ->set('description', $description) + ->set('tags', (array) $tags) + ->set('task', $task); + } + + echo $view; + } + + /** + * Handles the request to execute a task. + * + * Responsible for parsing the tasks to execute & also any config items that + * should be passed to the tasks + */ + public function action_execute() + { + $tasks = trim($this->request->param('task')); + + if(empty($tasks)) + return $this->action_help(); + + $tasks = explode(',', $tasks); + + $master = new Minion_Master; + + $options = $master->load($tasks)->get_config_options(); + + $config = array(); + + // Allow the user to specify config for each task, namespacing each + // config option with the name of the task that "owns" it + foreach($options as $task_name => $task_options) + { + $namespace = $task_name.Minion_Util::$task_separator; + + // Namespace each config option + foreach($task_options as $i => $task_option) + { + $task_options[$i] = $namespace.$task_option; + } + + // Get any config options the user's passed + $task_config = call_user_func_array(array('CLI', 'options'), $task_options); + + if( ! empty($task_config)) + { + $namespace_length = strlen($namespace); + + // Strip the namespace off all the config options + foreach($task_config as $key => $value) + { + $config[$task_name][substr($key, $namespace_length)] = $value; + } + } + } + + $master->execute($config); + } +} diff --git a/classes/minion/master.php b/classes/minion/master.php new file mode 100644 index 0000000..a54d006 --- /dev/null +++ b/classes/minion/master.php @@ -0,0 +1,93 @@ + + */ +class Minion_Master { + + /** + * Tasks the master will execute + * @var array + */ + protected $_tasks = array(); + + /** + * Get a list of config options that the loaded tasks accept at execution + * + * @return array + */ + public function get_config_options() + { + $config = array(); + + foreach($this->_tasks as $task) + { + $config[(string) $task] = (array) $task->get_config_options(); + } + + return $config; + } + + /** + * Loads a number of tasks into the task master + * + * Passed task can either be an instance of Minion_Task, a task name (e.g. + * db:migrate) or an array of the above + * + * If an invalid task is passed then a Kohana_Exception will be thrown + * + * @chainable + * @throws Kohana_Exception + * @param array|string|Minion_Task The task(s) to load + * @returns Minion_Master Chainable instance + */ + public function load($task) + { + if(is_array($task)) + { + array_map(array($this, 'load'), $task); + + return $this; + } + + if(is_string($task)) + { + $class = Minion_Util::convert_task_to_class_name($task); + + $task = new $class; + } + + if( ! $task instanceof Minion_Task) + { + throw new Kohana_Exception( + "Task ':task' is not a valid minion task", + array(':task' => get_class($task)) + ); + } + + $this->_tasks[(string) $task] = $task; + + return $this; + } + + /** + * Executes the loaded tasks one at a time + * + * @return Minion_Master Chainable instance + */ + public function execute(array $config = array()) + { + if(empty($this->_tasks)) + return $this; + + foreach($this->_tasks as $task) + { + $task->execute(Arr::get($config, (string) $task, array())); + } + + return $this; + } +} diff --git a/classes/minion/migration/base.php b/classes/minion/migration/base.php new file mode 100644 index 0000000..0b1079f --- /dev/null +++ b/classes/minion/migration/base.php @@ -0,0 +1,24 @@ + + */ +abstract class Minion_Migration_Base { + + /** + * Runs any SQL queries necessary to bring the database up a migration version + * + */ + abstract public function up(); + + /** + * Runs any SQL queries necessary to bring the database schema down a version + * + */ + abstract public function down(); +} diff --git a/classes/minion/task.php b/classes/minion/task.php new file mode 100644 index 0000000..c329308 --- /dev/null +++ b/classes/minion/task.php @@ -0,0 +1,39 @@ + + **/ +class Minion_Task_Cache_Purge extends Minion_Task +{ + + /** + * Gets a set of config options this minion task accepts + * + * @return array + */ + public function get_config_options() + { + return array(); + } + + /** + * Clears the cache + */ + public function execute(array $config) + { + + } +} diff --git a/classes/minion/task/db/migrate.php b/classes/minion/task/db/migrate.php new file mode 100644 index 0000000..4838e3e --- /dev/null +++ b/classes/minion/task/db/migrate.php @@ -0,0 +1,51 @@ + + */ +class Minion_Task_Db_Migrate extends Minion_Task +{ + /** + * Get a set of config options that migrations will accept + * + * @return array + */ + public function get_config_options() + { + return array( + 'version', + 'modules', + ); + } + + /** + * Migrates the database to the version specified + * + * @param array Configuration to use + */ + public function execute(array $config) + { + $k_config = Kohana::config('minion/task/migrations'); + + // Default is upgrade to latest + $version = Arr::get($config, 'version', NULL); + + // Do fancy migration stuff here + } +} diff --git a/classes/minion/util.php b/classes/minion/util.php new file mode 100644 index 0000000..34a8789 --- /dev/null +++ b/classes/minion/util.php @@ -0,0 +1,124 @@ + $line) + { + // Remove all leading whitespace + $line = preg_replace('/^\s*\* ?/m', '', $line); + + // Search this line for a tag + if (preg_match('/^@(\S+)(?:\s*(.+))?$/', $line, $matches)) + { + // This is a tag line + unset($comment[$i]); + + $name = $matches[1]; + $text = isset($matches[2]) ? $matches[2] : ''; + + $tags[$name] = $text; + } + else + { + $comment[$i] = (string) $line; + } + } + + $comment = trim(implode("\n", $comment)); + + return array($comment, $tags); + } + + /** + * Compiles a list of available tasks from a directory structure + * + * @param array Directory structure of tasks + * @return array Compiled tasks + */ + public static function compile_task_list(array $files, $prefix = '') + { + $output = array(); + + foreach($files as $file => $path) + { + $file = substr($file, strrpos($file, '/') + 1); + + if(is_array($path) AND count($path)) + { + $task = Minion_Util::compile_task_list($path, $prefix.$file.Minion_Util::$task_separator); + + if($task) + { + $output = array_merge($output, $task); + } + } + else + { + $output[] = strtolower($prefix.substr($file, 0, -strlen(EXT))); + } + } + + return $output; + } + + /** + * Converts a task (e.g. db:migrate to a class name) + * + * @param string Task name + * @return string Class name + */ + public static function convert_task_to_class_name($task) + { + $task = trim($task); + + if(empty($task)) + return ''; + + return 'Minion_Task_'.implode('_', array_map('ucfirst', explode(Minion_Util::$task_separator, $task))); + } + + /** + * Gets the task name of a task class / task object + * + * @param string|Minion_Task The task class / object + * @return string The task name + */ + public static function convert_class_to_task($class) + { + if(is_object($class)) + { + $class = get_class($class); + } + + return strtolower(str_replace('_', Minion_Util::$task_separator, substr($class, 12))); + } +} diff --git a/init.php b/init.php new file mode 100644 index 0000000..c5eb2d9 --- /dev/null +++ b/init.php @@ -0,0 +1,8 @@ +)(/)', array('action' => 'help')) + ->defaults(array( + 'controller' => 'minion', + 'action' => 'execute', + )); diff --git a/tests/minion/master.php b/tests/minion/master.php new file mode 100644 index 0000000..21ddd5b --- /dev/null +++ b/tests/minion/master.php @@ -0,0 +1,26 @@ +getMockForAbstractClass('Minion_Task'); + + $this->assertSame($master, $master->load($task)); + + $this->assertAttributeContains($task, '_tasks', $master); + } +} diff --git a/tests/minion/util.php b/tests/minion/util.php new file mode 100644 index 0000000..16e09f0 --- /dev/null +++ b/tests/minion/util.php @@ -0,0 +1,136 @@ + 'Matt Button ', + ), + ), + " /**\n * This is my comment from something or\n * other\n * \n * @author Matt Button \n */", + ), + ); + } + + /** + * Tests Minion_Util::prase_doccoment + * + * @test + * @dataProvider provider_parse_doccoment + * @covers Minion_Util::parse_doccomment + * @param array Expected output + * @param string Input doccoment + */ + public function test_parse_doccoment($expected, $doccomment) + { + $this->assertSame($expected, Minion_Util::parse_doccomment($doccomment)); + } + + /** + * Provides test data for test_compile_task_list() + * + * @return array Test data + */ + public function provider_compile_task_list() + { + return array( + array( + array( + 'db:migrate', + 'db:status', + ), + array ( + 'classes/minion/task/db' => array ( + 'classes/minion/task/db/migrate.php' => '/var/www/memberful/memberful-core/modules/kohana-minion/classes/minion/task/db/migrate.php', + 'classes/minion/task/db/status.php' => '/var/www/memberful/memberful-core/modules/kohana-minion/classes/minion/task/db/status.php', + ), + ), + ), + ); + } + + /** + * Tests that compile_task_list accurately creates a list of tasks from a directory structure + * + * @test + * @covers Minion_Util::compile_task_list + * @dataProvider provider_compile_task_list + * @param array Expected output + * @param array List of files + * @param string Prefix to use + * @param string Separator to use + */ + public function test_compile_task_list($expected, $files, $prefix = '', $separator = ':') + { + $this->assertSame($expected, Minion_Util::compile_task_list($files, $prefix, $separator)); + } + + /** + * Provides test data for test_convert_task_to_class_name() + * + * @return array + */ + public function provider_convert_task_to_class_name() + { + return array( + array('Minion_Task_Db_Migrate', 'db:migrate'), + array('Minion_Task_Db_Status', 'db:status'), + array('', ''), + ); + } + + /** + * Tests that a task can be converted to a class name + * + * @test + * @covers Minion_Util::convert_task_to_class_name + * @dataProvider provider_convert_task_to_class_name + * @param string Expected class name + * @param string Input task name + */ + public function test_convert_task_to_class_name($expected, $task_name) + { + $this->assertSame($expected, Minion_Util::convert_task_to_class_name($task_name)); + } + + /** + * Provides test data for test_convert_class_to_task() + * + * @return array + */ + public function provider_convert_class_to_task() + { + return array( + array('db:migrate', 'Minion_Task_Db_Migrate'), + ); + } + + /** + * Tests that the task name can be found from a class name / object + * + * @test + * @covers Minion_Util::convert_class_to_task + * @dataProvider provider_convert_class_to_task + * @param string Expected task name + * @param mixed Input class + */ + public function test_convert_class_to_task($expected, $class) + { + $this->assertSame($expected, Minion_Util::convert_class_to_task($class)); + } +} diff --git a/views/minion/help/error.php b/views/minion/help/error.php new file mode 100644 index 0000000..504115d --- /dev/null +++ b/views/minion/help/error.php @@ -0,0 +1,7 @@ + + +Run + + index.php --uri=minion + +for more help diff --git a/views/minion/help/list.php b/views/minion/help/list.php new file mode 100644 index 0000000..e0b3d90 --- /dev/null +++ b/views/minion/help/list.php @@ -0,0 +1,17 @@ +Minion is a cli tool for performing tasks + +Usage + + php index.php --uri=minion/{task} + +Where {task} is one of the following: + + + * + + + +For more information on what a task does and usage details execute + + php index.php --uri=minion/help/{task} + diff --git a/views/minion/help/task.php b/views/minion/help/task.php new file mode 100644 index 0000000..c2cd9f0 --- /dev/null +++ b/views/minion/help/task.php @@ -0,0 +1,17 @@ + +Usage +======= +php index.php --uri=minion/ [--option1=value1] [--option2=value2] + +Details +======= + $tag_content): ?> +: + + + +Description +=========== + + +