diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index 6027a53b..232759fb 100644 --- a/src/Command/InstallCommand.php +++ b/src/Command/InstallCommand.php @@ -10,7 +10,6 @@ namespace App\Command; use App\Constants; -use App\Utils\File; use Doctrine\DBAL\Connection; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; @@ -23,36 +22,28 @@ use Symfony\Component\Console\Style\SymfonyStyle; /** * Command used to do the basic installation steps for Kimai. */ -class InstallCommand extends Command +final class InstallCommand extends Command { public const ERROR_PERMISSIONS = 1; public const ERROR_CACHE_CLEAN = 2; public const ERROR_CACHE_WARMUP = 4; public const ERROR_DATABASE = 8; - public const ERROR_SCHEMA = 16; public const ERROR_MIGRATIONS = 32; - public const ERROR_INTERACTIVE = 64; - protected static $defaultName = 'kimai:install'; /** * @var string */ - protected $rootDir; + private $rootDir; /** * @var Connection */ - protected $connection; - /** - * @var File - */ - protected $file; + private $connection; - public function __construct(string $projectDirectory, Connection $connection, File $files) + public function __construct(string $projectDirectory, Connection $connection) { - parent::__construct(self::$defaultName); + parent::__construct(); $this->rootDir = $projectDirectory; $this->connection = $connection; - $this->file = $files; } /** @@ -61,7 +52,7 @@ class InstallCommand extends Command protected function configure() { $this - ->setName(self::$defaultName) + ->setName('kimai:install') ->setDescription('Basic installation for Kimai') ->setHelp('This command will perform the basic installation steps to get Kimai up and running.') ; @@ -76,18 +67,11 @@ class InstallCommand extends Command { $io = new SymfonyStyle($input, $output); - $io->title('Kimai installer - v' . Constants::VERSION); + $io->title('Kimai installation running ...'); - $result = $this->reviewPermissions($io, $input, $output); - if (true !== $result) { - return $result; - } - - // we cannot change the environment here, as it needs to be configured in the .env file before this command is started - // $environment = $io->choice('Which environment should be used ("dev" is only for testing and imports demo data)?', ['dev', 'production'], 'production'); - // $io->note(sprintf('You have chosen the "%s" environment', $environment)); $environment = getenv('APP_ENV'); + // create the database, in case it is not yet existing try { $this->createDatabase($io, $input, $output); } catch (\Exception $ex) { @@ -96,15 +80,7 @@ class InstallCommand extends Command return self::ERROR_DATABASE; } - try { - $this->createSchema($io, $input, $output); - } catch (\Exception $ex) { - $io->error('Failed to create database schema: ' . $ex->getMessage()); - - return self::ERROR_SCHEMA; - } - - // initialize database with proper migration status + // bootstrap database ONLY via doctrine migrations, so all installation will have the correct and same state try { $this->importMigrations($io, $output); } catch (\Exception $ex) { @@ -113,10 +89,11 @@ class InstallCommand extends Command return self::ERROR_MIGRATIONS; } + // flush the cache, just to make sure ... and ignore result $this->rebuildCaches($environment, $io, $input, $output); $io->success( - 'Congratulations! ' . Constants::SOFTWARE . ' (' . Constants::VERSION . ' ' . Constants::STATUS . ') was successful installed!' + sprintf('Congratulations! Successfully installed %s version %s (%s)', Constants::SOFTWARE, Constants::VERSION, Constants::STATUS) ); return 0; @@ -124,14 +101,7 @@ class InstallCommand extends Command protected function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output) { - if ($input->isInteractive()) { - $question = 'Do you want me to rebuild the caches (yes) or skip this step (no)?'; - if (!$this->askConfirmation($input, $output, $question, true)) { - return; - } - } - - $io->text('Rebuilding your cache now, please be patient ...'); + $io->text('Rebuilding your cache, please be patient ...'); $command = $this->getApplication()->find('cache:clear'); try { @@ -146,104 +116,16 @@ class InstallCommand extends Command try { $command->run(new ArrayInput(['--env' => $environment]), $output); } catch (\Exception $ex) { - $io->error('Failed to clear cache: ' . $ex->getMessage()); + $io->error('Failed to warmup cache: ' . $ex->getMessage()); return self::ERROR_CACHE_WARMUP; } - } - protected function checkPermissions(): array - { - $directories = [ - 'var/cache/', - 'var/data/', - 'var/log/', - 'var/plugins/', - 'var/sessions/', - ]; - - $rows = []; - - foreach ($directories as $directory) { - $absDir = rtrim($this->rootDir) . DIRECTORY_SEPARATOR . $directory; - $perms = $this->file->getPermissions($absDir); - $reason = []; - if (!($perms & 0x0100)) { - $reason[] = 'read owner'; - } - if (!($perms & 0x0080)) { - $reason[] = 'write owner'; - } - if (!($perms & 0x0020)) { - $reason[] = 'read group'; - } - if (!($perms & 0x0010)) { - $reason[] = 'write group'; - } - - if (!empty($reason)) { - $rows[] = [$directory, 'missing: ' . implode(',', $reason)]; - } elseif (!is_writable($absDir)) { - $rows[] = [$directory, 'Directory not writable']; - } - } - - return $rows; - } - - protected function reviewPermissions(SymfonyStyle $io, InputInterface $input, OutputInterface $output) - { - if (!$input->isInteractive()) { - return true; - } - - $permissions = $this->checkPermissions(); - - if (empty($permissions)) { - return true; - } - - $question = 'Kimai found file permissions which look incorrect.' . - ' More information is available at https://www.kimai.org/documentation/installation.html.' . - ' If you are sure that all directories can be written by the webserver, you can continue.' . - ' Otherwise it is recommended to abort the installation and check them first.'; - - $io->caution($question); - - $io->table(['Directory', 'Permission'], $permissions); - - if (!$this->askConfirmation($input, $output, 'Continue with the installation (yes) or review permissions first (no)?', false)) { - $io->warning('Aborting installation to review the permissions for above mentioned directories'); - - return self::ERROR_PERMISSIONS; - } - $io->writeln(''); - - return true; + return 0; } protected function importMigrations(SymfonyStyle $io, OutputInterface $output) { - if (!$this->connection->getSchemaManager()->tablesExist(['migration_versions'])) { - $command = $this->getApplication()->find('doctrine:migrations:version'); - $cmdInput = new ArrayInput(['--add' => true, '--all' => true]); - $cmdInput->setInteractive(false); - $command->run($cmdInput, $output); - - return; - } - - // this case should not happen, but you know ... everything is possible - $amount = $this->connection->executeQuery('SELECT count(*) as counter FROM migration_versions')->fetchColumn(0); - if ($amount === 0) { - $command = $this->getApplication()->find('doctrine:migrations:version'); - $cmdInput = new ArrayInput(['--add' => true, '--all' => true]); - $cmdInput->setInteractive(false); - $command->run($cmdInput, $output); - - return; - } - $command = $this->getApplication()->find('doctrine:migrations:migrate'); $cmdInput = new ArrayInput(['--allow-no-migration' => true]); $cmdInput->setInteractive(false); @@ -254,6 +136,12 @@ class InstallCommand extends Command protected function createDatabase(SymfonyStyle $io, InputInterface $input, OutputInterface $output) { + if (!$this->connection->isConnected() && !$this->connection->connect()) { + throw new \Exception( + sprintf('Database connection could not be established: %s', $this->connection->getDatabase()) + ); + } + if ($this->connection->isConnected()) { $io->note(sprintf('Database is existing and connection could be established')); @@ -268,22 +156,6 @@ class InstallCommand extends Command $command->run(new ArrayInput([]), $output); } - protected function createSchema(SymfonyStyle $io, InputInterface $input, OutputInterface $output) - { - if (!$this->connection->isConnected() && !$this->connection->connect()) { - throw new \Exception(sprintf('Cannot create tables in database "%s", connection could not be established', $this->connection->getDatabase())); - } - - if ($this->connection->getSchemaManager()->tablesExist(['kimai2_users', 'kimai2_timesheet'])) { - $io->note('It seems as if you already have the required tables in your database, skipping schema creation'); - - return; - } - - $command = $this->getApplication()->find('doctrine:schema:create'); - $command->run(new ArrayInput([]), $output); - } - /** * @param InputInterface $input * @param OutputInterface $output diff --git a/src/Command/UpdateCommand.php b/src/Command/UpdateCommand.php new file mode 100644 index 00000000..b24175d6 --- /dev/null +++ b/src/Command/UpdateCommand.php @@ -0,0 +1,149 @@ +rootDir = $projectDirectory; + $this->connection = $connection; + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this + ->setName('kimai:update') + ->setDescription('Update your Kimai installation') + ->setHelp('This command will execute all required steps to update your Kimai installation.') + ; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int|null + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output); + + $io->title('Kimai updates running ...'); + + // we cannot change the environment here, as it needs to be configured in the .env file before this command is started + $environment = getenv('APP_ENV'); + + // make sure database is available, Kimai running and installed + try { + if (!$this->connection->isConnected() && !$this->connection->connect()) { + throw new \Exception( + sprintf('Database connection could not be established: %s', $this->connection->getDatabase()) + ); + } + + if (!$this->connection->getSchemaManager()->tablesExist(['kimai2_users', 'kimai2_timesheet'])) { + $io->error('Tables missing. Did you run the installer already?'); + + return self::ERROR_DATABASE; + } + + if (!$this->connection->getSchemaManager()->tablesExist(['migration_versions'])) { + $io->error('Unknown migration status, aborting database update'); + + return self::ERROR_DATABASE; + } + } catch (\Exception $ex) { + $io->error('Failed to validate database: ' . $ex->getMessage()); + + return self::ERROR_DATABASE; + } + + // execute latest doctrine migrations + try { + $command = $this->getApplication()->find('doctrine:migrations:migrate'); + $cmdInput = new ArrayInput(['--allow-no-migration' => true]); + $cmdInput->setInteractive(false); + $command->run($cmdInput, $output); + + $io->writeln(''); + } catch (\Exception $ex) { + $io->error('Failed to set migration status: ' . $ex->getMessage()); + + return self::ERROR_MIGRATIONS; + } + + // flush the cache, in case values from the database are cached + $cacheResult = $this->rebuildCaches($environment, $io, $input, $output); + + $io->success( + sprintf('Congratulations! Successfully updated %s to version %s (%s)', Constants::SOFTWARE, Constants::VERSION, Constants::STATUS) + ); + + if ($cacheResult !== 0) { + $io->warning('Problem resetting cache, please execute cache clean manually'); + } + + return 0; + } + + protected function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output) + { + $io->text('Rebuilding your cache, please be patient ...'); + + $command = $this->getApplication()->find('cache:clear'); + try { + $command->run(new ArrayInput(['--env' => $environment]), $output); + } catch (\Exception $ex) { + $io->error('Failed to clear cache: ' . $ex->getMessage()); + + return self::ERROR_CACHE_CLEAN; + } + + $command = $this->getApplication()->find('cache:warmup'); + try { + $command->run(new ArrayInput(['--env' => $environment]), $output); + } catch (\Exception $ex) { + $io->error('Failed to warmup cache: ' . $ex->getMessage()); + + return self::ERROR_CACHE_WARMUP; + } + + return 0; + } +} diff --git a/src/Utils/File.php b/src/Utils/File.php deleted file mode 100644 index ff10bbd3..00000000 --- a/src/Utils/File.php +++ /dev/null @@ -1,29 +0,0 @@ -getMockBuilder(File::class)->onlyMethods(['getPermissions'])->getMock(); - $fileMock->expects($this->exactly(5))->method('getPermissions')->willReturn($permission); - $kernel = self::bootKernel(); $this->application = new Application($kernel); $container = self::$kernel->getContainer(); $this->application->add(new InstallCommand( $container->getParameter('kernel.project_dir'), - $container->get('doctrine')->getConnection(), - $fileMock + $container->get('doctrine')->getConnection() )); return $this->application->find('kimai:install'); } - public function testMissingPermissionsAborted() - { - $command = $this->getCommand(0210); - $commandTester = new CommandTester($command); - $commandTester->setInputs(['no']); - $commandTester->execute([ - 'command' => $command->getName(), - ]); - - $result = $commandTester->getDisplay(); - - self::assertStringContainsString('var/cache/', $result); - self::assertStringContainsString('var/data/', $result); - self::assertStringContainsString('var/log/', $result); - self::assertStringContainsString('var/plugins/', $result); - self::assertStringContainsString('var/sessions/', $result); - self::assertEquals(5, substr_count($result, 'missing: read owner,read group,write group')); - self::assertStringContainsString('[WARNING] Aborting installation to review the permissions for above mentioned', $result); - self::assertEquals(InstallCommand::ERROR_PERMISSIONS, $commandTester->getStatusCode()); - } - public function testFullRunWithEverythingPreInstalled() { - $command = $this->getCommand(0770); + $command = $this->getCommand(); $commandTester = new CommandTester($command); $commandTester->setInputs(['no']); $commandTester->execute([ @@ -78,19 +52,15 @@ class InstallCommandTest extends KernelTestCase $result = $commandTester->getDisplay(); + self::assertStringContainsString('Kimai installation running', $result); // create database is skipped self::assertStringContainsString('[NOTE] Database is existing and connection could be established', $result); - - // create schema is skipped - self::assertStringContainsString('[NOTE] It seems as if you already have the required tables in your database,', $result); - self::assertStringContainsString('skipping schema creation', $result); - // make sure migrations run always self::assertStringContainsString('Application Migrations', $result); self::assertStringContainsString('No migrations to execute.', $result); self::assertStringContainsString( - sprintf('[OK] Congratulations! Kimai 2 (%s %s) was successful installed!', Constants::VERSION, Constants::STATUS), + sprintf('[OK] Congratulations! Successfully installed Kimai 2 version %s (%s)', Constants::VERSION, Constants::STATUS), $result ); diff --git a/tests/Command/UpdateCommandTest.php b/tests/Command/UpdateCommandTest.php new file mode 100644 index 00000000..06978bc2 --- /dev/null +++ b/tests/Command/UpdateCommandTest.php @@ -0,0 +1,67 @@ +application = new Application($kernel); + $container = self::$kernel->getContainer(); + + $this->application->add(new UpdateCommand( + $container->getParameter('kernel.project_dir'), + $container->get('doctrine')->getConnection() + )); + + return $this->application->find('kimai:update'); + } + + public function testFullRun() + { + $command = $this->getCommand(); + $commandTester = new CommandTester($command); + $commandTester->setInputs(['no']); + $commandTester->execute([ + 'command' => $command->getName(), + ]); + + $result = $commandTester->getDisplay(); + + self::assertStringContainsString('Kimai updates running', $result); + // make sure migrations run always + self::assertStringContainsString('Application Migrations', $result); + self::assertStringContainsString('No migrations to execute.', $result); + + self::assertStringContainsString( + sprintf('[OK] Congratulations! Successfully updated Kimai 2 to version %s (%s)', Constants::VERSION, Constants::STATUS), + $result + ); + + self::assertEquals(0, $commandTester->getStatusCode()); + } +} diff --git a/tests/Utils/FileTest.php b/tests/Utils/FileTest.php deleted file mode 100644 index 805c348d..00000000 --- a/tests/Utils/FileTest.php +++ /dev/null @@ -1,36 +0,0 @@ -expectException(FileNotFoundException::class); - $this->expectExceptionMessage('Unknown file "/kjhgkjhg/jkhgkjhg"'); - - $sut = new File(); - $sut->getPermissions('/kjhgkjhg/jkhgkjhg'); - } - - public function testGetPermissionsOnDisallowedDirectory() - { - $sut = new File(); - $perms = $sut->getPermissions(__FILE__); - $this->assertEquals($perms, fileperms(__FILE__)); - } -}