diff --git a/resources/hooks/pre-commit b/resources/hooks/pre-commit new file mode 100755 index 000000000..40f74750f --- /dev/null +++ b/resources/hooks/pre-commit @@ -0,0 +1,29 @@ +#!/usr/bin/env sh + +if [ ! -f "$APPLICATION_EXECUTABLE" ]; then #check if the cli is installed + exit 0 +fi + +get_git_head() { + if git rev-parse --verify HEAD >/dev/null 2>&1 + then + echo "HEAD" + else + git hash-object -t tree /dev/null + fi +} + +against=$(get_git_head) + +exec < /dev/tty #redirect keyboard input so we can do interactivity in symfony +$APPLICATION_EXECUTABLE commit:validate $against 1>/dev/null + +if [ $? -eq 0 ] +then + echo "CLI says OK, allow commit." + exit 0 +else + echo "Cancelling commit." + exit 1 +fi +exec <&- #restore keyboard input \ No newline at end of file diff --git a/src/Application.php b/src/Application.php index 48be678b5..7d2a300e8 100644 --- a/src/Application.php +++ b/src/Application.php @@ -115,6 +115,7 @@ protected function getCommands() $commands[] = new Command\Certificate\CertificateListCommand(); $commands[] = new Command\Commit\CommitGetCommand(); $commands[] = new Command\Commit\CommitListCommand(); + $commands[] = new Command\Commit\CommitValidateCommand(); $commands[] = new Command\Db\DbSqlCommand(); $commands[] = new Command\Db\DbDumpCommand(); $commands[] = new Command\Db\DbSizeCommand(); diff --git a/src/Command/App/AppConfigGetCommand.php b/src/Command/App/AppConfigGetCommand.php index 13ab54d5a..f3c57604c 100644 --- a/src/Command/App/AppConfigGetCommand.php +++ b/src/Command/App/AppConfigGetCommand.php @@ -4,6 +4,8 @@ use Platformsh\Cli\Command\CommandBase; use Platformsh\Cli\Model\AppConfig; use Platformsh\Cli\Model\Host\LocalHost; +use Platformsh\Cli\Local\LocalApplication; + use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -19,7 +21,8 @@ protected function configure() ->setName('app:config-get') ->setDescription('View the configuration of an app') ->addOption('property', 'P', InputOption::VALUE_REQUIRED, 'The configuration property to view') - ->addOption('refresh', null, InputOption::VALUE_NONE, 'Whether to refresh the cache'); + ->addOption('refresh', null, InputOption::VALUE_NONE, 'Whether to refresh the cache') + ->addOption('local','L', InputOption::VALUE_NONE, 'Use the local configuration'); $this->addProjectOption(); $this->addEnvironmentOption(); $this->addAppOption(); @@ -31,6 +34,7 @@ protected function configure() */ protected function execute(InputInterface $input, OutputInterface $output) { + // Allow override via PLATFORM_APPLICATION. $prefix = $this->config()->get('service.env_prefix'); if (getenv($prefix . 'APPLICATION') && !LocalHost::conflictsWithCommandLineOptions($input, $prefix)) { @@ -40,6 +44,9 @@ protected function execute(InputInterface $input, OutputInterface $output) throw new \RuntimeException('Failed to decode: ' . $prefix . 'APPLICATION'); } $appConfig = new AppConfig($decoded); + } elseif((bool) $input->getOption('local')) { + $local = new LocalApplication($this->getProjectRoot()); + $appConfig = new AppConfig($local->getConfig()); } else { $this->validateInput($input); $this->warnAboutDeprecatedOptions(['identity-file']); diff --git a/src/Command/Commit/CommitValidateCommand.php b/src/Command/Commit/CommitValidateCommand.php new file mode 100644 index 000000000..3a06142cc --- /dev/null +++ b/src/Command/Commit/CommitValidateCommand.php @@ -0,0 +1,394 @@ +setName('commit:validate') + ->setAliases(['pre-commit-validate']) + ->setDescription('This will validate the commit you are about to make (used by the pre-commit hook)') + ->addArgument('revision', InputArgument::OPTIONAL, 'The revision to ckeck against','HEAD') + ; + } + + protected function getHookConfigValue($config) { + return (bool)$this->git->getConfig($config); + } + + protected function checkDiff($strDiff, $regex, &$matches) { + return preg_match_all($regex, $strDiff, $matches); + } + + protected function writeErrorMessage($title, $message) { + $line_of_stars = str_repeat("*",strlen($title)+4); + $this->stdErr->writeLn($line_of_stars); + $this->stdErr->writeLn("* $title *"); + $this->stdErr->writeLn($line_of_stars); + $this->stdErr->writeLn(""); + $this->stdErr->writeLn($message); + } + + protected function printRelevantChanges(array $changes,$title="") { + if(!$changes) return; + if(!$title) $title = "Relevant changes(s)"; + $message = implode(PHP_EOL, $changes); + + $line_of_dashes = str_repeat("-",strlen($title)+4); + $this->stdErr->writeLn($line_of_dashes); + $this->stdErr->writeLn("| $title |"); + $this->stdErr->writeLn($line_of_dashes); + $this->stdErr->writeLn(""); + $this->stdErr->writeLn("$message"); + $this->stdErr->writeLn(""); + $this->stdErr->writeLn($line_of_dashes); + } + + protected function printPermanentlyDisableNotice($hook_config_name){ + $this->stdErr->writeLn( + [ + "If you know what you are doing you can permanently disable this check using:", + "", + " git config hooks.$hook_config_name true", + "", + ] + ); + } + + protected function confirmOrExit($question="Proceed?") { + $questionHelper = $this->getService('question_helper'); + return $questionHelper->confirm($question); + } + + protected function checkContainerNameChange($strDiff) { + $this->stdErr->write("Checking name change of containers..."); + $hookConfigName="psh_allow_container_rename"; + $hookConfigValue=(bool)$this->getHookConfigValue($hookConfigName); + if(!$hookConfigValue && preg_match('/name\: ?\[.+\}/', $strDiff, $matches) ){ + $this->stdErr->writeLn("[FAIL]"); + $this->writeErrorMessage("Attempt to rename a container detected!", + [ + "Changing the name of your application after it has been deployed will destroy all storage volumes and result in the loss of all persistent data. This is typically a Very Bad Thing to do. It could be useful under certain circumstances in the early stages of development but you almost certainly don't want to change it on a live project.", + "For more information see: https://docs.platform.sh/configuration/app/name.html" + ] + ); + $this->printRelevantChanges($matches); + $this->printPermanentlyDisableNotice($hookConfigName); + return false; + } + + $this->stdErr->writeLn("[OK]"); + return true; + } + + protected function checkLargeFiles($arrFilesToCheck) { + + $this->stdErr->write("Checking for large files... "); + $hookConfigName="psh_allow_large_files"; + $hookConfigValue=(bool)$this->getHookConfigValue($hookConfigName); + if(!$hookConfigValue){ + + $max_byte_size = 1;//get all files larger than x MB + $large_files = []; + foreach($arrFilesToCheck as $fileName) { + $size=round(filesize($fileName) / 1024/1024, 2);//always round down + if($size >= $max_byte_size) { + $large_files[] = "$fileName : $size MB"; + } + } + + if(count($large_files)) { + $this->stdErr->writeLn("[FAIL]"); + $this->writeErrorMessage("Large file commit detected!", + [ + "It looks like you are attempting to commit a file larger than ~1 MB.", + "This will slow down builds/clones, and other operations we would rather not slow down.", + "", + "The maximum total size of your git environment is 4GB.", + "Therefore we strongly recommend against commit large files.", + "", + "Please verify that you want to commit the following:" + ] + ); + $this->printRelevantChanges($large_files, " Filename : MB "); + $this->printPermanentlyDisableNotice($hookConfigName); + + return false; + } + } + + $this->stdErr->writeLn("[OK]"); + return true; + } + + protected function checkCommonMistakesInBuildHook() { + $this->stdErr->write("Checking build hook for known faulty commands... "); + $hookConfigName="psh_disable_build_hook_check"; + $hookConfigValue=(bool)$this->getHookConfigValue($hookConfigName); + if(!$hookConfigValue){ + $arrFaultyCommands=[ + 'npm run serve', + './mercure' + ]; + $buildHookContents=$this->getBuildHookContents(); + + foreach($arrFaultyCommands as $faultyCommand) { + if(stripos($buildHookContents,$faultyCommand)!==FALSE) { + $this->stdErr->writeLn("[FAIL]"); + $this->writeErrorMessage("Possible faulty build hook command detected!", + [ + "If a command in the build hook does not finish, the build is unable to finish and it will have to be killed manually.", + "", + "It looks like your build hook contains a command that is known to cause this issue.", + "Please verify that this is indeed correct.", + "", + ] + ); + $this->printRelevantChanges([$faultyCommand], " Command "); + $this->printPermanentlyDisableNotice($hookConfigName); + + return false; + } + } + } + + $this->stdErr->writeLn("[OK]"); + return true; + } + + protected function checkDiskSizeVsPlanSize($project) { + $this->stdErr->write("Checking plan size hook for known faulty commands... "); + $hookConfigName="psh_disable_plan_size_check"; + $hookConfigValue=(bool)$this->getHookConfigValue($hookConfigName); + if(!$hookConfigValue){ + + $planSize = $this->getSubscriptionPlanSize($project); + $summedDiskSize = $this->getSummedDiskSize(); + if($planSize < $summedDiskSize) { + + $this->stdErr->writeLn("[FAIL]"); + $this->writeErrorMessage("Disk size seems higher than what your plan allows!", + [ + "We did a check on your yaml files and it looks like you are asking for more disk than your plan allows.", + "", + "Summed disk size $summedDiskSize MB is greater than the current plan limit $planSize MB", + "", + "Please check the disk: properties in your .yaml files.", + "Alternatively, ask the owner of your project to increase the storage of your plan.", + "", + "For more information see: https://docs.platform.sh/configuration/app/storage.html#disk", + "", + "The push might fail with the same error should you continue." + ] + ); + + $this->printPermanentlyDisableNotice($hookConfigName); + + return false; + + } + } + + $this->stdErr->writeLn("[OK]"); + return true; + } + + protected function checkServiceNameChange($strDiff) { + $this->stdErr->write("Checking name change of service containers..."); + $hookConfigName="psh_allow_service_container_rename"; + $hookConfigValue=(bool)$this->getHookConfigValue($hookConfigName); + + if(!$hookConfigValue && preg_match('/\[\-(.+):\-]\{\+.+:\+}/', $strDiff, $matches) && !in_array($matches[1],['disk','type']) ){ + unset($matches[1]); + $this->stdErr->writeLn("[FAIL]"); + $this->writeErrorMessage("Attempt to rename a service container detected!", + [ + "Changing the name of your application after it has been deployed will destroy all storage volumes and result in the loss of all persistent data. This is typically a Very Bad Thing to do. It could be useful under certain circumstances in the early stages of development but you almost certainly don't want to change it on a live project.", + "For more information see: https://docs.platform.sh/configuration/app/name.html", + "", + "Please check your services.yaml file" + ] + ); + $this->printRelevantChanges($matches); + $this->printPermanentlyDisableNotice($hookConfigName); + return false; + } + + $this->stdErr->writeLn("[OK]"); + return true; + } + + protected function checkServiceTypeChanges($strDiff) { + $this->stdErr->write("Checking service type changes..."); + $hookConfigName="psh_allow_service_type_change"; + $hookConfigValue=(bool)$this->getHookConfigValue($hookConfigName); + + if(!$hookConfigValue && preg_match('/type: ?\[\-.+\-]\{\+.+\+}/', $strDiff, $matches) ){ + $this->stdErr->writeLn("[FAIL]"); + $this->writeErrorMessage("Change of service type detected!", + [ + "Persistent services can not be downgraded (e.g. MySQL v10.3 -> MySQL v10.2). Only non-persistent containers like chrome-headless, redis, memcached can be downgraded.", + "", + "Please verify your changes before proceeding:", + "- Downgrading to an older version will break your service and has the potential to cause dataloss.", + "- Upgrading to a newer version should work flawlessly(*). But please do verify that this is working correctly for your application by branching your production/master environment first.", + "Downgrading again later is not possible!", + "", + "* There are limitations regarding which service supports big version jumps while keeping the data (e.g.: Elasticsearch 1.7 -> 6.5). These are upstream limitations not specific to platform.sh. Check the documentation relevant to your service.", + "", + "For your convenience, here are the links to documentation of the most common services:", + "- MySQL https://docs.platform.sh/configuration/services/mysql.html#supported-versions", + "- PostgreSQL https://docs.platform.sh/configuration/services/postgresql.html#upgrading", + "- MongoDB https://docs.platform.sh/configuration/services/mongodb.html#supported-versions", + ] + ); + $this->printRelevantChanges($matches); + $this->printPermanentlyDisableNotice($hookConfigName); + return false; + } + + $this->stdErr->writeLn("[OK]"); + return true; + } + + protected function getModifiedFiles($revision) { + return explode("\0", $this->git->diff($revision,["--diff-filter=M","-z","--name-only"])); + } + + protected function getAddedFiles($revision) { + return explode("\0", $this->git->diff($revision,["--diff-filter=A","-z","--name-only"])); + } + + protected function hasFileChanged($arrModifiedFiles, $patternToLookFor='/.yaml$/') { + foreach($arrModifiedFiles as $fileName) { + if(preg_match($patternToLookFor, $fileName) == 1) { + return true; + } + } + return false; + } + + protected function hasYamlChanges($arrModifiedFiles) { + return $this->hasFileChanged($arrModifiedFiles,'/\.yaml$/'); + } + + protected function hasServiceYamlChanges($arrModifiedFiles) { + return $this->hasFileChanged($arrModifiedFiles,'/services\.yaml$/'); + } + + protected function getSummedDiskSize() { + $sum=0; + + /** @var \Platformsh\Cli\Local\LocalProject $localProject */ + $localProject = $this->getService('local.project'); + $serviceConfig = $localProject->readProjectConfigFile($this->getProjectRoot(), 'services.yaml'); + foreach($serviceConfig as $service) { + $sum+= isset($service['disk']) ? $service['disk'] : self::MIN_SERVICE_DISK_SIZE; + } + + $appConfig = $this->getNormalizedAppConfig(); + if(isset($appConfig['disk'])) { + $sum+=$appConfig['disk']; + } + return $sum; + } + + protected function getNormalizedAppConfig() { + $local = new LocalApplication($this->getProjectRoot()); + $appConfig = new AppConfig($local->getConfig()); + return $appConfig->getNormalized(); + } + + protected function getBuildHookContents() { + $appConfig = $this->getNormalizedAppConfig(); + if(isset($appConfig['hooks']['build'])) { + return $appConfig['hooks']['build']; + } + return ""; + } + + protected function getSubscriptionPlanSize($project) { + return $this->getSubscriptionInfo($project, 'storage') ?:PHP_INT_MAX; + } + + protected function getSubscriptionInfo($project, $property) { + $id = $project->getSubscriptionId(); + + $subscription = $this->api()->getClient() + ->getSubscription($id); + if ($subscription) { + return $this->api()->getNestedProperty($subscription, $property); + } + } + + + + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $project = $this->getCurrentProject(); + $projectRoot = $this->getProjectRoot(); + if (!$project || !$projectRoot) { + throw new RootNotFoundException(); + } + + /** @var \Platformsh\Cli\Service\Git $git */ + $this->git = $this->getService('git'); + /** @var \Platformsh\Cli\Service\Ssh $ssh */ + $ssh = $this->getService('ssh'); + $this->git->setDefaultRepositoryDir($projectRoot); + $this->git->setSshCommand($ssh->getSshCommand()); + + + $strDiff = $this->git->diff($input->getArgument('revision'),["--diff-filter=M","-z","--word-diff=plain"]); + $arrModifiedFiles = $this->getModifiedFiles($input->getArgument('revision')); + $arrAddedFiles = $this->getAddedFiles($input->getArgument('revision')); + + $questionHelper = $this->getService('question_helper'); + + //check the global diff + if(!$this->checkContainerNameChange($strDiff) && !$questionHelper->confirm('Proceed with commit?')){return self::EXIT_CODE_1;} + if(!$this->checkLargeFiles(array_merge($arrModifiedFiles,$arrAddedFiles)) && !$questionHelper->confirm('Proceed with commit?') ){return self::EXIT_CODE_1;}; + + + //only do these checks when there are .yaml file changes + if($this->hasYamlChanges($arrModifiedFiles)) { + if(!$this->checkCommonMistakesInBuildHook() && !$questionHelper->confirm('Proceed with commit?') ){return self::EXIT_CODE_1;} + if(!$this->checkDiskSizeVsPlanSize($project) && !$questionHelper->confirm('Proceed with commit?') ){return self::EXIT_CODE_1;} + + if($this->hasServiceYamlChanges($arrModifiedFiles)){ + $strServiceDiff = $this->git->diff($input->getArgument('revision'),["--diff-filter=M","-z","--word-diff=plain", $this->config()->get('service.project_config_dir') . '/services.yaml']); + + if(!$this->checkServiceNameChange($strServiceDiff) && !$questionHelper->confirm('Proceed with commit?')){ return self::EXIT_CODE_1;} + if(!$this->checkServiceTypeChanges($strServiceDiff) && !$questionHelper->confirm('Proceed with commit?')){ return self::EXIT_CODE_1;} + }//end hasServiceYamlChanges + }//end hasYamlChanges + + //if we get down here, all good. + $this->stdErr->writeln("All good"); + return self::EXIT_CODE_0; + } +} diff --git a/src/Command/Project/ProjectGetCommand.php b/src/Command/Project/ProjectGetCommand.php index 30731b96f..c280d69a8 100644 --- a/src/Command/Project/ProjectGetCommand.php +++ b/src/Command/Project/ProjectGetCommand.php @@ -12,7 +12,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; - +use Symfony\Component\Console\Question\ConfirmationQuestion; class ProjectGetCommand extends CommandBase { protected function configure() @@ -25,7 +25,8 @@ protected function configure() ->addArgument('directory', InputArgument::OPTIONAL, 'The directory to clone to. Defaults to the project title') ->addOption('environment', 'e', InputOption::VALUE_REQUIRED, "The environment ID to clone. Defaults to 'master' or the first available environment") ->addOption('depth', null, InputOption::VALUE_REQUIRED, 'Create a shallow clone: limit the number of commits in the history') - ->addOption('build', null, InputOption::VALUE_NONE, 'Build the project after cloning'); + ->addOption('build', null, InputOption::VALUE_NONE, 'Build the project after cloning') + ->addOption('no-create-hooks', null, InputOption::VALUE_NONE, 'Do not create hooks in your git repository to help you avoid common mistakes'); $this->addProjectOption(); Ssh::configureInput($this->getDefinition()); $this->addExample('Clone the project "abc123" into the directory "my-project"', 'abc123 my-project'); @@ -198,6 +199,17 @@ protected function execute(InputInterface $input, OutputInterface $output) $projectRootRelative )); + if (!$input->getOption('no-create-hooks')) { + $this->debug('Creating hooks'); + + $questionHelper = $this->getService('question_helper'); + $question = 'It looks like you already have a pre-commit hook, do you want to overwrite your existing pre-commit hook with ours?'; + + if(!$git->hasHook('pre-commit', $projectRoot) || $questionHelper->confirm($question)) { + $git->createHook('pre-commit', $projectRoot, $this->config()->get('application.executable')); + } + } + // Return early if there is no code in the repository. if (!glob($projectRoot . '/*', GLOB_NOSORT)) { return 0; diff --git a/src/Service/Git.php b/src/Service/Git.php index 2f2972fda..4903a279c 100644 --- a/src/Service/Git.php +++ b/src/Service/Git.php @@ -285,6 +285,24 @@ public function fetch($remote, $branch = null, $dir = null, $mustRun = false) return (bool) $this->execute($args, $dir, $mustRun, false); } + /** + * diff local against the Git remote. + * + * @param string $remote + * @param string|null $branch + * @param string|null $dir + * @param bool $mustRun + * + * @return string + */ + public function diff($remote, $additional_args = null, $dir = null, $mustRun = false) + { + $args = ['diff', $remote]; + if($additional_args) { + $args = array_merge($args, $additional_args); + } + return $this->execute($args, $dir, $mustRun, false); + } /** * Pull a ref from a repository. * @@ -431,6 +449,32 @@ public function cloneRepo($url, $destination = null, array $args = [], $mustRun return (bool) $this->execute($args, false, $mustRun, false); } + public function hasHook($hookName,$projectRoot) { + $gitRoot = $projectRoot . '/.git'; + $dest_file="$gitRoot/hooks/$hookName"; + + return file_exists($dest_file); + } + + public function createHook($hookName, $projectRoot, $application_executable) { + $source_file="resources/hooks/$hookName"; + $gitRoot = $projectRoot . '/.git'; + $dest_file="$gitRoot/hooks/$hookName"; + + if(!file_exists($source_file)) { + throw new \RuntimeException('Hook does not exist'); + } + + if(file_put_contents($dest_file, + str_replace( + '$APPLICATION_EXECUTABLE', + $application_executable, + file_get_contents($source_file) + ) + )) { + chmod($dest_file,0755); + } + } /** * Find the root directory of a Git repository. *