%PDF- %PDF-
| Direktori : /www/varak.net/nextcloud.varak.net/apps/app_api/lib/DeployActions/ |
| Current File : //www/varak.net/nextcloud.varak.net/apps/app_api/lib/DeployActions/DockerActions.php |
<?php
declare(strict_types=1);
namespace OCA\AppAPI\DeployActions;
use Exception;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use OCA\AppAPI\AppInfo\Application;
use OCA\AppAPI\Db\DaemonConfig;
use OCA\AppAPI\Db\ExApp;
use OCA\AppAPI\Service\AppAPICommonService;
use OCA\AppAPI\Service\ExAppService;
use OCP\App\IAppManager;
use OCP\ICertificateManager;
use OCP\IConfig;
use OCP\IURLGenerator;
use OCP\Security\ICrypto;
use Psr\Log\LoggerInterface;
class DockerActions implements IDeployActions {
public const DOCKER_API_VERSION = 'v1.41';
public const EX_APP_CONTAINER_PREFIX = 'nc_app_';
public const APP_API_HAPROXY_USER = 'app_api_haproxy_user';
private Client $guzzleClient;
private bool $useSocket = false; # for `pullImage` function, to detect can be stream used or not.
public function __construct(
private readonly LoggerInterface $logger,
private readonly IConfig $config,
private readonly ICertificateManager $certificateManager,
private readonly IAppManager $appManager,
private readonly IURLGenerator $urlGenerator,
private readonly AppAPICommonService $service,
private readonly ExAppService $exAppService,
private readonly ICrypto $crypto,
) {
}
public function getAcceptsDeployId(): string {
return 'docker-install';
}
public function deployExApp(ExApp $exApp, DaemonConfig $daemonConfig, array $params = []): string {
if (!isset($params['image_params'])) {
return 'Missing image_params.';
}
if (!isset($params['container_params'])) {
return 'Missing container_params.';
}
$dockerUrl = $this->buildDockerUrl($daemonConfig);
$this->initGuzzleClient($daemonConfig);
$this->exAppService->setAppDeployProgress($exApp, 0);
$imageId = '';
$result = $this->pullImage($dockerUrl, $params['image_params'], $exApp, 0, 94, $daemonConfig, $imageId);
if ($result) {
return $result;
}
$this->exAppService->setAppDeployProgress($exApp, 95);
$containerInfo = $this->inspectContainer($dockerUrl, $this->buildExAppContainerName($params['container_params']['name']));
if (isset($containerInfo['Id'])) {
$result = $this->removeContainer($dockerUrl, $this->buildExAppContainerName($params['container_params']['name']));
if ($result) {
return $result;
}
}
$this->exAppService->setAppDeployProgress($exApp, 96);
$result = $this->createContainer($dockerUrl, $imageId, $params['container_params']);
if (isset($result['error'])) {
return $result['error'];
}
$this->exAppService->setAppDeployProgress($exApp, 97);
$result = $this->startContainer($dockerUrl, $this->buildExAppContainerName($params['container_params']['name']));
if (isset($result['error'])) {
return $result['error'];
}
$this->exAppService->setAppDeployProgress($exApp, 99);
if (!$this->waitTillContainerStart($this->buildExAppContainerName($exApp->getAppid()), $daemonConfig)) {
return 'container startup failed';
}
$this->exAppService->setAppDeployProgress($exApp, 100);
return '';
}
public function buildApiUrl(string $dockerUrl, string $route): string {
return sprintf('%s/%s/%s', $dockerUrl, self::DOCKER_API_VERSION, $route);
}
public function buildBaseImageName(array $imageParams): string {
return $imageParams['image_src'] . '/' .
$imageParams['image_name'] . ':' . $imageParams['image_tag'];
}
private function buildExtendedImageName(array $imageParams, DaemonConfig $daemonConfig): ?string {
if (empty($daemonConfig->getDeployConfig()['computeDevice']['id'])) {
return null;
}
return $imageParams['image_src'] . '/' .
$imageParams['image_name'] . '-' . $daemonConfig->getDeployConfig()['computeDevice']['id'] . ':' . $imageParams['image_tag'];
}
private function buildExtendedImageName2(array $imageParams, DaemonConfig $daemonConfig): ?string {
if (empty($daemonConfig->getDeployConfig()['computeDevice']['id'])) {
return null;
}
return $imageParams['image_src'] . '/' .
$imageParams['image_name'] . ':' . $imageParams['image_tag'] . '-' . $daemonConfig->getDeployConfig()['computeDevice']['id'];
}
public function createContainer(string $dockerUrl, string $imageId, array $params = []): array {
$createVolumeResult = $this->createVolume($dockerUrl, $this->buildExAppVolumeName($params['name']));
if (isset($createVolumeResult['error'])) {
return $createVolumeResult;
}
$containerParams = [
'Image' => $imageId,
'Hostname' => $params['hostname'],
'HostConfig' => [
'NetworkMode' => $params['net'],
'Mounts' => $this->buildDefaultExAppVolume($params['hostname']),
'RestartPolicy' => [
'Name' => $this->config->getAppValue(Application::APP_ID, 'container_restart_policy', 'unless-stopped'),
],
],
'Env' => $params['env'],
];
if (!in_array($params['net'], ['host', 'bridge'])) {
$networkingConfig = [
'EndpointsConfig' => [
$params['net'] => [
'Aliases' => [
$params['hostname']
],
],
],
];
$containerParams['NetworkingConfig'] = $networkingConfig;
}
if (isset($params['computeDevice'])) {
if ($params['computeDevice']['id'] === 'cuda') {
if (isset($params['deviceRequests'])) {
$containerParams['HostConfig']['DeviceRequests'] = $params['deviceRequests'];
} else {
$containerParams['HostConfig']['DeviceRequests'] = $this->buildDefaultGPUDeviceRequests();
}
}
if ($params['computeDevice']['id'] === 'rocm') {
if (isset($params['devices'])) {
$containerParams['HostConfig']['Devices'] = $params['devices'];
} else {
$containerParams['HostConfig']['Devices'] = $this->buildDevicesParams(['/dev/kfd', '/dev/dri']);
}
}
}
$url = $this->buildApiUrl($dockerUrl, sprintf('containers/create?name=%s', urlencode($this->buildExAppContainerName($params['name']))));
try {
$options['json'] = $containerParams;
$response = $this->guzzleClient->post($url, $options);
return json_decode((string) $response->getBody(), true);
} catch (GuzzleException $e) {
$this->logger->error('Failed to create container', ['exception' => $e]);
error_log($e->getMessage());
return ['error' => 'Failed to create container'];
}
}
public function startContainer(string $dockerUrl, string $containerId): array {
$url = $this->buildApiUrl($dockerUrl, sprintf('containers/%s/start', $containerId));
try {
$response = $this->guzzleClient->post($url);
return ['success' => $response->getStatusCode() === 204];
} catch (GuzzleException $e) {
$this->logger->error('Failed to start container', ['exception' => $e]);
error_log($e->getMessage());
return ['error' => 'Failed to start container'];
}
}
public function stopContainer(string $dockerUrl, string $containerId): array {
$url = $this->buildApiUrl($dockerUrl, sprintf('containers/%s/stop', $containerId));
try {
$response = $this->guzzleClient->post($url);
return ['success' => $response->getStatusCode() === 204];
} catch (GuzzleException $e) {
$this->logger->error('Failed to stop container', ['exception' => $e]);
error_log($e->getMessage());
return ['error' => 'Failed to stop container'];
}
}
public function removeContainer(string $dockerUrl, string $containerId): string {
$url = $this->buildApiUrl($dockerUrl, sprintf('containers/%s?force=true', $containerId));
try {
$response = $this->guzzleClient->delete($url);
$this->logger->debug(sprintf('StatusCode of container removal: %d', $response->getStatusCode()));
if ($response->getStatusCode() === 200 || $response->getStatusCode() === 204) {
return '';
}
} catch (GuzzleException $e) {
if ($e->getCode() === 409) { // "removal of container ... is already in progress"
return '';
}
$this->logger->error('Failed to remove container', ['exception' => $e]);
error_log($e->getMessage());
}
return sprintf('Failed to remove container: %s', $containerId);
}
public function pullImage(
string $dockerUrl, array $params, ExApp $exApp, int $startPercent, int $maxPercent, DaemonConfig $daemonConfig, string &$imageId
): string {
$imageId = $this->buildExtendedImageName2($params, $daemonConfig);
if ($imageId) {
try {
$r = $this->pullImageInternal($dockerUrl, $exApp, $startPercent, $maxPercent, $imageId);
if ($r === '') {
$this->logger->info(sprintf('Successfully pulled "extended" image in a new name format: %s', $imageId));
return '';
}
$this->logger->info(sprintf('Failed to pull "extended" image(%s): %s', $imageId, $r));
} catch (GuzzleException $e) {
$this->logger->info(
sprintf('Failed to pull "extended" image(%s), GuzzleException occur: %s', $imageId, $e->getMessage())
);
}
}
$imageId = $this->buildExtendedImageName($params, $daemonConfig); // TODO: remove with drop of NC29 support
if ($imageId) {
try {
$r = $this->pullImageInternal($dockerUrl, $exApp, $startPercent, $maxPercent, $imageId);
if ($r === '') {
$this->logger->info(sprintf('Successfully pulled "extended" image in an old name format: %s', $imageId));
return '';
}
$this->logger->info(sprintf('Failed to pull "extended" image(%s): %s', $imageId, $r));
} catch (GuzzleException $e) {
$this->logger->info(
sprintf('Failed to pull "extended" image(%s), GuzzleException occur: %s', $imageId, $e->getMessage())
);
}
}
$imageId = $this->buildBaseImageName($params);
$this->logger->info(sprintf('Pulling "base" image: %s', $imageId));
try {
$r = $this->pullImageInternal($dockerUrl, $exApp, $startPercent, $maxPercent, $imageId);
if ($r === '') {
$this->logger->info(sprintf('Image(%s) pulled successfully.', $imageId));
}
} catch (GuzzleException $e) {
$r = sprintf('Failed to pull image, GuzzleException occur: %s', $e->getMessage());
}
return $r;
}
/**
* @throws GuzzleException
*/
public function pullImageInternal(
string $dockerUrl, ExApp $exApp, int $startPercent, int $maxPercent, string $imageId
): string {
# docs: https://github.com/docker/compose/blob/main/pkg/compose/pull.go
$layerInProgress = ['preparing', 'waiting', 'pulling fs layer', 'download', 'extracting', 'verifying checksum'];
$layerFinished = ['already exists', 'pull complete'];
$disableProgressTracking = false;
$url = $this->buildApiUrl($dockerUrl, sprintf('images/create?fromImage=%s', urlencode($imageId)));
if ($this->useSocket) {
$response = $this->guzzleClient->post($url);
} else {
$response = $this->guzzleClient->post($url, ['stream' => true]);
}
if ($response->getStatusCode() !== 200) {
return sprintf('Pulling ExApp Image: %s return status code: %d', $imageId, $response->getStatusCode());
}
if ($this->useSocket) {
return '';
}
$lastPercent = $startPercent;
$layers = [];
$buffer = '';
$responseBody = $response->getBody();
while (!$responseBody->eof()) {
$buffer .= $responseBody->read(1024);
try {
while (($newlinePos = strpos($buffer, "\n")) !== false) {
$line = substr($buffer, 0, $newlinePos);
$buffer = substr($buffer, $newlinePos + 1);
$jsonLine = json_decode(trim($line));
if ($jsonLine) {
if (isset($jsonLine->id) && isset($jsonLine->status)) {
$layerId = $jsonLine->id;
$status = strtolower($jsonLine->status);
foreach ($layerInProgress as $substring) {
if (str_contains($status, $substring)) {
$layers[$layerId] = false;
break;
}
}
foreach ($layerFinished as $substring) {
if (str_contains($status, $substring)) {
$layers[$layerId] = true;
break;
}
}
}
} else {
$this->logger->warning(
sprintf("Progress tracking of image pulling(%s) disabled, error: %d, data: %s", $exApp->getAppid(), json_last_error(), $line)
);
$disableProgressTracking = true;
}
}
} catch (Exception $e) {
$this->logger->warning(
sprintf("Progress tracking of image pulling(%s) disabled, exception: %s", $exApp->getAppid(), $e->getMessage()), ['exception' => $e]
);
$disableProgressTracking = true;
}
if (!$disableProgressTracking) {
$completedLayers = count(array_filter($layers));
$totalLayers = count($layers);
$newLastPercent = intval($totalLayers > 0 ? ($completedLayers / $totalLayers) * ($maxPercent - $startPercent) : 0);
if ($lastPercent != $newLastPercent) {
$this->exAppService->setAppDeployProgress($exApp, $newLastPercent);
$lastPercent = $newLastPercent;
}
}
}
return '';
}
public function inspectContainer(string $dockerUrl, string $containerId): array {
$url = $this->buildApiUrl($dockerUrl, sprintf('containers/%s/json', $containerId));
try {
$response = $this->guzzleClient->get($url);
return json_decode((string) $response->getBody(), true);
} catch (GuzzleException $e) {
return ['error' => $e->getMessage(), 'exception' => $e];
}
}
/**
* @throws GuzzleException
*/
public function getContainerLogs(string $dockerUrl, string $containerId, string $tail = 'all'): string {
$url = $this->buildApiUrl(
$dockerUrl, sprintf('containers/%s/logs?stdout=true&stderr=true&tail=%s', $containerId, $tail)
);
$response = $this->guzzleClient->get($url);
return array_reduce($this->processDockerLogs((string) $response->getBody()), function ($carry, $logEntry) {
return $carry . $logEntry['content'];
}, '');
}
private function processDockerLogs($binaryData): array {
$offset = 0;
$length = strlen($binaryData);
$logs = [];
while ($offset < $length) {
if ($offset + 8 > $length) {
break; // Incomplete header, handle this case as needed
}
// Unpack the header
$header = unpack('C1type/C3skip/N1size', substr($binaryData, $offset, 8));
$offset += 8; // Move past the header
// Extract the log data based on the size from header
$logSize = $header['size'];
if ($offset + $logSize > $length) {
break; // Incomplete data, handle this case as needed
}
$logs[] = [
'stream_type' => $header['type'] === 1 ? 'stdout' : 'stderr',
'content' => substr($binaryData, $offset, $logSize)
];
$offset += $logSize; // Move to the next log entry
}
return $logs;
}
public function createVolume(string $dockerUrl, string $volume): array {
$url = $this->buildApiUrl($dockerUrl, 'volumes/create');
try {
$options['json'] = [
'name' => $volume,
];
$response = $this->guzzleClient->post($url, $options);
$result = json_decode((string) $response->getBody(), true);
if ($response->getStatusCode() === 201) {
return $result;
}
if ($response->getStatusCode() === 500) {
error_log($result['message']);
return ['error' => $result['message']];
}
} catch (GuzzleException $e) {
$this->logger->error('Failed to create volume', ['exception' => $e]);
error_log($e->getMessage());
}
return ['error' => 'Failed to create volume'];
}
public function removeVolume(string $dockerUrl, string $volume): array {
$url = $this->buildApiUrl($dockerUrl, sprintf('volumes/%s', $volume));
try {
$options['json'] = [
'name' => $volume,
];
$response = $this->guzzleClient->delete($url, $options);
if ($response->getStatusCode() === 204) {
return ['success' => true];
}
if ($response->getStatusCode() === 404) {
error_log('Volume not found.');
return ['error' => 'Volume not found.'];
}
if ($response->getStatusCode() === 409) {
error_log('Volume is in use.');
return ['error' => 'Volume is in use.'];
}
if ($response->getStatusCode() === 500) {
error_log('Something went wrong.');
return ['error' => 'Something went wrong.'];
}
} catch (GuzzleException $e) {
$this->logger->error('Failed to create volume', ['exception' => $e]);
error_log($e->getMessage());
}
return ['error' => 'Failed to remove volume'];
}
public function ping(string $dockerUrl): bool {
$url = $this->buildApiUrl($dockerUrl, '_ping');
try {
$response = $this->guzzleClient->get($url, [
'timeout' => 3,
]);
if ($response->getStatusCode() === 200) {
return true;
}
} catch (Exception $e) {
$this->logger->error('Could not connect to Docker daemon', ['exception' => $e]);
error_log($e->getMessage());
}
return false;
}
public function buildDeployParams(DaemonConfig $daemonConfig, array $appInfo): array {
$appId = (string) $appInfo['id'];
$externalApp = $appInfo['external-app'];
$deployConfig = $daemonConfig->getDeployConfig();
$deviceRequests = [];
$devices = [];
if (isset($deployConfig['computeDevice'])) {
if ($deployConfig['computeDevice']['id'] === 'cuda') {
$deviceRequests = $this->buildDefaultGPUDeviceRequests();
} elseif ($deployConfig['computeDevice']['id'] === 'rocm') {
$devices = $this->buildDevicesParams(['/dev/kfd', '/dev/dri']);
}
}
$storage = $this->buildDefaultExAppVolume($appId)[0]['Target'];
$imageParams = [
'image_src' => (string) ($externalApp['docker-install']['registry'] ?? 'docker.io'),
'image_name' => (string) ($externalApp['docker-install']['image'] ?? $appId),
'image_tag' => (string) ($externalApp['docker-install']['image-tag'] ?? 'latest'),
];
$envs = $this->buildDeployEnvs([
'appid' => $appId,
'name' => (string) $appInfo['name'],
'version' => (string) $appInfo['version'],
'host' => $this->service->buildExAppHost($deployConfig),
'port' => $appInfo['port'],
'storage' => $storage,
'secret' => $appInfo['secret'],
], $deployConfig);
$containerParams = [
'name' => $appId,
'hostname' => $appId,
'port' => $appInfo['port'],
'net' => $deployConfig['net'] ?? 'host',
'env' => $envs,
'computeDevice' => $deployConfig['computeDevice'] ?? null,
'devices' => $devices,
'deviceRequests' => $deviceRequests,
];
return [
'image_params' => $imageParams,
'container_params' => $containerParams,
];
}
public function buildDeployEnvs(array $params, array $deployConfig): array {
$autoEnvs = [
sprintf('AA_VERSION=%s', $this->appManager->getAppVersion(Application::APP_ID, false)),
sprintf('APP_SECRET=%s', $params['secret']),
sprintf('APP_ID=%s', $params['appid']),
sprintf('APP_DISPLAY_NAME=%s', $params['name']),
sprintf('APP_VERSION=%s', $params['version']),
sprintf('APP_HOST=%s', $params['host']),
sprintf('APP_PORT=%s', $params['port']),
sprintf('APP_PERSISTENT_STORAGE=%s', $params['storage']),
sprintf('NEXTCLOUD_URL=%s', $deployConfig['nextcloud_url'] ?? str_replace('https', 'http', $this->urlGenerator->getAbsoluteURL(''))),
];
// Always set COMPUTE_DEVICE=CPU|CUDA|ROCM
$autoEnvs[] = sprintf('COMPUTE_DEVICE=%s', strtoupper($deployConfig['computeDevice']['id']));
// Add required GPU runtime envs if daemon configured to use GPU
if (isset($deployConfig['computeDevice'])) {
if ($deployConfig['computeDevice']['id'] === 'cuda') {
$autoEnvs[] = sprintf('NVIDIA_VISIBLE_DEVICES=%s', 'all');
$autoEnvs[] = sprintf('NVIDIA_DRIVER_CAPABILITIES=%s', 'compute,utility');
}
}
return $autoEnvs;
}
public function resolveExAppUrl(
string $appId, string $protocol, string $host, array $deployConfig, int $port, array &$auth
): string {
$auth = [];
if (isset($deployConfig['additional_options']['OVERRIDE_APP_HOST']) &&
$deployConfig['additional_options']['OVERRIDE_APP_HOST'] !== ''
) {
$wideNetworkAddresses = ['0.0.0.0', '127.0.0.1', '::', '::1'];
if (!in_array($deployConfig['additional_options']['OVERRIDE_APP_HOST'], $wideNetworkAddresses)) {
return sprintf(
'%s://%s:%s', $protocol, $deployConfig['additional_options']['OVERRIDE_APP_HOST'], $port
);
}
}
$host = explode(':', $host)[0];
if ($protocol == 'https') {
$exAppHost = $host;
} elseif (isset($deployConfig['net']) && $deployConfig['net'] === 'host') {
$exAppHost = 'localhost';
} else {
$exAppHost = $appId;
}
if ($protocol == 'https' && isset($deployConfig['haproxy_password']) && $deployConfig['haproxy_password'] !== '') {
// we only set haproxy auth for remote installations, when all requests come through HaProxy.
$haproxyPass = $this->crypto->decrypt($deployConfig['haproxy_password']);
$auth = [self::APP_API_HAPROXY_USER, $haproxyPass];
}
return sprintf('%s://%s:%s', $protocol, $exAppHost, $port);
}
public function waitTillContainerStart(string $containerId, DaemonConfig $daemonConfig): bool {
$dockerUrl = $this->buildDockerUrl($daemonConfig);
$attempts = 0;
$totalAttempts = 90; // ~90 seconds for container to start
while ($attempts < $totalAttempts) {
$containerInfo = $this->inspectContainer($dockerUrl, $containerId);
if ($containerInfo['State']['Status'] === 'running') {
return true;
}
$attempts++;
sleep(1);
}
return false;
}
public function healthcheckContainer(string $containerId, DaemonConfig $daemonConfig, bool $waitForSuccess): bool {
$dockerUrl = $this->buildDockerUrl($daemonConfig);
$containerInfo = $this->inspectContainer($dockerUrl, $containerId);
if (!isset($containerInfo['State']['Health']['Status'])) {
return true; // container does not support Healthcheck
}
if (!$waitForSuccess) {
return $containerInfo['State']['Health']['Status'] === 'healthy';
}
$maxTotalAttempts = 900;
while ($maxTotalAttempts > 0) {
$containerInfo = $this->inspectContainer($dockerUrl, $containerId);
if ($containerInfo['State']['Health']['Status'] === 'healthy') {
return true;
}
if ($containerInfo['State']['Health']['Status'] === 'unhealthy') {
return false;
}
$maxTotalAttempts--;
sleep(1);
}
return false;
}
public function buildDockerUrl(DaemonConfig $daemonConfig): string {
if (file_exists($daemonConfig->getHost())) {
return 'http://localhost';
}
return $daemonConfig->getProtocol() . '://' . $daemonConfig->getHost();
}
public function initGuzzleClient(DaemonConfig $daemonConfig): void {
$guzzleParams = [];
if (file_exists($daemonConfig->getHost())) {
$guzzleParams = [
'curl' => [
CURLOPT_UNIX_SOCKET_PATH => $daemonConfig->getHost(),
],
];
$this->useSocket = true;
} elseif ($daemonConfig->getProtocol() === 'https') {
$guzzleParams = $this->setupCerts($guzzleParams);
}
if (isset($daemonConfig->getDeployConfig()['haproxy_password']) && $daemonConfig->getDeployConfig()['haproxy_password'] !== '') {
$haproxyPass = $this->crypto->decrypt($daemonConfig->getDeployConfig()['haproxy_password']);
$guzzleParams['auth'] = [self::APP_API_HAPROXY_USER, $haproxyPass];
}
$this->guzzleClient = new Client($guzzleParams);
}
private function setupCerts(array $guzzleParams): array {
if (!$this->config->getSystemValueBool('installed', false)) {
$certs = \OC::$SERVERROOT . '/resources/config/ca-bundle.crt';
} else {
$certs = $this->certificateManager->getAbsoluteBundlePath();
}
$guzzleParams['verify'] = $certs;
return $guzzleParams;
}
private function buildDevicesParams(array $devices): array {
return array_map(function (string $device) {
return ["PathOnHost" => $device, "PathInContainer" => $device, "CgroupPermissions" => "rwm"];
}, $devices);
}
/**
* Build default volume for ExApp.
* For now only one volume created per ExApp.
*/
private function buildDefaultExAppVolume(string $appId): array {
return [
[
'Type' => 'volume',
'Source' => $this->buildExAppVolumeName($appId),
'Target' => '/' . $this->buildExAppVolumeName($appId),
'ReadOnly' => false
],
];
}
public function buildExAppContainerName(string $appId): string {
return self::EX_APP_CONTAINER_PREFIX . $appId;
}
public function buildExAppVolumeName(string $appId): string {
return self::EX_APP_CONTAINER_PREFIX . $appId . '_data';
}
/**
* Return default GPU device requests for container.
*/
private function buildDefaultGPUDeviceRequests(): array {
return [
[
'Driver' => 'nvidia', // Currently only NVIDIA GPU vendor
'Count' => -1, // All available GPUs
'Capabilities' => [['compute', 'utility']], // Compute and utility capabilities
],
];
}
}