From 64a7bcea94c24252e395f795cee9364c98ad11ac Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 20 Apr 2026 15:22:39 +0400 Subject: [PATCH 1/3] Refactor DockerCLI for console 0.2 commands --- composer.json | 2 +- composer.lock | 64 ++++++- src/Orchestration/Adapter/DockerCLI.php | 245 +++++++++++++++++------- 3 files changed, 232 insertions(+), 79 deletions(-) diff --git a/composer.json b/composer.json index a2cb206..a4194be 100755 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ }, "require": { "php": ">=8.0", - "utopia-php/console": "0.1.*" + "utopia-php/console": "0.2.*" }, "require-dev": { "phpunit/phpunit": "^9.3", diff --git a/composer.lock b/composer.lock index 1f26377..f34fb18 100644 --- a/composer.lock +++ b/composer.lock @@ -4,24 +4,25 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ee85106451dc2f1c5cb1f93e292a848c", + "content-hash": "b88db99f3fa23de38c5d00a85a5b8e5a", "packages": [ { "name": "utopia-php/console", - "version": "0.1.1", + "version": "0.2.1", "source": { "type": "git", "url": "https://github.com/utopia-php/console.git", - "reference": "d298e43960780e6d76e66de1228c75dc81220e3e" + "reference": "97e3de44424ee9ea207c3129dfcc82f8df37c5b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/console/zipball/d298e43960780e6d76e66de1228c75dc81220e3e", - "reference": "d298e43960780e6d76e66de1228c75dc81220e3e", + "url": "https://api.github.com/repos/utopia-php/console/zipball/97e3de44424ee9ea207c3129dfcc82f8df37c5b5", + "reference": "97e3de44424ee9ea207c3129dfcc82f8df37c5b5", "shasum": "" }, "require": { - "php": ">=8.0" + "php": ">=8.0", + "utopia-php/validators": "^0.2.0" }, "require-dev": { "laravel/pint": "1.2.*", @@ -50,9 +51,54 @@ ], "support": { "issues": "https://github.com/utopia-php/console/issues", - "source": "https://github.com/utopia-php/console/tree/0.1.1" + "source": "https://github.com/utopia-php/console/tree/0.2.1" + }, + "time": "2026-04-20T10:53:53+00:00" + }, + { + "name": "utopia-php/validators", + "version": "0.2.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/validators.git", + "reference": "30b6030a5b100fc1dff34506e5053759594b2a20" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/30b6030a5b100fc1dff34506e5053759594b2a20", + "reference": "30b6030a5b100fc1dff34506e5053759594b2a20", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "require-dev": { + "laravel/pint": "1.*", + "phpstan/phpstan": "2.*", + "phpunit/phpunit": "11.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A lightweight collection of reusable validators for Utopia projects", + "keywords": [ + "php", + "utopia", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/utopia-php/validators/issues", + "source": "https://github.com/utopia-php/validators/tree/0.2.0" }, - "time": "2026-02-10T10:20:29+00:00" + "time": "2026-01-13T09:16:51+00:00" } ], "packages-dev": [ @@ -1982,5 +2028,5 @@ "php": ">=8.0" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/src/Orchestration/Adapter/DockerCLI.php b/src/Orchestration/Adapter/DockerCLI.php index 6627119..060c191 100644 --- a/src/Orchestration/Adapter/DockerCLI.php +++ b/src/Orchestration/Adapter/DockerCLI.php @@ -2,6 +2,7 @@ namespace Utopia\Orchestration\Adapter; +use Utopia\Command; use Utopia\Console; use Utopia\Orchestration\Adapter; use Utopia\Orchestration\Container; @@ -23,7 +24,13 @@ public function __construct(?string $username = null, ?string $password = null) $output = ''; $stderr = ''; - if (Console::execute('docker login --username '.$username.' --password-stdin', $password, $output, $stderr) !== 0) { + $command = new Command('docker'); + $command + ->argument('login') + ->option('--username', $username) + ->flag('--password-stdin'); + + if (Console::execute($command, $password, $output, $stderr) !== 0) { $error = empty($stderr) ? $output : $stderr; throw new Orchestration("Docker Error: {$error}"); } @@ -38,7 +45,18 @@ public function createNetwork(string $name, bool $internal = false): bool $output = ''; $stderr = ''; - $result = Console::execute('docker network create '.$name.($internal ? '--internal' : ''), '', $output, $stderr); + $command = new Command('docker'); + $command + ->argument('network') + ->argument('create'); + + if ($internal) { + $command->flag('--internal'); + } + + $command->argument($name); + + $result = Console::execute($command, '', $output, $stderr); return $result === 0; } @@ -51,7 +69,13 @@ public function removeNetwork(string $name): bool $output = ''; $stderr = ''; - $result = Console::execute('docker network rm '.$name, '', $output, $stderr); + $command = new Command('docker'); + $command + ->argument('network') + ->argument('rm') + ->argument($name); + + $result = Console::execute($command, '', $output, $stderr); return $result === 0; } @@ -64,7 +88,14 @@ public function networkConnect(string $container, string $network): bool $output = ''; $stderr = ''; - $result = Console::execute('docker network connect '.$network.' '.$container, '', $output, $stderr); + $command = new Command('docker'); + $command + ->argument('network') + ->argument('connect') + ->argument($network) + ->argument($container); + + $result = Console::execute($command, '', $output, $stderr); return $result === 0; } @@ -77,7 +108,20 @@ public function networkDisconnect(string $container, string $network, bool $forc $output = ''; $stderr = ''; - $result = Console::execute('docker network disconnect '.$network.' '.$container.($force ? ' --force' : ''), '', $output, $stderr); + $command = new Command('docker'); + $command + ->argument('network') + ->argument('disconnect'); + + if ($force) { + $command->flag('--force'); + } + + $command + ->argument($network) + ->argument($container); + + $result = Console::execute($command, '', $output, $stderr); return $result === 0; } @@ -90,7 +134,14 @@ public function networkExists(string $name): bool $output = ''; $stderr = ''; - $result = Console::execute('docker network inspect '.$name.' --format "{{.Name}}"', '', $output, $stderr); + $command = new Command('docker'); + $command + ->argument('network') + ->argument('inspect') + ->argument($name) + ->option('--format', '{{.Name}}'); + + $result = Console::execute($command, '', $output, $stderr); return $result === 0 && trim($output) === $name; } @@ -122,13 +173,18 @@ public function getStats(?string $container = null, array $filters = []): array $stats = []; - $containersString = ''; + $command = new Command('docker'); + $command + ->argument('stats') + ->flag('--no-trunc') + ->option('--format', 'id={{.ID}}&name={{.Name}}&cpu={{.CPUPerc}}&memory={{.MemPerc}}&diskIO={{.BlockIO}}&memoryIO={{.MemUsage}}&networkIO={{.NetIO}}') + ->flag('--no-stream'); foreach ($containerIds as $containerId) { - $containersString .= ' '.$containerId; + $command->argument($containerId); } - $result = Console::execute('docker stats --no-trunc --format "id={{.ID}}&name={{.Name}}&cpu={{.CPUPerc}}&memory={{.MemPerc}}&diskIO={{.BlockIO}}&memoryIO={{.MemUsage}}&networkIO={{.NetIO}}" --no-stream'.$containersString, '', $output, $stderr); + $result = Console::execute($command, '', $output, $stderr); if ($result !== 0) { return []; @@ -208,6 +264,20 @@ private function parseIOStats(string $stats) return $response; } + private function normalizeCommandArgument(string $value): string + { + if ( + \strlen($value) >= 2 + && \str_contains($value, ' ') + && ((\str_starts_with($value, "'") && \str_ends_with($value, "'")) + || (\str_starts_with($value, '"') && \str_ends_with($value, '"'))) + ) { + return \substr($value, 1, -1); + } + + return $value; + } + /** * List Networks * @@ -218,7 +288,13 @@ public function listNetworks(): array $output = ''; $stderr = ''; - $result = Console::execute('docker network ls --format "id={{.ID}}&name={{.Name}}&driver={{.Driver}}&scope={{.Scope}}"', '', $output, $stderr); + $command = new Command('docker'); + $command + ->argument('network') + ->argument('ls') + ->option('--format', 'id={{.ID}}&name={{.Name}}&driver={{.Driver}}&scope={{.Scope}}'); + + $result = Console::execute($command, '', $output, $stderr); if ($result !== 0) { $error = empty($stderr) ? $output : $stderr; @@ -251,7 +327,12 @@ public function pull(string $image): bool $output = ''; $stderr = ''; - $result = Console::execute('docker pull '.$image, '', $output, $stderr); + $command = new Command('docker'); + $command + ->argument('pull') + ->argument($image); + + $result = Console::execute($command, '', $output, $stderr); return $result === 0; } @@ -267,13 +348,18 @@ public function list(array $filters = []): array $output = ''; $stderr = ''; - $filterString = ''; + $command = new Command('docker'); + $command + ->argument('ps') + ->flag('--all') + ->flag('--no-trunc') + ->option('--format', 'id={{.ID}}&name={{.Names}}&status={{.Status}}&labels={{.Labels}}'); foreach ($filters as $key => $value) { - $filterString = $filterString.' --filter "'.$key.'='.$value.'"'; + $command->option('--filter', $key.'='.$value); } - $result = Console::execute('docker ps --all --no-trunc --format "id={{.ID}}&name={{.Names}}&status={{.Status}}&labels={{.Labels}}"'.$filterString, '', $output, $stderr); + $result = Console::execute($command, '', $output, $stderr); if ($result !== 0 && $result !== -1) { $error = empty($stderr) ? $output : $stderr; @@ -337,63 +423,76 @@ public function run( $output = ''; $stderr = ''; - foreach ($command as $key => $value) { - if (str_contains($value, ' ')) { - $command[$key] = "'".$value."'"; - } + $time = time(); + + $dockerCommand = new Command('docker'); + $dockerCommand + ->argument('run') + ->flag('-d'); + + if ($remove) { + $dockerCommand->flag('--rm'); } - $labelString = ''; + if (! empty($network)) { + $dockerCommand->option('--network', $network); + } - foreach ($labels as $labelKey => $label) { - // sanitize label - $label = str_replace("'", '', $label); + if (! empty($entrypoint)) { + $dockerCommand->option('--entrypoint', $entrypoint); + } - if (str_contains($label, ' ')) { - $label = "'".$label."'"; - } + if (! empty($this->cpus)) { + $dockerCommand->option('--cpus', $this->cpus); + } - $labelString = $labelString.' --label '.$labelKey.'='.$label; + if (! empty($this->memory)) { + $dockerCommand->option('--memory', $this->memory.'m'); } - $parsedVariables = []; + if (! empty($this->swap)) { + $dockerCommand->option('--memory-swap', $this->swap.'m'); + } - foreach ($vars as $key => $value) { - $key = $this->filterEnvKey($key); + $dockerCommand + ->option('--restart', $restart) + ->option('--name', $name) + ->option('--label', "{$this->namespace}-type=runtime") + ->option('--label', "{$this->namespace}-created={$time}"); - $value = \escapeshellarg((empty($value)) ? '' : $value); - $parsedVariables[$key] = "--env {$key}={$value}"; + if (! empty($mountFolder)) { + $dockerCommand->option('--volume', $mountFolder.':/tmp:rw'); } - $volumeString = ''; foreach ($volumes as $volume) { - $volumeString = $volumeString.'--volume '.$volume.' '; + $dockerCommand->option('--volume', $volume); + } + + foreach ($labels as $labelKey => $label) { + $label = str_replace("'", '', $label); + $dockerCommand->option('--label', $labelKey.'='.$label); } - $vars = $parsedVariables; + if (! empty($workdir)) { + $dockerCommand->option('--workdir', $workdir); + } - $time = time(); + if (! empty($hostname)) { + $dockerCommand->option('--hostname', $hostname); + } + + foreach ($vars as $key => $value) { + $key = $this->filterEnvKey($key); + $dockerCommand->option('--env', $key.'='.$value); + } + + $dockerCommand->argument($image); + + foreach ($command as $value) { + $dockerCommand->argument($this->normalizeCommandArgument($value)); + } - $result = Console::execute('docker run'. - ' -d'. - ($remove ? ' --rm' : ''). - (empty($network) ? '' : " --network=\"{$network}\""). - (empty($entrypoint) ? '' : " --entrypoint=\"{$entrypoint}\""). - (empty($this->cpus) ? '' : (' --cpus='.$this->cpus)). - (empty($this->memory) ? '' : (' --memory='.$this->memory.'m')). - (empty($this->swap) ? '' : (' --memory-swap='.$this->swap.'m')). - " --restart={$restart}". - " --name={$name}". - " --label {$this->namespace}-type=runtime". - " --label {$this->namespace}-created={$time}". - (empty($mountFolder) ? '' : " --volume {$mountFolder}:/tmp:rw"). - (empty($volumeString) ? '' : ' '.$volumeString). - (empty($labelString) ? '' : ' '.$labelString). - (empty($workdir) ? '' : " --workdir {$workdir}"). - (empty($hostname) ? '' : " --hostname {$hostname}"). - (empty($vars) ? '' : ' '.\implode(' ', $vars)). - " {$image}". - (empty($command) ? '' : ' '.implode(' ', $command)), '', $output, $stderr, 30); + $result = Console::execute($dockerCommand, '', $output, $stderr, 30); if ($result !== 0) { $error = empty($stderr) ? $output : $stderr; @@ -421,27 +520,25 @@ public function execute( ): bool { $stderr = ''; - foreach ($command as $key => $value) { - if (str_contains($value, ' ')) { - $command[$key] = "'".$value."'"; - } - } - - $parsedVariables = []; + $dockerCommand = new Command('docker'); + $dockerCommand + ->argument('exec'); foreach ($vars as $key => $value) { $key = $this->filterEnvKey($key); - - $value = \escapeshellarg((empty($value)) ? '' : $value); - $parsedVariables[$key] = "--env {$key}={$value}"; + $dockerCommand->option('--env', $key.'='.$value); } - $vars = $parsedVariables; + $dockerCommand->argument($name); + + foreach ($command as $value) { + $dockerCommand->argument($this->normalizeCommandArgument($value)); + } - $result = Console::execute('docker exec '.\implode(' ', $vars)." {$name} ".implode(' ', $command), '', $output, $stderr, $timeout); + $result = Console::execute($dockerCommand, '', $output, $stderr, $timeout); if ($result !== 0) { - if ($result == 124) { + if ($result === 124) { throw new Timeout('Command timed out'); } else { $error = empty($stderr) ? $output : $stderr; @@ -460,7 +557,17 @@ public function remove(string $name, bool $force = false): bool $output = ''; $stderr = ''; - $result = Console::execute('docker rm '.($force ? '--force' : '')." {$name}", '', $output, $stderr); + $command = new Command('docker'); + $command + ->argument('rm'); + + if ($force) { + $command->flag('--force'); + } + + $command->argument($name); + + $result = Console::execute($command, '', $output, $stderr); $combinedOutput = $output.$stderr; From 570c8c1a15ed185b87ca69f0c5b1d4f0129be90a Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 20 Apr 2026 15:26:51 +0400 Subject: [PATCH 2/3] Remove legacy quoted command shim --- src/Orchestration/Adapter/DockerCLI.php | 18 ++---------------- tests/Orchestration/Base.php | 4 ++-- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/src/Orchestration/Adapter/DockerCLI.php b/src/Orchestration/Adapter/DockerCLI.php index 060c191..a98257e 100644 --- a/src/Orchestration/Adapter/DockerCLI.php +++ b/src/Orchestration/Adapter/DockerCLI.php @@ -264,20 +264,6 @@ private function parseIOStats(string $stats) return $response; } - private function normalizeCommandArgument(string $value): string - { - if ( - \strlen($value) >= 2 - && \str_contains($value, ' ') - && ((\str_starts_with($value, "'") && \str_ends_with($value, "'")) - || (\str_starts_with($value, '"') && \str_ends_with($value, '"'))) - ) { - return \substr($value, 1, -1); - } - - return $value; - } - /** * List Networks * @@ -489,7 +475,7 @@ public function run( $dockerCommand->argument($image); foreach ($command as $value) { - $dockerCommand->argument($this->normalizeCommandArgument($value)); + $dockerCommand->argument($value); } $result = Console::execute($dockerCommand, '', $output, $stderr, 30); @@ -532,7 +518,7 @@ public function execute( $dockerCommand->argument($name); foreach ($command as $value) { - $dockerCommand->argument($this->normalizeCommandArgument($value)); + $dockerCommand->argument($value); } $result = Console::execute($dockerCommand, '', $output, $stderr, $timeout); diff --git a/tests/Orchestration/Base.php b/tests/Orchestration/Base.php index 429f3a2..54bda55 100644 --- a/tests/Orchestration/Base.php +++ b/tests/Orchestration/Base.php @@ -638,8 +638,8 @@ public function testUsageStats(): void // This allows CPU-heavy load check $output = ''; - static::getOrchestration()->execute($containerId1, ['screen', '-d', '-m', "'stress --cpu 1 --timeout 5'"], $output); // Run in screen so it's background task - static::getOrchestration()->execute($containerId2, ['screen', '-d', '-m', "'stress --cpu 1 --timeout 5'"], $output); + static::getOrchestration()->execute($containerId1, ['screen', '-d', '-m', 'stress --cpu 1 --timeout 5'], $output); // Run in screen so it's background task + static::getOrchestration()->execute($containerId2, ['screen', '-d', '-m', 'stress --cpu 1 --timeout 5'], $output); // Set CPU stress-test start \sleep(1); From 49a86d9b5bbea655f33f2acac6cc7b5a7aeaf5bb Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 20 Apr 2026 15:36:56 +0400 Subject: [PATCH 3/3] Fix structured command regressions --- src/Orchestration/Adapter/DockerCLI.php | 1 - tests/Orchestration/Base.php | 35 +++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/Orchestration/Adapter/DockerCLI.php b/src/Orchestration/Adapter/DockerCLI.php index a98257e..3c179cc 100644 --- a/src/Orchestration/Adapter/DockerCLI.php +++ b/src/Orchestration/Adapter/DockerCLI.php @@ -455,7 +455,6 @@ public function run( } foreach ($labels as $labelKey => $label) { - $label = str_replace("'", '', $label); $dockerCommand->option('--label', $labelKey.'='.$label); } diff --git a/tests/Orchestration/Base.php b/tests/Orchestration/Base.php index 54bda55..cd613ff 100644 --- a/tests/Orchestration/Base.php +++ b/tests/Orchestration/Base.php @@ -496,6 +496,37 @@ public function testListFilters(): void $this->assertSame(self::$containerID, $response[0]->getId()); } + /** + * @depends testPullImage + */ + public function testPreservesLabelValuesWithSingleQuotes(): void + { + $containerName = 'TestContainerLabelQuote'; + $labelValue = "O'Brien"; + + $containerId = static::getOrchestration()->run( + 'appwrite/runtime-for-php:8.0', + $containerName, + [ + 'sh', + '-c', + 'tail -f /dev/null', + ], + labels: ['author' => $labelValue], + ); + + $this->assertNotEmpty($containerId); + + $containers = static::getOrchestration()->list(['id' => $containerId]); + + $this->assertCount(1, $containers); + $this->assertSame($containerId, $containers[0]->getId()); + $this->assertSame($labelValue, $containers[0]->getLabels()['author']); + + $response = static::getOrchestration()->remove($containerName, true); + $this->assertSame(true, $response); + } + /** * @depends testExecContainer */ @@ -638,8 +669,8 @@ public function testUsageStats(): void // This allows CPU-heavy load check $output = ''; - static::getOrchestration()->execute($containerId1, ['screen', '-d', '-m', 'stress --cpu 1 --timeout 5'], $output); // Run in screen so it's background task - static::getOrchestration()->execute($containerId2, ['screen', '-d', '-m', 'stress --cpu 1 --timeout 5'], $output); + static::getOrchestration()->execute($containerId1, ['screen', '-d', '-m', 'stress', '--cpu', '1', '--timeout', '5'], $output); // Run in screen so it's background task + static::getOrchestration()->execute($containerId2, ['screen', '-d', '-m', 'stress', '--cpu', '1', '--timeout', '5'], $output); // Set CPU stress-test start \sleep(1);