From 850482b0b05407c5a32242b9b59764925ffaf199 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 1 Jun 2026 19:06:56 +0400 Subject: [PATCH 1/2] Add Docker mount value object --- README.md | 9 +- src/Orchestration/Adapter.php | 2 +- src/Orchestration/Adapter/DockerAPI.php | 17 +++- src/Orchestration/Adapter/DockerCLI.php | 57 +++++++++--- src/Orchestration/Mount.php | 78 ++++++++++++++++ src/Orchestration/Orchestration.php | 2 +- tests/Orchestration/MountTest.php | 119 ++++++++++++++++++++++++ 7 files changed, 265 insertions(+), 19 deletions(-) create mode 100644 src/Orchestration/Mount.php create mode 100644 tests/Orchestration/MountTest.php diff --git a/README.md b/README.md index fca9c5e..1c62c44 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ require_once 'vendor/autoload.php'; use Utopia\Orchestration\Orchestration; use Utopia\Orchestration\Adapter\DockerCLI; +use Utopia\Orchestration\Mount; // Initialise Orchestration with Docker CLI adapter. $orchestration = new Orchestration(new DockerCLI()); @@ -102,7 +103,11 @@ Once you have initialised your Orchestration object the following methods can be ['echo', 'hello world!'], 'entrypoint', 'workdir', - ['tmp:/tmp:rw', 'cooldirectory:/home/folder:rw'], + [ + 'tmp:/tmp:rw', + Mount::bind('/host/cache', '/cache'), + Mount::volume('openruntimes-build-cache', '/cache', subpath: 'cache-key'), + ], ['ENV_VAR' => 'value'], '/tmp', ['label' => 'value'], @@ -139,7 +144,7 @@ Once you have initialised your Orchestration object the following methods can be - `volumes` [Array] - The volumes to attach to the container. + The volumes to attach to the container. String values are passed as legacy Docker volume binds. `Mount::bind(...)` and `Mount::volume(...)` values are rendered as Docker mounts. Docker volume subpaths must already exist; callers are responsible for preparing them before running the container. - `env` [Array] diff --git a/src/Orchestration/Adapter.php b/src/Orchestration/Adapter.php index c4cfd56..723ef01 100644 --- a/src/Orchestration/Adapter.php +++ b/src/Orchestration/Adapter.php @@ -100,7 +100,7 @@ abstract public function list(array $filters = []): array; * On fail it will throw an exception. * * @param string[] $command - * @param string[] $volumes + * @param array $volumes * @param array $vars * @param array $labels */ diff --git a/src/Orchestration/Adapter/DockerAPI.php b/src/Orchestration/Adapter/DockerAPI.php index 22d2946..8895aad 100644 --- a/src/Orchestration/Adapter/DockerAPI.php +++ b/src/Orchestration/Adapter/DockerAPI.php @@ -8,6 +8,7 @@ use Utopia\Orchestration\Container\Stats; use Utopia\Orchestration\Exception\Orchestration; use Utopia\Orchestration\Exception\Timeout; +use Utopia\Orchestration\Mount; use Utopia\Orchestration\Network; class DockerAPI extends Adapter @@ -475,7 +476,7 @@ public function list(array $filters = []): array * On fail it will throw an exception. * * @param string[] $command - * @param string[] $volumes + * @param array $volumes * @param array $vars * @param array $labels */ @@ -511,6 +512,17 @@ public function run( $labels[$this->namespace.'-type'] = 'runtime'; $labels[$this->namespace.'-created'] = (string) time(); + $binds = []; + $mounts = []; + + foreach ($volumes as $volume) { + if ($volume instanceof Mount) { + $mounts[] = $volume->toDockerAPI(); + } else { + $binds[] = $volume; + } + } + $body = [ 'Hostname' => $hostname, 'Entrypoint' => $entrypoint, @@ -520,7 +532,8 @@ public function run( 'Labels' => (object) $labels, 'Env' => array_values($vars), 'HostConfig' => [ - 'Binds' => $volumes, + 'Binds' => $binds, + 'Mounts' => $mounts, 'CpuQuota' => floatval($this->cpus) * 100000, 'CpuPeriod' => 100000, 'Memory' => intval($this->memory) * 1e+6, // Convert into bytes diff --git a/src/Orchestration/Adapter/DockerCLI.php b/src/Orchestration/Adapter/DockerCLI.php index 3c179cc..640894e 100644 --- a/src/Orchestration/Adapter/DockerCLI.php +++ b/src/Orchestration/Adapter/DockerCLI.php @@ -9,6 +9,7 @@ use Utopia\Orchestration\Container\Stats; use Utopia\Orchestration\Exception\Orchestration; use Utopia\Orchestration\Exception\Timeout; +use Utopia\Orchestration\Mount; use Utopia\Orchestration\Network; class DockerCLI extends Adapter @@ -387,7 +388,7 @@ public function list(array $filters = []): array * On fail it will throw an exception. * * @param string[] $command - * @param string[] $volumes + * @param array $volumes * @param array $vars * @param array $labels */ @@ -409,6 +410,42 @@ public function run( $output = ''; $stderr = ''; + $dockerCommand = $this->getRunCommand($image, $name, $command, $entrypoint, $workdir, $volumes, $vars, $mountFolder, $labels, $hostname, $remove, $network, $restart); + + $result = Console::execute($dockerCommand, '', $output, $stderr, 30); + + if ($result !== 0) { + $error = empty($stderr) ? $output : $stderr; + throw new Orchestration("Docker Error: {$error}"); + } + + // Use first line only, CLI can add warnings or other messages + $output = \explode("\n", $output)[0]; + + return rtrim($output); + } + + /** + * @param string[] $command + * @param array $volumes + * @param array $vars + * @param array $labels + */ + protected function getRunCommand( + string $image, + string $name, + array $command = [], + string $entrypoint = '', + string $workdir = '', + array $volumes = [], + array $vars = [], + string $mountFolder = '', + array $labels = [], + string $hostname = '', + bool $remove = false, + string $network = '', + string $restart = self::RESTART_NO + ): Command { $time = time(); $dockerCommand = new Command('docker'); @@ -451,7 +488,11 @@ public function run( } foreach ($volumes as $volume) { - $dockerCommand->option('--volume', $volume); + if ($volume instanceof Mount) { + $dockerCommand->option('--mount', $volume->toDockerCLI()); + } else { + $dockerCommand->option('--volume', $volume); + } } foreach ($labels as $labelKey => $label) { @@ -477,17 +518,7 @@ public function run( $dockerCommand->argument($value); } - $result = Console::execute($dockerCommand, '', $output, $stderr, 30); - - if ($result !== 0) { - $error = empty($stderr) ? $output : $stderr; - throw new Orchestration("Docker Error: {$error}"); - } - - // Use first line only, CLI can add warnings or other messages - $output = \explode("\n", $output)[0]; - - return rtrim($output); + return $dockerCommand; } /** diff --git a/src/Orchestration/Mount.php b/src/Orchestration/Mount.php new file mode 100644 index 0000000..f285ecc --- /dev/null +++ b/src/Orchestration/Mount.php @@ -0,0 +1,78 @@ +|bool|string> + */ + public function toDockerAPI(): array + { + $mount = [ + 'Type' => $this->type, + 'Source' => $this->getSource(), + 'Target' => $this->target, + 'ReadOnly' => $this->readOnly, + ]; + + if ($this->type === self::TYPE_VOLUME && $this->subpath !== '') { + $mount['VolumeOptions'] = [ + 'Subpath' => $this->subpath, + ]; + } + + return $mount; + } + + public function toDockerCLI(): string + { + $mount = [ + 'type='.$this->type, + 'source='.$this->getSource(), + 'target='.$this->target, + ]; + + if ($this->readOnly) { + $mount[] = 'readonly'; + } + + if ($this->type === self::TYPE_VOLUME && $this->subpath !== '') { + $mount[] = 'volume-subpath='.$this->subpath; + } + + return \implode(',', $mount); + } + + private function getSource(): string + { + if ($this->type !== self::TYPE_BIND || $this->subpath === '') { + return $this->source; + } + + return \rtrim($this->source, '/').'/'.\ltrim($this->subpath, '/'); + } +} diff --git a/src/Orchestration/Orchestration.php b/src/Orchestration/Orchestration.php index 6e366b7..be8a707 100644 --- a/src/Orchestration/Orchestration.php +++ b/src/Orchestration/Orchestration.php @@ -159,7 +159,7 @@ public function list(array $filters = []): array * On fail it will throw an exception. * * @param string[] $command - * @param string[] $volumes + * @param array $volumes * @param array $labels * @param array $vars */ diff --git a/tests/Orchestration/MountTest.php b/tests/Orchestration/MountTest.php new file mode 100644 index 0000000..a63cd16 --- /dev/null +++ b/tests/Orchestration/MountTest.php @@ -0,0 +1,119 @@ +assertSame([ + 'Type' => 'volume', + 'Source' => 'openruntimes-build-cache', + 'Target' => '/cache', + 'ReadOnly' => false, + 'VolumeOptions' => [ + 'Subpath' => 'cache-key', + ], + ], $mount->toDockerAPI()); + + $this->assertSame( + 'type=volume,source=openruntimes-build-cache,target=/cache,volume-subpath=cache-key', + $mount->toDockerCLI() + ); + } + + public function testDockerAPIRequestBodyIncludesMountsAndLegacyBinds(): void + { + $adapter = new MountTestDockerAPI(); + + $adapter->run( + 'ubuntu:latest', + 'MountTestContainer', + volumes: [ + '/host/path:/container/path:rw', + Mount::volume('openruntimes-build-cache', '/cache', false, 'cache-key'), + ] + ); + + $hostConfig = $adapter->createBody['HostConfig']; + + $this->assertSame(['/host/path:/container/path:rw'], $hostConfig['Binds']); + $this->assertSame([ + [ + 'Type' => 'volume', + 'Source' => 'openruntimes-build-cache', + 'Target' => '/cache', + 'ReadOnly' => false, + 'VolumeOptions' => [ + 'Subpath' => 'cache-key', + ], + ], + ], $hostConfig['Mounts']); + } + + public function testDockerCLIRendersMountsAndLegacyVolumes(): void + { + $adapter = new MountTestDockerCLI(); + + $arguments = $adapter->getRunArguments([ + '/host/path:/container/path:rw', + Mount::volume('openruntimes-build-cache', '/cache', false, 'cache-key'), + ]); + + $this->assertContains('--volume', $arguments); + $this->assertContains('/host/path:/container/path:rw', $arguments); + $this->assertContains('--mount', $arguments); + $this->assertContains('type=volume,source=openruntimes-build-cache,target=/cache,volume-subpath=cache-key', $arguments); + } +} + +class MountTestDockerAPI extends DockerAPI +{ + /** + * @var array + */ + public array $createBody = []; + + /** + * @param array|bool|int|float|object|resource|string|null $body + * @param string[] $headers + * @return array{response: mixed, code: mixed} + */ + protected function call(string $url, string $method, $body = null, array $headers = [], int $timeout = -1): array + { + if ($method === 'GET' && str_contains($url, '/images/')) { + return ['response' => '{}', 'code' => 200]; + } + + if ($method === 'POST' && str_contains($url, '/containers/create')) { + $this->createBody = \json_decode((string) $body, true); + + return ['response' => '{"Id":"container-id"}', 'code' => 201]; + } + + if ($method === 'POST' && str_contains($url, '/containers/container-id/start')) { + return ['response' => '', 'code' => 204]; + } + + return ['response' => '', 'code' => 500]; + } +} + +class MountTestDockerCLI extends DockerCLI +{ + /** + * @param array $volumes + * @return string[] + */ + public function getRunArguments(array $volumes): array + { + return $this->getRunCommand('ubuntu:latest', 'MountTestContainer', volumes: $volumes)->toArray(); + } +} From 34f62718bde0017030cb48e90a1c843e1befa49d Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 1 Jun 2026 19:18:44 +0400 Subject: [PATCH 2/2] Cover bind mount serialization --- README.md | 2 +- src/Orchestration/Mount.php | 4 ++++ tests/Orchestration/MountTest.php | 34 +++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1c62c44..ef4f867 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ Once you have initialised your Orchestration object the following methods can be - `volumes` [Array] - The volumes to attach to the container. String values are passed as legacy Docker volume binds. `Mount::bind(...)` and `Mount::volume(...)` values are rendered as Docker mounts. Docker volume subpaths must already exist; callers are responsible for preparing them before running the container. + The volumes to attach to the container. String values are passed as legacy Docker volume binds. `Mount::bind(...)` and `Mount::volume(...)` values are rendered as Docker mounts. For bind mounts, `subpath` is appended to the host source path. Docker volume subpaths use Docker's native subpath support and must already exist; callers are responsible for preparing them before running the container. - `env` [Array] diff --git a/src/Orchestration/Mount.php b/src/Orchestration/Mount.php index f285ecc..18c06fe 100644 --- a/src/Orchestration/Mount.php +++ b/src/Orchestration/Mount.php @@ -22,6 +22,9 @@ public static function bind(string $source, string $target, bool $readOnly = fal return new self(self::TYPE_BIND, $source, $target, $readOnly, $subpath); } + /** + * Docker volume subpaths use native volume subpath support. Docker requires the subpath to already exist. + */ public static function volume(string $source, string $target, bool $readOnly = false, string $subpath = ''): self { return new self(self::TYPE_VOLUME, $source, $target, $readOnly, $subpath); @@ -73,6 +76,7 @@ private function getSource(): string return $this->source; } + // Bind subpaths are regular host path suffixes, not Docker native subpaths. return \rtrim($this->source, '/').'/'.\ltrim($this->subpath, '/'); } } diff --git a/tests/Orchestration/MountTest.php b/tests/Orchestration/MountTest.php index a63cd16..5b1f738 100644 --- a/tests/Orchestration/MountTest.php +++ b/tests/Orchestration/MountTest.php @@ -29,6 +29,40 @@ public function testVolumeSerialization(): void ); } + public function testBindSerialization(): void + { + $mount = Mount::bind('/host/path', '/container/path'); + + $this->assertSame([ + 'Type' => 'bind', + 'Source' => '/host/path', + 'Target' => '/container/path', + 'ReadOnly' => false, + ], $mount->toDockerAPI()); + + $this->assertSame( + 'type=bind,source=/host/path,target=/container/path', + $mount->toDockerCLI() + ); + } + + public function testBindSerializationWithSubpath(): void + { + $mount = Mount::bind('/host/path', '/container/path', subpath: 'sub'); + + $this->assertSame([ + 'Type' => 'bind', + 'Source' => '/host/path/sub', + 'Target' => '/container/path', + 'ReadOnly' => false, + ], $mount->toDockerAPI()); + + $this->assertSame( + 'type=bind,source=/host/path/sub,target=/container/path', + $mount->toDockerCLI() + ); + } + public function testDockerAPIRequestBodyIncludesMountsAndLegacyBinds(): void { $adapter = new MountTestDockerAPI();