Created new Installer module and moved everything from CLI there

This commit is contained in:
Alejandro Celaya
2018-09-29 09:52:32 +02:00
parent d5dc6cea99
commit a65ce649ac
26 changed files with 106 additions and 76 deletions

View File

@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Command;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Shlinkio\Shlink\Installer\Config\ConfigCustomizerManagerInterface;
use Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\PhpExecutableFinder;
use Zend\Config\Writer\WriterInterface;
class InstallCommand extends Command
{
public const GENERATED_CONFIG_PATH = 'config/params/generated_config.php';
/**
* @var SymfonyStyle
*/
private $io;
/**
* @var ProcessHelper
*/
private $processHelper;
/**
* @var WriterInterface
*/
private $configWriter;
/**
* @var Filesystem
*/
private $filesystem;
/**
* @var \Shlinkio\Shlink\Installer\Config\ConfigCustomizerManagerInterface
*/
private $configCustomizers;
/**
* @var bool
*/
private $isUpdate;
/**
* @var PhpExecutableFinder
*/
private $phpFinder;
/**
* @var string|bool
*/
private $phpBinary;
/**
* InstallCommand constructor.
* @param WriterInterface $configWriter
* @param Filesystem $filesystem
* @param ConfigCustomizerManagerInterface $configCustomizers
* @param bool $isUpdate
* @param PhpExecutableFinder|null $phpFinder
* @throws LogicException
*/
public function __construct(
WriterInterface $configWriter,
Filesystem $filesystem,
ConfigCustomizerManagerInterface $configCustomizers,
bool $isUpdate = false,
PhpExecutableFinder $phpFinder = null
) {
parent::__construct();
$this->configWriter = $configWriter;
$this->isUpdate = $isUpdate;
$this->filesystem = $filesystem;
$this->configCustomizers = $configCustomizers;
$this->phpFinder = $phpFinder ?: new PhpExecutableFinder();
}
protected function configure(): void
{
$this
->setName('shlink:install')
->setDescription('Installs or updates Shlink');
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @return void
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
protected function execute(InputInterface $input, OutputInterface $output): void
{
$this->io = new SymfonyStyle($input, $output);
$this->io->writeln([
'<info>Welcome to Shlink!!</info>',
'This tool will guide you through the installation process.',
]);
// Check if a cached config file exists and drop it if so
if ($this->filesystem->exists('data/cache/app_config.php')) {
$this->io->write('Deleting old cached config...');
try {
$this->filesystem->remove('data/cache/app_config.php');
$this->io->writeln(' <info>Success</info>');
} catch (IOException $e) {
$this->io->error(
'Failed! You will have to manually delete the data/cache/app_config.php file to'
. ' get new config applied.'
);
if ($this->io->isVerbose()) {
$this->getApplication()->renderException($e, $output);
}
return;
}
}
// If running update command, ask the user to import previous config
$config = $this->isUpdate ? $this->importConfig() : new CustomizableAppConfig();
// Ask for custom config params
foreach ([
Plugin\DatabaseConfigCustomizer::class,
Plugin\UrlShortenerConfigCustomizer::class,
Plugin\LanguageConfigCustomizer::class,
Plugin\ApplicationConfigCustomizer::class,
] as $pluginName) {
/** @var Plugin\ConfigCustomizerInterface $configCustomizer */
$configCustomizer = $this->configCustomizers->get($pluginName);
$configCustomizer->process($this->io, $config);
}
// Generate config params files
$this->configWriter->toFile(self::GENERATED_CONFIG_PATH, $config->getArrayCopy(), false);
$this->io->writeln(['<info>Custom configuration properly generated!</info>', '']);
// If current command is not update, generate database
if (! $this->isUpdate) {
$this->io->write('Initializing database...');
if (! $this->runPhpCommand(
'vendor/doctrine/orm/bin/doctrine.php orm:schema-tool:create',
'Error generating database.',
$output
)) {
return;
}
}
// Run database migrations
$this->io->write('Updating database...');
if (! $this->runPhpCommand(
'vendor/doctrine/migrations/bin/doctrine-migrations.php migrations:migrate',
'Error updating database.',
$output
)) {
return;
}
// Generate proxies
$this->io->write('Generating proxies...');
if (! $this->runPhpCommand(
'vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies',
'Error generating proxies.',
$output
)) {
return;
}
$this->io->success('Installation complete!');
}
/**
* @return CustomizableAppConfig
* @throws RuntimeException
*/
private function importConfig(): CustomizableAppConfig
{
$config = new CustomizableAppConfig();
// Ask the user if he/she wants to import an older configuration
$importConfig = $this->io->confirm('Do you want to import configuration from previous installation?');
if (! $importConfig) {
return $config;
}
// Ask the user for the older shlink path
$keepAsking = true;
do {
$config->setImportedInstallationPath($this->io->ask(
'Previous shlink installation path from which to import config'
));
$configFile = $config->getImportedInstallationPath() . '/' . self::GENERATED_CONFIG_PATH;
$configExists = $this->filesystem->exists($configFile);
if (! $configExists) {
$keepAsking = $this->io->confirm(
'Provided path does not seem to be a valid shlink root path. Do you want to try another path?'
);
}
} while (! $configExists && $keepAsking);
// If after some retries the user has chosen not to test another path, return
if (! $configExists) {
return $config;
}
// Read the config file
$config->exchangeArray(include $configFile);
return $config;
}
/**
* @param string $command
* @param string $errorMessage
* @param OutputInterface $output
* @return bool
* @throws LogicException
* @throws InvalidArgumentException
*/
private function runPhpCommand($command, $errorMessage, OutputInterface $output): bool
{
if ($this->processHelper === null) {
$this->processHelper = $this->getHelper('process');
}
if ($this->phpBinary === null) {
$this->phpBinary = $this->phpFinder->find(false) ?: 'php';
}
$this->io->write(
' <options=bold>[Running "' . sprintf('%s %s', $this->phpBinary, $command) . '"]</> ',
false,
OutputInterface::VERBOSITY_VERBOSE
);
$process = $this->processHelper->run($output, sprintf('%s %s', $this->phpBinary, $command));
if ($process->isSuccessful()) {
$this->io->writeln(' <info>Success!</info>');
return true;
}
if (! $this->io->isVerbose()) {
$this->io->error($errorMessage . ' Run this command with -vvv to see specific error info.');
}
return false;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config;
use Shlinkio\Shlink\Installer\Config\Plugin\ConfigCustomizerInterface;
use Zend\ServiceManager\AbstractPluginManager;
class ConfigCustomizerManager extends AbstractPluginManager implements ConfigCustomizerManagerInterface
{
protected $instanceOf = ConfigCustomizerInterface::class;
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config;
use Psr\Container\ContainerInterface;
interface ConfigCustomizerManagerInterface extends ContainerInterface
{
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
class ApplicationConfigCustomizer implements ConfigCustomizerInterface
{
use StringUtilsTrait;
/**
* @param SymfonyStyle $io
* @param CustomizableAppConfig $appConfig
* @return void
*/
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig)
{
$io->title('APPLICATION');
if ($appConfig->hasApp() && $io->confirm('Do you want to keep imported application config?')) {
return;
}
$validator = function ($value) {
return $value;
};
$appConfig->setApp([
'SECRET' => $io->ask(
'Define a secret string that will be used to sign API tokens (leave empty to autogenerate one)',
null,
$validator
) ?: $this->generateRandomString(32),
'DISABLE_TRACK_PARAM' => $io->ask(
'Provide a parameter name that you will be able to use to disable tracking on specific request to '
. 'short URLs (leave empty and this feature won\'t be enabled)',
null,
$validator
),
]);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
interface ConfigCustomizerInterface
{
/**
* @param SymfonyStyle $io
* @param CustomizableAppConfig $appConfig
* @return void
*/
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig);
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Installer\Config\Plugin\ConfigCustomizerInterface;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
class DatabaseConfigCustomizer implements ConfigCustomizerInterface
{
private const DATABASE_DRIVERS = [
'MySQL' => 'pdo_mysql',
'PostgreSQL' => 'pdo_pgsql',
'SQLite' => 'pdo_sqlite',
];
/**
* @var Filesystem
*/
private $filesystem;
public function __construct(Filesystem $filesystem)
{
$this->filesystem = $filesystem;
}
/**
* @param SymfonyStyle $io
* @param CustomizableAppConfig $appConfig
* @return void
* @throws IOException
*/
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig)
{
$io->title('DATABASE');
if ($appConfig->hasDatabase() && $io->confirm('Do you want to keep imported database config?')) {
// If the user selected to keep DB config and is configured to use sqlite, copy DB file
if ($appConfig->getDatabase()['DRIVER'] === self::DATABASE_DRIVERS['SQLite']) {
try {
$this->filesystem->copy(
$appConfig->getImportedInstallationPath() . '/' . CustomizableAppConfig::SQLITE_DB_PATH,
CustomizableAppConfig::SQLITE_DB_PATH
);
} catch (IOException $e) {
$io->error('It wasn\'t possible to import the SQLite database');
throw $e;
}
}
return;
}
// Select database type
$params = [];
$databases = \array_keys(self::DATABASE_DRIVERS);
$dbType = $io->choice('Select database type', $databases, $databases[0]);
$params['DRIVER'] = self::DATABASE_DRIVERS[$dbType];
// Ask for connection params if database is not SQLite
if ($params['DRIVER'] !== self::DATABASE_DRIVERS['SQLite']) {
$params['NAME'] = $io->ask('Database name', 'shlink');
$params['USER'] = $io->ask('Database username');
$params['PASSWORD'] = $io->ask('Database password');
$params['HOST'] = $io->ask('Database host', 'localhost');
$params['PORT'] = $io->ask('Database port', $this->getDefaultDbPort($params['DRIVER']));
}
$appConfig->setDatabase($params);
}
private function getDefaultDbPort(string $driver): string
{
return $driver === 'pdo_mysql' ? '3306' : '5432';
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Installer\Config\Plugin\ConfigCustomizerInterface;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
class LanguageConfigCustomizer implements ConfigCustomizerInterface
{
private const SUPPORTED_LANGUAGES = ['en', 'es'];
/**
* @param SymfonyStyle $io
* @param \Shlinkio\Shlink\Installer\Model\CustomizableAppConfig $appConfig
* @return void
*/
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig)
{
$io->title('LANGUAGE');
if ($appConfig->hasLanguage() && $io->confirm('Do you want to keep imported language?')) {
return;
}
$appConfig->setLanguage([
'DEFAULT' => $this->chooseLanguage('Select default language for the application in general', $io),
'CLI' => $this->chooseLanguage('Select default language for CLI executions', $io),
]);
}
private function chooseLanguage(string $message, SymfonyStyle $io): string
{
return $io->choice($message, self::SUPPORTED_LANGUAGES, self::SUPPORTED_LANGUAGES[0]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
use function str_shuffle;
class UrlShortenerConfigCustomizer implements ConfigCustomizerInterface
{
/**
* @param SymfonyStyle $io
* @param CustomizableAppConfig $appConfig
* @return void
*/
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig)
{
$io->title('URL SHORTENER');
if ($appConfig->hasUrlShortener() && $io->confirm('Do you want to keep imported URL shortener config?')) {
return;
}
// Ask for URL shortener params
$appConfig->setUrlShortener([
'SCHEMA' => $io->choice(
'Select schema for generated short URLs',
['http', 'https'],
'http'
),
'HOSTNAME' => $io->ask('Hostname for generated URLs'),
'CHARS' => $io->ask(
'Character set for generated short codes (leave empty to autogenerate one)',
null,
function ($value) {
return $value;
}
) ?: str_shuffle(UrlShortener::DEFAULT_CHARS),
'VALIDATE_URL' => $io->confirm('Do you want to validate long urls by 200 HTTP status code on response'),
]);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer;
use Zend\Config\Factory;
use Zend\Stdlib\Glob;
class ConfigProvider
{
public function __invoke()
{
return Factory::fromFiles(Glob::glob(__DIR__ . '/../config/{,*.}config.php', Glob::GLOB_BRACE));
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Factory;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Shlinkio\Shlink\Installer\Command\InstallCommand;
use Shlinkio\Shlink\Installer\Config\ConfigCustomizerManager;
use Shlinkio\Shlink\Installer\Config\Plugin;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Filesystem\Filesystem;
use Zend\Config\Writer\PhpArray;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
use Zend\ServiceManager\Factory\InvokableFactory;
class InstallApplicationFactory implements FactoryInterface
{
/**
* Create an object
*
* @param ContainerInterface $container
* @param string $requestedName
* @param null|array $options
* @return object
* @throws LogicException
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$isUpdate = $options !== null && isset($options['isUpdate']) ? (bool) $options['isUpdate'] : false;
$app = new Application();
$command = new InstallCommand(
new PhpArray(),
$container->get(Filesystem::class),
new ConfigCustomizerManager($container, ['factories' => [
Plugin\DatabaseConfigCustomizer::class => ConfigAbstractFactory::class,
Plugin\UrlShortenerConfigCustomizer::class => InvokableFactory::class,
Plugin\LanguageConfigCustomizer::class => InvokableFactory::class,
Plugin\ApplicationConfigCustomizer::class => InvokableFactory::class,
]]),
$isUpdate
);
$app->add($command);
$app->setDefaultCommand($command->getName(), true);
return $app;
}
}

View File

@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Model;
use Zend\Stdlib\ArraySerializableInterface;
final class CustomizableAppConfig implements ArraySerializableInterface
{
public const SQLITE_DB_PATH = 'data/database.sqlite';
/**
* @var array
*/
private $database;
/**
* @var array
*/
private $urlShortener;
/**
* @var array
*/
private $language;
/**
* @var array
*/
private $app;
/**
* @var string
*/
private $importedInstallationPath;
/**
* @return array
*/
public function getDatabase()
{
return $this->database;
}
/**
* @param array $database
* @return $this
*/
public function setDatabase(array $database)
{
$this->database = $database;
return $this;
}
/**
* @return bool
*/
public function hasDatabase()
{
return ! empty($this->database);
}
/**
* @return array
*/
public function getUrlShortener()
{
return $this->urlShortener;
}
/**
* @param array $urlShortener
* @return $this
*/
public function setUrlShortener(array $urlShortener)
{
$this->urlShortener = $urlShortener;
return $this;
}
/**
* @return bool
*/
public function hasUrlShortener()
{
return ! empty($this->urlShortener);
}
/**
* @return array
*/
public function getLanguage()
{
return $this->language;
}
/**
* @param array $language
* @return $this
*/
public function setLanguage(array $language)
{
$this->language = $language;
return $this;
}
/**
* @return bool
*/
public function hasLanguage()
{
return ! empty($this->language);
}
/**
* @return array
*/
public function getApp()
{
return $this->app;
}
/**
* @param array $app
* @return $this
*/
public function setApp(array $app)
{
$this->app = $app;
return $this;
}
/**
* @return bool
*/
public function hasApp()
{
return ! empty($this->app);
}
/**
* @return string
*/
public function getImportedInstallationPath()
{
return $this->importedInstallationPath;
}
/**
* @param string $importedInstallationPath
* @return $this|self
*/
public function setImportedInstallationPath($importedInstallationPath)
{
$this->importedInstallationPath = $importedInstallationPath;
return $this;
}
/**
* @return bool
*/
public function hasImportedInstallationPath()
{
return $this->importedInstallationPath !== null;
}
/**
* Exchange internal values from provided array
*
* @param array $array
* @return void
*/
public function exchangeArray(array $array)
{
if (isset($array['app_options'], $array['app_options']['secret_key'])) {
$this->setApp([
'SECRET' => $array['app_options']['secret_key'],
]);
}
if (isset($array['entity_manager'], $array['entity_manager']['connection'])) {
$this->deserializeDatabase($array['entity_manager']['connection']);
}
if (isset($array['translator'], $array['translator']['locale'], $array['cli'], $array['cli']['locale'])) {
$this->setLanguage([
'DEFAULT' => $array['translator']['locale'],
'CLI' => $array['cli']['locale'],
]);
}
if (isset($array['url_shortener'])) {
$urlShortener = $array['url_shortener'];
$this->setUrlShortener([
'SCHEMA' => $urlShortener['domain']['schema'],
'HOSTNAME' => $urlShortener['domain']['hostname'],
'CHARS' => $urlShortener['shortcode_chars'],
'VALIDATE_URL' => $urlShortener['validate_url'] ?? true,
]);
}
}
private function deserializeDatabase(array $conn)
{
if (! isset($conn['driver'])) {
return;
}
$driver = $conn['driver'];
$params = ['DRIVER' => $driver];
if ($driver !== 'pdo_sqlite') {
$params['USER'] = $conn['user'];
$params['PASSWORD'] = $conn['password'];
$params['NAME'] = $conn['dbname'];
$params['HOST'] = $conn['host'];
$params['PORT'] = $conn['port'];
}
$this->setDatabase($params);
}
/**
* Return an array representation of the object
*
* @return array
*/
public function getArrayCopy()
{
$config = [
'app_options' => [
'secret_key' => $this->app['SECRET'],
'disable_track_param' => $this->app['DISABLE_TRACK_PARAM'] ?? null,
],
'entity_manager' => [
'connection' => [
'driver' => $this->database['DRIVER'],
],
],
'translator' => [
'locale' => $this->language['DEFAULT'],
],
'cli' => [
'locale' => $this->language['CLI'],
],
'url_shortener' => [
'domain' => [
'schema' => $this->urlShortener['SCHEMA'],
'hostname' => $this->urlShortener['HOSTNAME'],
],
'shortcode_chars' => $this->urlShortener['CHARS'],
'validate_url' => $this->urlShortener['VALIDATE_URL'],
],
];
// Build dynamic database config based on selected driver
if ($this->database['DRIVER'] === 'pdo_sqlite') {
$config['entity_manager']['connection']['path'] = self::SQLITE_DB_PATH;
} else {
$config['entity_manager']['connection']['user'] = $this->database['USER'];
$config['entity_manager']['connection']['password'] = $this->database['PASSWORD'];
$config['entity_manager']['connection']['dbname'] = $this->database['NAME'];
$config['entity_manager']['connection']['host'] = $this->database['HOST'];
$config['entity_manager']['connection']['port'] = $this->database['PORT'];
if ($this->database['DRIVER'] === 'pdo_mysql') {
$config['entity_manager']['connection']['driverOptions'] = [
\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
];
}
}
return $config;
}
}