diff --git a/src/composer.json b/src/composer.json index 22959da4..6f2b7754 100644 --- a/src/composer.json +++ b/src/composer.json @@ -23,6 +23,7 @@ "ext-curl": "*", "ext-zip": "*", "symfony/console": "^5", + "symfony/process": "^5", "symfony/filesystem": "^5", "lucatume/di52": "^3", "stecman/symfony-console-completion": "^0.11.0" diff --git a/src/composer.lock b/src/composer.lock index 45cb7504..82f67bb5 100644 --- a/src/composer.lock +++ b/src/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4adf4b66727aa6fc6d98408f4bc261ad", + "content-hash": "ed84f6c3e05a35fd3f0bf19609c0d984", "packages": [ { "name": "lucatume/di52", @@ -852,6 +852,68 @@ ], "time": "2024-01-29T20:11:03+00:00" }, + { + "name": "symfony/process", + "version": "v5.4.35", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "cbc28e34015ad50166fc2f9c8962d28d0fe861eb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/cbc28e34015ad50166fc2f9c8962d28d0fe861eb", + "reference": "cbc28e34015ad50166fc2f9c8962d28d0fe861eb", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-php80": "^1.16" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v5.4.35" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-01-23T13:51:25+00:00" + }, { "name": "symfony/service-contracts", "version": "v2.5.2", diff --git a/src/src/Commands/Environment/DownEnvironmentCommand.php b/src/src/Commands/Environment/DownEnvironmentCommand.php index 625ade24..ad5b20c3 100644 --- a/src/src/Commands/Environment/DownEnvironmentCommand.php +++ b/src/src/Commands/Environment/DownEnvironmentCommand.php @@ -68,7 +68,7 @@ protected function execute( InputInterface $input, OutputInterface $output ): in private function stopEnvironment( string $temporary_environment, OutputInterface $output ) { // Implement the logic to stop the environment - $this->e2e_environment->down( $temporary_environment ); + $this->e2e_environment->down( $this->environment_monitor->get_env_info_by_id( $temporary_environment ) ); $output->writeln( "Environment '$temporary_environment' stopped." ); } } diff --git a/src/src/Commands/Environment/ListEnvironmentCommand.php b/src/src/Commands/Environment/ListEnvironmentCommand.php index fa295a62..02c29d0c 100644 --- a/src/src/Commands/Environment/ListEnvironmentCommand.php +++ b/src/src/Commands/Environment/ListEnvironmentCommand.php @@ -49,6 +49,9 @@ protected function execute( InputInterface $input, OutputInterface $output ): in if ( $k === 'created_at' ) { $v = $elapsed; } + if ( is_array( $v ) ) { + $v = implode( ', ', $v ); + } $definitions[] = [ ucwords( $k ) => $v ]; } diff --git a/src/src/Commands/Environment/RestartEnvironmentCommand.php b/src/src/Commands/Environment/RestartEnvironmentCommand.php index 50e0ba8b..e86fa50e 100644 --- a/src/src/Commands/Environment/RestartEnvironmentCommand.php +++ b/src/src/Commands/Environment/RestartEnvironmentCommand.php @@ -32,7 +32,7 @@ public function __construct( } protected function configure() { - $this->setDescription( 'Restarts the local test environment.' ); + $this->setDescription( 'Restarts a local test environment.' ); } protected function execute( InputInterface $input, OutputInterface $output ): int { diff --git a/src/src/Commands/Environment/UpEnvironmentCommand.php b/src/src/Commands/Environment/UpEnvironmentCommand.php index 103d3dda..0a764d45 100644 --- a/src/src/Commands/Environment/UpEnvironmentCommand.php +++ b/src/src/Commands/Environment/UpEnvironmentCommand.php @@ -32,7 +32,7 @@ protected function configure() { DynamicCommandCreator::add_schema_to_command( $this, $schemas['e2e'], [ 'compatibility' ] ); - $this->setDescription( 'Starts the local test environment.' ) + $this->setDescription( 'Starts a local test environment.' ) ->setAliases( [ 'env:start' ] ); } diff --git a/src/src/Environment/Docker.php b/src/src/Environment/Docker.php index fb2db80a..cc10781b 100644 --- a/src/src/Environment/Docker.php +++ b/src/src/Environment/Docker.php @@ -15,18 +15,22 @@ public function find_docker() { } } - public function find_docker_compose() { + public function run_on_docker( EnvInfo $env_info, array $command ) { + + } + + public function find_docker_compose(): array { // Find out if it's docker-compose (v1) or docker compose (v2) - $dockerComposeV2 = 'docker compose'; - $dockerComposeV1 = 'docker-compose'; + $docker_compose_v2 = 'docker compose'; + $docker_compose_v1 = 'docker-compose'; - $dockerComposeV2Version = trim( shell_exec( $dockerComposeV2 . ' --version' ) ); - $dockerComposeV1Version = trim( shell_exec( $dockerComposeV1 . ' --version' ) ); + $v1_version = trim( shell_exec( $docker_compose_v2 . ' --version' ) ); + $v2_version = trim( shell_exec( $docker_compose_v1 . ' --version' ) ); - if ( $dockerComposeV2Version ) { - return $dockerComposeV2; - } elseif ( $dockerComposeV1Version ) { - return $dockerComposeV1; + if ( $v1_version ) { + return [ 'docker-compose' ]; + } elseif ( $v2_version ) { + return [ 'docker', 'compose' ]; } else { throw new \RuntimeException( 'Could not find docker-compose or docker compose' ); } diff --git a/src/src/Environment/Environment.php b/src/src/Environment/Environment.php index 69e717ea..740a0327 100644 --- a/src/src/Environment/Environment.php +++ b/src/src/Environment/Environment.php @@ -4,7 +4,10 @@ use QIT_CLI\Cache; use QIT_CLI\Config; +use QIT_CLI\SafeRemove; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Process\Process; abstract class Environment { /** @var EnvironmentDownloader */ @@ -34,12 +37,16 @@ abstract class Environment { /** @var string */ protected $temporary_environment_path; + /** @var OutputInterface */ + protected $output; + public function __construct( EnvironmentDownloader $environment_Downloader, Cache $cache, EnvironmentMonitor $environment_monitor, Filesystem $filesystem, - Docker $docker + Docker $docker, + OutputInterface $output ) { $this->environment_downloader = $environment_Downloader; $this->cache = $cache; @@ -51,58 +58,147 @@ public function __construct( $this->source_environment_path = Config::get_qit_dir() . '/environments/' . $this->get_name(); $this->temporary_environment_id = uniqid(); $this->temporary_environment_path = static::get_temp_envs_dir() . $this->get_name() . '-' . $this->temporary_environment_id; + $this->output = $output; } abstract public function get_name(): string; - abstract protected function do_up( EnvInfo $env_info ): void; + abstract protected function prepare_generate_docker_compose( Process $process ): void; public function up(): void { + // Start the benchmark. + $start = microtime( true ); + $this->environment_downloader->maybe_download( $this->get_name() ); + $this->maybe_create_cache_dir(); + $this->copy_environment(); + $env_info = $this->init_env_info(); + $this->environment_monitor->environment_added_or_updated( $env_info ); + $this->generate_docker_compose(); + $this->up_docker_compose( $env_info ); - // Make sure cache directory exists. - if ( ! file_exists( $this->cache_dir ) ) { - if ( mkdir( $this->cache_dir ) === false ) { - throw new \RuntimeException( 'Failed to create cache directory on ' . $this->cache_dir ); - } - } + $server_started = microtime( true ); + echo "Server started at " . round( microtime( true ) - $start, 2 ) . " seconds\n"; - // Start the benchmark. - $start = microtime( true ); + echo "Temporary environment: $this->temporary_environment_path\n"; + } - // Copy the reference environment to a temporary one. + // Copies the source environment to the temporary environment. + protected function copy_environment(): void { $this->filesystem->mirror( $this->source_environment_path, $this->temporary_environment_path ); + if ( ! file_exists( $this->temporary_environment_path . '/docker-compose-generator.php' ) ) { throw new \RuntimeException( "Failed to copy the environment." ); } + } + + // Creates the cache directory if it doesn't exist. + protected function maybe_create_cache_dir(): void { + if ( ! file_exists( $this->cache_dir ) ) { + if ( mkdir( $this->cache_dir, 0755 ) === false ) { + throw new \RuntimeException( 'Failed to create cache directory on ' . $this->cache_dir ); + } + } + } - // Create the Env Info. + // Initialize the default env info for the temporary environment. + protected function init_env_info(): EnvInfo { $env_info = new EnvInfo(); $env_info->type = $this->get_name(); $env_info->temporary_env = $this->temporary_environment_path; $env_info->created_at = time(); $env_info->status = 'pending'; - $this->environment_monitor->environment_added_or_updated( $env_info ); + return $env_info; + } - $this->do_up( $env_info ); + protected function generate_docker_compose(): void { + $process = new Process( [ PHP_BINARY, $this->temporary_environment_path . '/docker-compose-generator.php' ] ); + $this->prepare_generate_docker_compose( $process ); + $process->setEnv( array_merge( $process->getEnv(), [ + 'CACHE_DIR' => $this->cache_dir, + 'QIT_ENV_ID' => $this->temporary_environment_id, + ] ) ); - $server_started = microtime( true ); - echo "Server started at " . round( microtime( true ) - $start, 2 ) . " seconds\n"; + try { + $process->mustRun(); - echo "Temporary environment: $this->temporary_environment_path\n"; + if ( $this->output->isVerbose() ) { + $this->output->writeln( $process->getOutput() ); + } + } catch ( \Exception $e ) { + throw new \RuntimeException( "Failed to generate docker-compose.yml" ); + } + } + + protected function up_docker_compose( EnvInfo $env_info ) { + $this->add_container_names( $env_info ); + + $up_process = new Process( array_merge( $this->docker->find_docker_compose(), [ '-f', $env_info->temporary_env . '/docker-compose.yml', 'up', '-d' ] ) ); + $up_process->setTty( true ); + + $up_process->run( function ( $type, $buffer ) { + $this->output->write( $buffer ); + } ); + + if ( ! $up_process->isSuccessful() ) { + $this->down( $env_info ); + throw new \RuntimeException( "Failed to start the environment." ); + } + + $env_info->status = 'started'; + + $this->environment_monitor->environment_added_or_updated( $env_info ); } - public function down( string $env_info_id ) { - // Stops the given environment. - $docker_compose = $this->docker->find_docker_compose(); + public function down( EnvInfo $env_info ): void { + $down_process = new Process( array_merge( $this->docker->find_docker_compose(), [ '-f', $env_info->temporary_env . '/docker-compose.yml', 'down' ] ) ); + $down_process->setTty( true ); + + $down_process->run( function ( $type, $buffer ) { + $this->output->write( $buffer ); + } ); - $env_info = $this->environment_monitor->get_env_info_by_id( $env_info_id ); + if ( $down_process->isSuccessful() ) { + $this->output->writeln( "Removing temporary environment: " . $env_info->temporary_env ); + SafeRemove::delete_dir( $env_info->temporary_env, static::get_temp_envs_dir() ); + } else { + $this->output->writeln( "Failed to remove temporary environment: " . $env_info->temporary_env ); + } - exec( $docker_compose . ' -f ' . $env_info->temporary_env . '/docker-compose.yml down', $down_output, $down_result_code ); $this->environment_monitor->environment_stopped( $env_info ); } + protected function add_container_names( EnvInfo $env_info ): void { + /* + * Get the docker containers names, eg: + * [+] Running 4/0 + * ✔ DRY-RUN MODE - Container qit_env_cache_65dcc53c66545 Running 0.0s + * ✔ DRY-RUN MODE - Container qit_env_db_65dcc53c66545 Running 0.0s + * ✔ DRY-RUN MODE - Container qit_env_php_65dcc53c66545 Running 0.0s + * ✔ DRY-RUN MODE - Container qit_env_nginx_65dcc53c66545 Created 0.0s + * end of 'compose up' output, interactive run is not supported in dry-run mode + */ + $up_dry_run_process = new Process( array_merge( $this->docker->find_docker_compose(), [ '-f', $this->temporary_environment_path . '/docker-compose.yml', 'up', '--dry-run' ] ) ); + $up_dry_run_process->run(); + + $containers = []; + + foreach ( explode( "\n", $up_dry_run_process->getOutput() . "\n" . $up_dry_run_process->getErrorOutput() ) as $line ) { + if ( preg_match( '/(qit_env_[\w\d]+)/', $line, $matches ) ) { + $containers[] = $matches[1]; + } + } + + $containers = array_unique( $containers ); + + if ( empty( $containers ) ) { + throw new \RuntimeException( "Failed to start the environment. No containers found." ); + } + + $env_info->docker_images = $containers; + } + public static function get_temp_envs_dir(): string { $dir = Config::get_qit_dir() . '/temporary-envs/'; diff --git a/src/src/Environment/EnvironmentOrphanCleanup.php b/src/src/Environment/EnvironmentOrphanCleanup.php index 69d55d18..6254d409 100644 --- a/src/src/Environment/EnvironmentOrphanCleanup.php +++ b/src/src/Environment/EnvironmentOrphanCleanup.php @@ -2,8 +2,15 @@ namespace QIT_CLI\Environment; +use QIT_CLI\App; +use QIT_CLI\IO\Input; +use QIT_CLI\SafeRemove; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Process\Process; class EnvironmentOrphanCleanup { /** @var array|mixed */ @@ -12,20 +19,110 @@ class EnvironmentOrphanCleanup { /** @var Filesystem */ protected $filesystem; + /** @var InputInterface */ + protected $input; + /** @var OutputInterface */ protected $output; + /** @var array */ + protected $directories_to_delete = []; + + /** @var array */ + protected $containers_to_remove = []; + public function __construct( EnvironmentMonitor $environment_monitor, Filesystem $filesystem, - OutputInterface $output + OutputInterface $output, + InputInterface $input, ) { $this->environment_monitor = $environment_monitor; $this->filesystem = $filesystem; $this->output = $output; + $this->input = $input; } public function cleanup_orphans(): void { + $this->delete_orphaned_environments_from_fileystem(); + $this->delete_orphaned_docker_containers(); + + // Check if there are actions to perform. + if ( empty( $this->directories_to_delete ) && empty( $this->containers_to_remove ) ) { + return; + } + + // List the actions to be taken + $this->output->writeln( 'The following actions will be taken:' ); + foreach ( $this->directories_to_delete as $directory ) { + $this->output->writeln( "- Remove directory $directory" ); + } + foreach ( $this->containers_to_remove as $containerName ) { + $this->output->writeln( "- Delete docker container $containerName" ); + } + + // Prepare the confirmation question + $helper = new QuestionHelper(); + $question = new ConfirmationQuestion( + 'Orphaned environments and containers were found. Do you want to remove them? [y/N]', + false + ); + + // Ask the user for confirmation + if ( ! $helper->ask( $this->input, $this->output, $question ) ) { + $this->output->writeln( 'Action cancelled by user.' ); + + return; + } + + foreach ( $this->directories_to_delete as $directory ) { + $this->output->writeln( "Removing orphaned environment: {$directory}" ); + SafeRemove::delete_dir( $directory, Environment::get_temp_envs_dir() ); + } + + foreach ( $this->containers_to_remove as $containerName ) { + $this->output->writeln( "Removing orphaned container: {$containerName}" ); + $stopProcess = new Process( [ 'docker', 'stop', $containerName ] ); + $stopProcess->run(); + $removeProcess = new Process( [ 'docker', 'rm', $containerName ] ); + $removeProcess->run(); + } + } + + protected function delete_orphaned_docker_containers() { + $running_environment_docker_images = array_map( function ( EnvInfo $env_info ) { + return $env_info->docker_images; + }, $this->environment_monitor->get() ); + + // 1. List the running containers + $listProcess = new Process( [ 'docker', 'container', 'ls', '--format=json' ] ); + $listProcess->run(); + $containersOutput = $listProcess->getOutput(); + + $lines = explode( "\n", $containersOutput ); + + foreach ( $lines as $line ) { + $c = json_decode( $line, true ); + if ( $c === null ) { + continue; + } + if ( empty( $c['Names'] ) ) { + continue; + } + $containerName = $c['Names']; + + if ( substr( $containerName, 0, 9 ) === 'qit_env_' ) { + foreach ( $running_environment_docker_images as $running_environment_docker_image ) { + if ( in_array( $containerName, $running_environment_docker_image, true ) ) { + continue 2; + } + } + $this->containers_to_remove[] = $containerName; + } + } + } + + protected function delete_orphaned_environments_from_fileystem() { $running_environment_paths = array_map( function ( EnvInfo $env_info ) { return $env_info->temporary_env; }, $this->environment_monitor->get() ); @@ -37,8 +134,7 @@ public function cleanup_orphans(): void { } if ( ! in_array( $fileInfo->getPathname(), $running_environment_paths, true ) ) { - $this->output->writeln( "Removing orphaned environment: {$fileInfo->getPathname()}" ); - $this->filesystem->remove( $fileInfo->getPathname() ); + $this->directories_to_delete[] = $fileInfo->getPathname(); } } } diff --git a/src/src/Environment/Environments/E2EEnvironment.php b/src/src/Environment/Environments/E2EEnvironment.php index fca23a97..993415da 100644 --- a/src/src/Environment/Environments/E2EEnvironment.php +++ b/src/src/Environment/Environments/E2EEnvironment.php @@ -2,10 +2,8 @@ namespace QIT_CLI\Environment\Environments; -use QIT_CLI\App; -use QIT_CLI\Environment\EnvInfo; use QIT_CLI\Environment\Environment; -use QIT_CLI\IO\Output; +use Symfony\Component\Process\Process; class E2EEnvironment extends Environment { /** @var string */ @@ -15,33 +13,7 @@ public function get_name(): string { return 'e2e'; } - public function do_up( EnvInfo $env_info ): void { - // Generate docker-compose.yml in the temporary environment. - passthru( "CACHE_DIR={$this->cache_dir} " . PHP_BINARY . ' ' . $this->temporary_environment_path . '/docker-compose-generator.php' ); - - $docker_compose = $this->docker->find_docker_compose(); - - // Start docker compose in temporary environment. - exec( $docker_compose . ' -f ' . $this->temporary_environment_path . '/docker-compose.yml up -d', $up_output, $up_result_code ); - - if ( $up_result_code !== 0 ) { - exec( $docker_compose . ' -f ' . $this->temporary_environment_path . '/docker-compose.yml down', $down_output, $down_result_code ); - - $this->filesystem->remove( $this->temporary_environment_path ); - - if ( file_exists( $this->temporary_environment_path . '/docker-compose-generator.php' ) ) { - App::make( Output::class )->writeln( sprintf( 'Failed to delete the temporary environment: %s', $this->temporary_environment_path ) ); - } - - $this->environment_monitor->environment_stopped( $env_info ); - - throw new \RuntimeException( "Failed to start the environment." ); - } - - $env_info->status = 'started'; - - $this->environment_monitor->environment_added_or_updated( $env_info ); - - + protected function prepare_generate_docker_compose( Process $process ): void { + // no-op. } } \ No newline at end of file diff --git a/src/src/SafeRemove.php b/src/src/SafeRemove.php new file mode 100644 index 00000000..fe89f1ef --- /dev/null +++ b/src/src/SafeRemove.php @@ -0,0 +1,45 @@ +remove( $normalizedPath ); + } +} diff --git a/src/src/bootstrap.php b/src/src/bootstrap.php index 205a7d31..0e8c9078 100644 --- a/src/src/bootstrap.php +++ b/src/src/bootstrap.php @@ -80,6 +80,8 @@ public function getDefaultCommands() { return new ConsoleOutput(); } ); +$container->bind( OutputInterface::class, $container->make( Output::class ) ); +$container->bind( InputInterface::class, $container->make( Input::class ) ); $container->singleton( ManagerSync::class ); $container->singleton( Config::class ); $container->singleton( ManagerBackend::class ); @@ -241,4 +243,6 @@ public function filter( $in, $out, &$consumed, $closing ): int { $container->make( Output::class )->writeln( sprintf( 'QIT Manager Backend: %s', Config::get_current_manager_backend() ) ); } +App::make( \QIT_CLI\Environment\EnvironmentOrphanCleanup::class )->cleanup_orphans(); + return $application;