diff --git a/.env.example b/.env.example index 74679cfc5..2aca61105 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ -APP_ENV=local -APP_DEBUG=true +APP_ENV=production +APP_DEBUG=false APP_KEY=SomeRandomString APP_URL=http://deploy.app APP_TIMEZONE=Europe/London diff --git a/.gitignore b/.gitignore index 959712a77..c3301d07a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ vendor/ node_modules/ .vagrant/ .env -_ide_helper.php -sftp-config.json +.env.prev .php_cs.cache -phpunit.xml \ No newline at end of file +phpunit.xml +composer.phar \ No newline at end of file diff --git a/Vagrantfile b/Vagrantfile index 07db5f3d4..e6ddea4bd 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -52,8 +52,6 @@ Vagrant.configure("2") do |config| config.vm.provision "shell", inline: "sudo cp /var/www/deployer/crontab.example /etc/cron.d/deployer" config.vm.provision "shell", inline: "sudo cp /var/www/deployer/nginx.conf.example /etc/nginx/sites-available/deployer.conf" config.vm.provision "shell", inline: "sudo ln -fs /etc/nginx/sites-available/deployer.conf /etc/nginx/sites-enabled/deployer.conf" - config.vm.provision "shell", inline: "echo \"\nenv[APP_ENV] = 'local'\" >> /etc/php5/fpm/php-fpm.conf" - config.vm.provision "shell", inline: "echo \"\n#Set Homestead environment variable\nexport APP_ENV=local\" >> /home/vagrant/.profile" config.vm.provision "shell", inline: "sudo service redis-server restart" config.vm.provision "shell", inline: "sudo service beanstalkd restart" config.vm.provision "shell", inline: "sudo service supervisor restart" diff --git a/app/Console/Commands/InstallApp.php b/app/Console/Commands/InstallApp.php new file mode 100644 index 000000000..bd714927c --- /dev/null +++ b/app/Console/Commands/InstallApp.php @@ -0,0 +1,642 @@ +verifyNotInstalled()) { + return; + } + + // TODO: Add options so they can be passed in via the command line? + + // This should not actually be needed as composer install should do it + // Removed for now as this causes problems with APP_KEY because key:generate and migrate + // will see it as empty because .env has already be loaded by this stage + // if (!file_exists(base_path('.env'))) { + // copy(base_path('.env.example'), base_path('.env')); + // } + + $this->line(''); + $this->info('***********************'); + $this->info(' Welcome to Deployer '); + $this->info('***********************'); + $this->line(''); + + if (!$this->checkRequirements()) { + return; + } + + $this->line('Please answer the following questions:'); + $this->line(''); + + $config = [ + 'db' => $this->getDatabaseInformation(), + 'app' => $this->getInstallInformation(), + 'mail' => $this->getEmailInformation(), + ]; + + $this->writeEnvFile($config); + $this->generateKey(); + $this->migrate(($this->getLaravel()->environment() === 'local')); + $this->optimize(); + + $this->line(''); + $this->comment('Success! Deployer is now installed'); + $this->line(''); + $this->comment('Visit ' . $config['app']['url'] . ' and login with the following details to get started'); + $this->line(''); + $this->comment(' Username: admin@example.com'); + $this->comment(' Password: password'); + $this->line(''); + + // TODO: Update admin user instead of using defaults? + } + + /** + * Writes the configuration data to the config file. + * + * @param array $input The config data to write + * @return bool + */ + protected function writeEnvFile(array $input) + { + $this->info('Writing configuration file'); + $this->line(''); + + $path = base_path('.env'); + $config = file_get_contents($path); + + // Move the socket value to the correct key + if (isset($input['app']['socket'])) { + $input['socket']['url'] = $input['app']['socket']; + unset($input['app']['socket']); + } + + foreach ($input as $section => $data) { + foreach ($data as $key => $value) { + $env = strtoupper($section . '_' . $key); + + $config = preg_replace('/' . $env . '=(.*)/', $env . '=' . $value, $config); + } + } + + // Remove keys not needed for sqlite + if ($input['db']['type'] === 'sqlite') { + foreach (['host', 'database', 'username', 'password'] as $key) { + $key = strtoupper($key); + + $config = preg_replace('/DB_' . $key . '=(.*)[\n]/', '', $config); + } + } + + // Remove keys not needed by SMTP + if ($input['mail']['driver'] !== 'smtp') { + foreach (['host', 'port', 'username', 'password'] as $key) { + $key = strtoupper($key); + + $config = preg_replace('/MAIL_' . $key . '=(.*)[\n]/', '', $config); + } + } + + return file_put_contents($path, $config); + } + + /** + * Calls the artisan key:generate to set the APP_KEY. + * + * @return void + */ + private function generateKey() + { + $this->info('Generating application key'); + $this->line(''); + $this->call('key:generate'); + } + + /** + * Calls the artisan migrate to set up the database + * in development mode it also seeds the DB. + * + * @param bool $seed Whether or not to seed the database + * @return void + */ + protected function migrate($seed = false) + { + $this->info('Running database migrations'); + $this->line(''); + $this->call('migrate', ['--force' => true]); + $this->line(''); + + if ($seed) { + $this->info('Seeding database'); + $this->line(''); + $this->call('db:seed', ['--force' => true]); + $this->line(''); + } + } + + /** + * Clears all Laravel caches. + * + * @return void + */ + protected function clearCaches() + { + $this->call('clear-compiled'); + $this->call('cache:clear'); + $this->call('route:clear'); + $this->call('config:clear'); + $this->call('view:clear'); + } + + /** + * Runs the artisan optimize commands. + * + * @return void + */ + protected function optimize() + { + $this->clearCaches(); + + if ($this->getLaravel()->environment() !== 'local') { + $this->call('optimize', ['--force' => true]); + $this->call('config:cache'); + $this->call('route:cache'); + } + } + + /** + * Prompts the user for the database connection details. + * + * @return array + */ + private function getDatabaseInformation() + { + $this->header('Database details'); + + $connectionVerified = false; + + while (!$connectionVerified) { + $database = []; + + // Should we just skip this step if only one driver is available? + $type = $this->choice('Type', $this->getDatabaseDrivers(), 0); + + $database['type'] = $type; + + if ($type !== 'sqlite') { + $host = $this->ask('Host', 'localhost'); + $name = $this->ask('Name', 'deployer'); + $user = $this->ask('Username', 'deployer'); + $pass = $this->secret('Password'); + + $database['host'] = $host; + $database['name'] = $name; + $database['username'] = $user; + $database['password'] = $pass; + } + + $connectionVerified = $this->verifyDatabaseDetails($database); + } + + return $database; + } + + /** + * Prompts the user for the basic setup information. + * + * @return array + */ + private function getInstallInformation() + { + $this->header('Installation details'); + + $regions = $this->getTimezoneRegions(); + $locales = $this->getLocales(); + + $callback = function ($answer) { + $validator = Validator::make(['url' => $answer], [ + 'url' => 'url', + ]); + + if (!$validator->passes()) { + throw new \RuntimeException($validator->errors()->first('url')); + }; + + return preg_replace('#/$#', '', $answer); + }; + + $url = $this->askAndValidate('Application URL ("http://deploy.app" for example)', [], $callback); + $region = $this->choice('Timezone region', array_keys($regions), 0); + + if ($region !== 'UTC') { + $locations = $this->getTimezoneLocations($regions[$region]); + + $region .= '/' . $this->choice('Timezone location', $locations, 0); + } + + $socket = $this->askAndValidate('Socket URL', [], $callback, $url); + + // If there is only 1 locale just use that + if (count($locales) === 1) { + $locale = $locales[0]; + } else { + $locale = $this->choice('Language', $locales, array_search(Config::get('app.fallback_locale'), $locales, true)); + } + + return [ + 'url' => $url, + 'timezone' => $region, + 'socket' => $socket, + 'locale' => $locale, + ]; + } + + /** + * Prompts the user for the details for the email setup. + * + * @return array + */ + private function getEmailInformation() + { + $this->header('Email details'); + + $email = []; + + $driver = $this->choice('Type', ['smtp', 'sendmail', 'mail'], 0); + + if ($driver === 'smtp') { + $host = $this->ask('Host', 'localhost'); + + $port = $this->askAndValidate('Port', [], function ($answer) { + $validator = Validator::make(['port' => $answer], [ + 'port' => 'integer', + ]); + + if (!$validator->passes()) { + throw new \RuntimeException($validator->errors()->first('port')); + }; + + return $answer; + }, 25); + + $user = $this->ask('Username'); + $pass = $this->secret('Password'); + + $email['host'] = $host; + $email['port'] = $port; + $email['username'] = $user; + $email['password'] = $pass; + } + + $from_name = $this->ask('From name', 'Deployer'); + + $from_address = $this->askAndValidate('From address', [], function ($answer) { + $validator = Validator::make(['from_address' => $answer], [ + 'from_address' => 'email', + ]); + + if (!$validator->passes()) { + throw new \RuntimeException($validator->errors()->first('from_address')); + }; + + return $answer; + }, 'deployer@deploy.app'); + + $email['from_name'] = $from_name; + $email['from_address'] = $from_address; + $email['driver'] = $driver; + + // TODO: Attempt to connect? + + return $email; + } + + /** + * Verifies that the database connection details are correct. + * + * @param array $database The connection details + * @return bool + */ + private function verifyDatabaseDetails(array $database) + { + if ($database['type'] === 'sqlite') { + return touch(storage_path() . '/database.sqlite'); + } + + try { + $connection = new PDO( + $database['type'] . ':host=' . $database['host'] . ';dbname=' . $database['name'], + $database['username'], + $database['password'], + [ + PDO::ATTR_PERSISTENT => false, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_TIMEOUT => 2, + ] + ); + + unset($connection); + + return true; + } catch (\Exception $error) { + $this->block([ + 'Deployer could not connect to the database with the details provided. Please try again.', + PHP_EOL, + $error->getMessage(), + ]); + } + + return false; + } + + /** + * Ensures that Deployer has not been installed yet. + * + * @return bool + */ + private function verifyNotInstalled() + { + // TODO: Check for valid DB connection, and migrations have run? + if (getenv('APP_KEY') !== false && getenv('APP_KEY') !== 'SomeRandomString') { + $this->block([ + 'You have already installed Deployer!', + PHP_EOL, + 'If you were trying to update Deployer, please use "php artisan app:update" instead.', + ]); + + return false; + } + + return true; + } + + /** + * Checks the system meets all the requirements needed to run Deployer. + * + * @return bool + */ + private function checkRequirements() + { + $errors = false; + + // Check PHP version: + if (!version_compare(PHP_VERSION, '5.5.9', '>=')) { + $this->error('PHP 5.5.9 or higher is required'); + $errors = true; + } + + // TODO: allow gd or imagemagick + // TODO: See if there are any others, maybe clean this list up? + $required_extensions = ['PDO', 'curl', 'memcached', 'gd', + 'mcrypt', 'json', 'tokenizer', + 'openssl', 'mbstring', + ]; + + foreach ($required_extensions as $extension) { + if (!extension_loaded($extension)) { + $this->error('Extension required: ' . $extension); + $errors = true; + } + } + + if (!count($this->getDatabaseDrivers())) { + $this->error('At least 1 PDO database driver is required. Either sqlite, mysql, pgsql or sqlsrv, check your php.ini file'); + $errors = true; + } + + // Functions needed by symfony process + $required_functions = ['proc_open']; + + foreach ($required_functions as $function) { + if (!function_exists($function)) { + $this->error('Function required: ' . $function . '. Is it disabled in php.ini?'); + $errors = true; + } + } + + // Programs needed in $PATH + $required_commands = ['ssh', 'ssh-keygen', 'git']; + + foreach ($required_commands as $command) { + $process = new Process('which ' . $command); + + $process->setTimeout(null); + $process->run(); + + if (!$process->isSuccessful()) { + $this->error('Program not found in path: ' . $command); + $errors = true; + } + } + + // Horrible work around for now + if (!file_exists(base_path('.env'))) { + copy(base_path('.env.example'), base_path('.env')); + + $this->error('.env was missing, it has now been generated'); + $errors = true; + } + + // Files and directories which need to be writable + $writable = ['.env', 'storage', 'storage/logs', 'storage/app', 'storage/framework', + 'storage/framework/cache', 'storage/framework/sessions', + 'storage/framework/views', 'bootstrap/cache', + ]; + + foreach ($writable as $path) { + if (!is_writeable(base_path($path))) { + $this->error($path . ' is not writeable'); + $errors = true; + } + } + + // FIXE: Check Memcache and redis are running? + + if ($errors) { + $this->line(''); + $this->block('Deployer cannot be installed, as not all requirements are met. Please review the errors above before continuing.'); + $this->line(''); + + return false; + } + + return true; + } + + /** + * Gets an array of available PDO drivers which are supported by Laravel. + * + * @return array + */ + private function getDatabaseDrivers() + { + $available = collect(PDO::getAvailableDrivers()); + + return $available->intersect(['mysql', 'sqlite', 'pgsql', 'sqlsrv']) + ->all(); + } + + /** + * Gets a list of timezone regions. + * + * @return array + */ + private function getTimezoneRegions() + { + return [ + 'UTC' => DateTimeZone::UTC, + 'Africa' => DateTimeZone::AFRICA, + 'America' => DateTimeZone::AMERICA, + 'Antarctica' => DateTimeZone::ANTARCTICA, + 'Asia' => DateTimeZone::ASIA, + 'Atlantic' => DateTimeZone::ATLANTIC, + 'Australia' => DateTimeZone::AUSTRALIA, + 'Europe' => DateTimeZone::EUROPE, + 'Indian' => DateTimeZone::INDIAN, + 'Pacific' => DateTimeZone::PACIFIC, + ]; + } + + /** + * Gets a list of available locations in the supplied region. + * + * @param int $region The region constant + * @return array + * @see DateTimeZone + */ + private function getTimezoneLocations($region) + { + $locations = []; + + foreach (DateTimeZone::listIdentifiers($region) as $timezone) { + $locations[] = substr($timezone, strpos($timezone, '/') + 1); + } + + return $locations; + } + + /** + * Gets a list of the available locales. + * + * @return array + */ + private function getLocales() + { + $locales = []; + + // Get the locales from the files on disk + foreach (glob(base_path('resources/lang/') . '*') as $path) { + if (is_dir($path)) { + $locales[] = basename($path); + } + } + + return $locales; + } + + /** + * Asks a question and validates the response. + * + * @param string $question The question + * @param array $choices Autocomplete options + * @param function $validator The callback function + * @param mixed $default The default value + * @return string + */ + public function askAndValidate($question, array $choices, $validator, $default = null) + { + $question = new Question($question, $default); + + if (count($choices)) { + $question->setAutocompleterValues($choices); + } + + $question->setValidator($validator); + + return $this->output->askQuestion($question); + } + + /** + * A wrapper around symfony's formatter helper to output a block. + * + * @param string|array $messages Messages to output + * @param string $type The type of message to output + * @return void + */ + protected function block($messages, $type = 'error') + { + $output = []; + + if (!is_array($messages)) { + $messages = (array) $messages; + } + + $output[] = ''; + + foreach ($messages as $message) { + $output[] = trim($message); + } + + $output[] = ''; + + $formatter = new FormatterHelper(); + $this->line($formatter->formatBlock($output, $type)); + } + + /** + * Outputs a header block. + * + * @param string $header The text to output + * @return void + */ + protected function header($header) + { + $this->block($header, 'question'); + } +} diff --git a/app/Console/Commands/ResetApp.php b/app/Console/Commands/ResetApp.php new file mode 100644 index 000000000..7adda46bc --- /dev/null +++ b/app/Console/Commands/ResetApp.php @@ -0,0 +1,85 @@ +verifyNotProduction()) { + return; + } + + $this->resetDB(); + $this->migrate(true); + $this->clearCaches(); + $this->restartQueue(); + } + + /** + * Resets the database. + * + * @return void + */ + protected function resetDB() + { + $this->info('Resetting the database'); + $this->line(''); + $this->call('migrate:reset', ['--force' => true]); + $this->line(''); + } + + /** + * Ensures that the command is running locally and in debugging mode. + * + * @return bool + */ + private function verifyNotProduction() + { + if ($this->getLaravel()->environment() !== 'local') { + $this->block([ + 'Deployer is not in development mode!', + PHP_EOL, + 'This command does not run in production as its purpose is to wipe your database', + ]); + + return false; + } + + return true; + } +} diff --git a/app/Console/Commands/UpdateApp.php b/app/Console/Commands/UpdateApp.php new file mode 100644 index 000000000..86943304c --- /dev/null +++ b/app/Console/Commands/UpdateApp.php @@ -0,0 +1,173 @@ +verifyInstalled() || $this->hasRunningDeployments() || $this->composerOutdated()) { + return; + } + + $this->call('down'); + + $this->updateConfiguration(); + $this->migrate(); + $this->optimize(); + $this->restartQueue(); + + $this->call('up'); + } + + /** + * Checks for new configuration values in .env.example and copy them to .env. + * + * @return void + */ + protected function updateConfiguration() + { + $config = []; + + // Read the current config values into an array for the writeEnvFile method + foreach (file(base_path('.env')) as $line) { + $line = trim($line); + + if (empty($line)) { + continue; + } + + $parts = explode('=', $line); + + $env = strtolower($parts[0]); + $value = trim($parts[1]); + + $section = substr($env, 0, strpos($env, '_')); + $key = substr($env, strpos($env, '_') + 1); + + $config[$section][$key] = $value; + } + + // Backup the .env file, just in case it failed because we don't want to lose APP_KEY + copy(base_path('.env'), base_path('.env.prev')); + + // Copy the example file so that new values are copied + copy(base_path('.env.example'), base_path('.env')); + + // Write the file to disk + $this->writeEnvFile($config); + } + + /** + * Restarts the queues. + * + * @return void + */ + protected function restartQueue() + { + $this->info('Restarting the queue'); + $this->line(''); + $this->call('queue:flush'); + $this->call('queue:restart'); + $this->line(''); + } + + /** + * Checks if there are any running or pending deployments. + * + * @return boolean + */ + protected function hasRunningDeployments() + { + $deploys = Deployment::whereIn('status', [Deployment::DEPLOYING, Deployment::PENDING]) + ->count(); + + if ($deploys > 0) { + $this->block([ + 'Deployments in progress', + PHP_EOL, + 'There are still running deployments, please wait for them to finish before updating.', + ]); + + return true; + } + + return false; + } + + /** + * Check if the composer autoload.php has been updated in the last 10 minutes, + * if not we assume composer install has not be run recently + * + * @return boolean + */ + protected function composerOutdated() + { + if (filemtime(base_path('vendor/autoload.php')) + 600 < time()) { + $this->block([ + 'Update not complete!', + PHP_EOL, + 'Please run "composer install" before you continue.', + ]); + + return true; + } + + return false; + } + + /** + * Ensures that Deployer has actually been installed. + * + * @return bool + */ + private function verifyInstalled() + { + if (getenv('APP_KEY') === false || getenv('APP_KEY') === 'SomeRandomString') { + $this->block([ + 'Deployer has not been installed', + PHP_EOL, + 'Please use "php artisan app:install" instead.', + ]); + + return false; + } + + return true; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index f7b4d7a0e..1921dd127 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -37,6 +37,9 @@ class Kernel extends ConsoleKernel \REBELinBLUE\Deployer\Console\Commands\CheckUrl::class, \REBELinBLUE\Deployer\Console\Commands\ClearOrphanAvatars::class, \REBELinBLUE\Deployer\Console\Commands\ClearStalledDeployment::class, + \REBELinBLUE\Deployer\Console\Commands\InstallApp::class, + \REBELinBLUE\Deployer\Console\Commands\UpdateApp::class, + \REBELinBLUE\Deployer\Console\Commands\ResetApp::class, ]; /** diff --git a/composer.json b/composer.json index 411876911..dd2edb8ed 100644 --- a/composer.json +++ b/composer.json @@ -60,16 +60,13 @@ }, "scripts": { "post-install-cmd": [ + "php -r \"file_exists('.env') || copy('.env.example', '.env');\"", "php artisan clear-compiled", "php artisan optimize" ], "post-update-cmd": [ "php artisan clear-compiled", "php artisan optimize" - ], - "post-create-project-cmd": [ - "php -r \"copy('.env.example', '.env');\"", - "php artisan key:generate" ] }, "config": { diff --git a/composer.lock b/composer.lock index 68969b80d..aee10cbda 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "375d010a88ad59422df627193bdcd342", + "hash": "f0837e583a8c2a41c24e1f5ee3987bba", "content-hash": "ba288b07fa221fd8ac40aa711ca6d9b5", "packages": [ { diff --git a/database/migrations/2014_10_12_000000_create_users_table.php b/database/migrations/2014_10_12_000000_create_users_table.php index 41ac25a5b..703db77fd 100644 --- a/database/migrations/2014_10_12_000000_create_users_table.php +++ b/database/migrations/2014_10_12_000000_create_users_table.php @@ -24,9 +24,10 @@ public function up() }); User::create([ - 'name' => 'Admin', - 'email' => 'admin@example.com', - 'password' => bcrypt('password'), + 'name' => 'Admin', + 'email' => 'admin@example.com', + 'password' => bcrypt('password'), + 'remember_token' => str_random(10), ]); } diff --git a/database/seeds/UserTableSeeder.php b/database/seeds/UserTableSeeder.php index 494951b16..0af113304 100644 --- a/database/seeds/UserTableSeeder.php +++ b/database/seeds/UserTableSeeder.php @@ -12,8 +12,8 @@ public function run() $faker = Faker\Factory::create('en_GB'); User::create([ - 'name' => 'Stephen Ball', - 'email' => 'stephen@rebelinblue.com', + 'name' => 'Admin', + 'email' => 'admin@example.com', 'password' => bcrypt('password'), 'remember_token' => str_random(10), ]); diff --git a/phpci.yml b/phpci.yml index 48878e916..4785c9e20 100644 --- a/phpci.yml +++ b/phpci.yml @@ -31,7 +31,7 @@ test: directory: "app/" php_unit: config: - - "phpunit.xml" + - "phpunit.xml.dist" directory: - "tests/" coverage: "/var/www/PHPCI/public/coverage/deployer/"