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;