diff --git a/src/Orchestration/Adapter.php b/src/Orchestration/Adapter.php index 4b394de..3c0642f 100644 --- a/src/Orchestration/Adapter.php +++ b/src/Orchestration/Adapter.php @@ -101,7 +101,8 @@ abstract public function run( array $labels = [], string $hostname = '', bool $remove = false, - string $network = ''): string; + string $network = '', + int $replicas = 1): string; /** * Execute Container diff --git a/src/Orchestration/Adapter/DockerAPI.php b/src/Orchestration/Adapter/DockerAPI.php index 71b1ef4..6527c3b 100644 --- a/src/Orchestration/Adapter/DockerAPI.php +++ b/src/Orchestration/Adapter/DockerAPI.php @@ -417,7 +417,8 @@ public function run( array $labels = [], string $hostname = '', bool $remove = false, - string $network = '' + string $network = '', + int $replicas = 1 ): string { $parsedVariables = []; diff --git a/src/Orchestration/Adapter/DockerCLI.php b/src/Orchestration/Adapter/DockerCLI.php index b5f75a7..59a1ffb 100644 --- a/src/Orchestration/Adapter/DockerCLI.php +++ b/src/Orchestration/Adapter/DockerCLI.php @@ -307,7 +307,8 @@ public function run(string $image, array $labels = [], string $hostname = '', bool $remove = false, - string $network = '' + string $network = '', + int $replicas = 1 ): string { $output = ''; diff --git a/src/Orchestration/Adapter/DockerSwarmCLI.php b/src/Orchestration/Adapter/DockerSwarmCLI.php new file mode 100644 index 0000000..9b2e66e --- /dev/null +++ b/src/Orchestration/Adapter/DockerSwarmCLI.php @@ -0,0 +1,442 @@ + $filters + * @return array + */ + public function getStats(string $container = null, array $filters = []): array + { + // List ahead of time, since docker stats does not allow filtering + $containerIds = []; + + if ($container === null) { + $containers = $this->list($filters); + $containerIds = \array_map(fn ($c) => $c->getId(), $containers); + } else { + $containerIds[] = $container; + } + + $output = ''; + + if (\count($containerIds) <= 0 && \count($filters) > 0) { + return []; // No containers found + } + + $stats = []; + + $containersString = ''; + + foreach ($containerIds as $containerId) { + $containersString .= ' '.$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); + + if ($result !== 0) { + throw new Orchestration("Docker Error: {$output}"); + } + + $lines = \explode("\n", $output); + + foreach ($lines as $line) { + if (empty($line)) { + continue; + } + + $stat = []; + \parse_str($line, $stat); + + $stats[] = new Stats( + containerId: $stat['id'], + containerName: $stat['name'], + cpuUsage: \floatval(\rtrim($stat['cpu'], '%')) / 100, // Remove percentage symbol, parse to number, convert to percentage + memoryUsage: empty($stat['memory']) ? 0 : \floatval(\rtrim($stat['memory'], '%')), // Remove percentage symbol and parse to number. Value is empty on Windows + diskIO: $this->parseIOStats($stat['diskIO']), + memoryIO: $this->parseIOStats($stat['memoryIO']), + networkIO: $this->parseIOStats($stat['networkIO']), + ); + } + + return $stats; + } + + /** + * Use this method to parse string format into numeric in&out stats. + * CLI IO stats in verbose format: "2.133MiB / 62.8GiB" + * Output after parsing: [ "in" => 2133000, "out" => 62800000000 ] + * + * @return array + */ + private function parseIOStats(string $stats) + { + $units = [ + 'B' => 1, + 'KB' => 1000, + 'MB' => 1000000, + 'GB' => 1000000000, + 'TB' => 1000000000000, + + 'KiB' => 1000, + 'MiB' => 1000000, + 'GiB' => 1000000000, + 'TiB' => 1000000000000, + ]; + + [$inStr, $outStr] = \explode(' / ', $stats); + + $inUnit = null; + $outUnit = null; + + foreach ($units as $unit => $value) { + if (\str_ends_with($inStr, $unit)) { + $inUnit = $unit; + } elseif (\str_ends_with($outStr, $unit)) { + $outUnit = $unit; + } + } + + $inMultiply = $inUnit === null ? 1 : $units[$inUnit]; + $outMultiply = $outUnit === null ? 1 : $units[$outUnit]; + + $inValue = \floatval(\rtrim($inStr, $inUnit)); + $outValue = \floatval(\rtrim($outStr, $outUnit)); + + $response = [ + 'in' => $inValue * $inMultiply, + 'out' => $outValue * $outMultiply, + ]; + + return $response; + } + + /** + * List Networks + */ + public function listNetworks(): array + { + $output = ''; + + $result = Console::execute('docker network ls --format "id={{.ID}}&name={{.Name}}&driver={{.Driver}}&scope={{.Scope}}"', '', $output); + + if ($result !== 0) { + throw new Orchestration("Docker Error: {$output}"); + } + + $list = []; + $stdoutArray = \explode("\n", $output); + + foreach ($stdoutArray as $value) { + $network = []; + + \parse_str($value, $network); + + if (isset($network['name'])) { + $parsedNetwork = new Network($network['name'], $network['id'], $network['driver'], $network['scope']); + + array_push($list, $parsedNetwork); + } + } + + return $list; + } + + /** + * Pull Image + */ + public function pull(string $image): bool + { + $output = ''; + + $result = Console::execute('docker pull '.$image, '', $output); + + return $result === 0; + } + + /** + * List Containers + * + * @param array $filters + * @return Container[] + */ + public function list(array $filters = []): array + { + $output = ''; + + $filterString = ''; + + foreach ($filters as $key => $value) { + $filterString = $filterString.' --filter "'.$key.'='.$value.'"'; + } + + $result = Console::execute('docker ps --all --no-trunc --format "id={{.ID}}&name={{.Names}}&status={{.Status}}&labels={{.Labels}}"'.$filterString, '', $output); + + if ($result !== 0 && $result !== -1) { + throw new Orchestration("Docker Error: {$output}"); + } + + $list = []; + $stdoutArray = \explode("\n", $output); + + foreach ($stdoutArray as $value) { + $container = []; + + \parse_str($value, $container); + + if (isset($container['name'])) { + $labelsParsed = []; + + foreach (\explode(',', $container['labels']) as $value) { + if (is_array($value)) { + $value = implode('', $value); + } + $value = \explode('=', $value); + + if (isset($value[0]) && isset($value[1])) { + $labelsParsed[$value[0]] = $value[1]; + } + } + + $parsedContainer = new Container($container['name'], $container['id'], $container['status'], $labelsParsed); + + array_push($list, $parsedContainer); + } + } + + return $list; + } + + /** + * Run Container + * + * Creates and runs a new container, On success it will return a string containing the container ID. + * On fail it will throw an exception. + * + * @param string[] $command + * @param string[] $volumes + * @param array $vars + */ + public function run(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 = '', + int $replicas = 1 + ): string { + $output = ''; + + foreach ($command as $key => $value) { + if (str_contains($value, ' ')) { + $command[$key] = "'".$value."'"; + } + } + + $labelString = ''; + + foreach ($labels as $labelKey => $label) { + // sanitize label + $label = str_replace("'", '', $label); + + if (str_contains($label, ' ')) { + $label = "'".$label."'"; + } + + $labelString = $labelString.' --label '.$labelKey.'='.$label; + } + + $parsedVariables = []; + + foreach ($vars as $key => $value) { + $key = $this->filterEnvKey($key); + + $value = \escapeshellarg((empty($value)) ? '' : $value); + $parsedVariables[$key] = "--env {$key}={$value}"; + } + + $volumeString = ''; + foreach ($volumes as $volume) { + $mount = explode(':', $volume); + $volumeString = $volumeString."--mount type=volume,source={$mount[0]},destination={$mount[1]}"; + + } + + $vars = $parsedVariables; + + $time = time(); + + $result = Console::execute('docker service create'. + ' -d'. + (empty($replicas) ? '' : " --replicas=\"{$replicas}\""). + (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')). + " --name={$name}". + " --label {$this->namespace}-type=runtime". + " --label {$this->namespace}-created={$time}". + (empty($volumeString) ? '' : ' '.$volumeString). + (empty($labelString) ? '' : ' '.$labelString). + (empty($workdir) ? '' : " --workdir {$workdir}"). + (empty($hostname) ? '' : " --hostname {$hostname}"). + (empty($vars) ? '' : ' '.\implode(' ', $vars)). + " {$image}". + (empty($command) ? '' : ' '.implode(' ', $command)), '', $output, 30); + + if ($result !== 0) { + throw new Orchestration("Docker Error: {$output}"); + } + + return rtrim($output); + } + + /** + * Execute Container + * Notice: Due to the limitations of Docker Swarm, this function is not feasible, as Docker Swarm does not + * have the capability to access and execute commands for containers across different nodes. + * + * @param string[] $command + * @param array $vars + */ + public function execute( + string $name, + array $command, + string &$output = '', + array $vars = [], + int $timeout = -1 + ): bool { + foreach ($command as $key => $value) { + if (str_contains($value, ' ')) { + $command[$key] = "'".$value."'"; + } + } + + $parsedVariables = []; + + foreach ($vars as $key => $value) { + $key = $this->filterEnvKey($key); + + $value = \escapeshellarg((empty($value)) ? '' : $value); + $parsedVariables[$key] = "--env {$key}={$value}"; + } + + $vars = $parsedVariables; + + $result = Console::execute('docker exec '.\implode(' ', $vars)." {$name} ".implode(' ', $command), '', $output, $timeout); + + if ($result !== 0) { + if ($result == 124) { + throw new Timeout('Command timed out'); + } else { + throw new Orchestration("Docker Error: {$output}"); + } + } + + return ! $result; + } + + /** + * Remove Container + */ + public function remove(string $name, bool $force = false): bool + { + $output = ''; + + $result = Console::execute("docker service rm {$name}", '', $output); + + if (! \str_starts_with($output, $name) || \str_contains($output, 'No such container')) { + throw new Orchestration("Docker Error: {$output}"); + } + + return ! $result; + } +} diff --git a/src/Orchestration/Orchestration.php b/src/Orchestration/Orchestration.php index 3de956e..3cb3bec 100644 --- a/src/Orchestration/Orchestration.php +++ b/src/Orchestration/Orchestration.php @@ -166,9 +166,10 @@ public function run( array $labels = [], string $hostname = '', bool $remove = false, - string $network = '' + string $network = '', + int $replicas = 1 ): string { - return $this->adapter->run($image, $name, $command, $entrypoint, $workdir, $volumes, $vars, $mountFolder, $labels, $hostname, $remove, $network); + return $this->adapter->run($image, $name, $command, $entrypoint, $workdir, $volumes, $vars, $mountFolder, $labels, $hostname, $remove, $network, $replicas); } /** diff --git a/tests/Orchestration/Adapter/DockerSwarmCLITest.php b/tests/Orchestration/Adapter/DockerSwarmCLITest.php new file mode 100644 index 0000000..b2822cb --- /dev/null +++ b/tests/Orchestration/Adapter/DockerSwarmCLITest.php @@ -0,0 +1,34 @@ +