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.
*