Skip to content

Commit bc7d61f

Browse files
committed
feat: custom Docker registries support (only for docker-install type)
Signed-off-by: Andrey Borysenko <andrey18106x@gmail.com>
1 parent b7a50db commit bc7d61f

10 files changed

Lines changed: 698 additions & 11 deletions

File tree

appinfo/info.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ to join us in shaping a more versatile, stable, and secure app landscape.
8787
<command>OCA\AppAPI\Command\Daemon\RegisterDaemon</command>
8888
<command>OCA\AppAPI\Command\Daemon\UnregisterDaemon</command>
8989
<command>OCA\AppAPI\Command\Daemon\ListDaemons</command>
90+
<command>OCA\AppAPI\Command\Daemon\AddRegistry</command>
91+
<command>OCA\AppAPI\Command\Daemon\RemoveRegistry</command>
92+
<command>OCA\AppAPI\Command\Daemon\ListRegistry</command>
9093
</commands>
9194
<settings>
9295
<admin>OCA\AppAPI\Settings\Admin</admin>

appinfo/routes.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@
5353
['name' => 'DaemonConfig#verifyDaemonConnection', 'url' => '/daemons/{name}/check', 'verb' => 'POST'],
5454
['name' => 'DaemonConfig#checkDaemonConnection', 'url' => '/daemons/verify_connection', 'verb' => 'POST'],
5555
['name' => 'DaemonConfig#updateDaemonConfig', 'url' => '/daemons/{name}', 'verb' => 'PUT'],
56+
['name' => 'DaemonConfig#addDaemonDockerRegistry', 'url' => '/daemons/{name}/add-registry', 'verb' => 'POST'],
57+
['name' => 'DaemonConfig#removeDaemonDockerRegistry', 'url' => '/daemons/{name}/remove-registry', 'verb' => 'POST'],
5658

5759
// Test Deploy actions
5860
['name' => 'DaemonConfig#startTestDeploy', 'url' => '/daemons/{name}/test_deploy', 'verb' => 'POST'],

lib/Command/Daemon/AddRegistry.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\AppAPI\Command\Daemon;
11+
12+
use OCA\AppAPI\Service\DaemonConfigService;
13+
14+
use Symfony\Component\Console\Command\Command;
15+
use Symfony\Component\Console\Input\InputArgument;
16+
use Symfony\Component\Console\Input\InputInterface;
17+
use Symfony\Component\Console\Input\InputOption;
18+
use Symfony\Component\Console\Output\OutputInterface;
19+
20+
class AddRegistry extends Command {
21+
22+
public function __construct(
23+
private readonly DaemonConfigService $daemonConfigService,
24+
) {
25+
parent::__construct();
26+
}
27+
28+
protected function configure(): void {
29+
$this->setName('app_api:daemon:registry:add');
30+
$this->setDescription('Add Deploy daemon Docker registry mapping');
31+
$this->addArgument('name', InputArgument::REQUIRED, 'Deploy daemon name');
32+
$this->addOption('registry-from', null, InputOption::VALUE_REQUIRED, 'Deploy daemon registry from URL');
33+
$this->addOption('registry-to', null, InputOption::VALUE_REQUIRED, 'Deploy daemon registry to URL');
34+
}
35+
36+
protected function execute(InputInterface $input, OutputInterface $output): int {
37+
$name = $input->getArgument('name');
38+
if (!$name) {
39+
$output->writeln('Daemon name is required.');
40+
return 1;
41+
}
42+
43+
$daemonConfig = $this->daemonConfigService->getDaemonConfigByName($name);
44+
if ($daemonConfig === null) {
45+
$output->writeln('Daemon config not found.');
46+
return 1;
47+
}
48+
49+
$registryFrom = $input->getOption('registry-from');
50+
$registryTo = $input->getOption('registry-to');
51+
if (!$registryFrom || !$registryTo) {
52+
$output->writeln('Registry URL pair (from -> to) is required.');
53+
return 1;
54+
}
55+
56+
$daemonConfig = $this->daemonConfigService->addDockerRegistry($daemonConfig, [
57+
'from' => $registryFrom,
58+
'to' => $registryTo,
59+
]);
60+
if (is_array($daemonConfig) && isset($daemonConfig['error'])) {
61+
$output->writeln(sprintf('Error adding Docker registry: %s', $daemonConfig['error']));
62+
return 1;
63+
}
64+
if ($daemonConfig === null) {
65+
$output->writeln('Failed to add registry mapping.');
66+
return 1;
67+
}
68+
69+
$output->writeln(sprintf('Added registry mapping "%s" -> "%s" to daemon "%s".', $registryFrom, $registryTo, $name));
70+
return 0;
71+
}
72+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\AppAPI\Command\Daemon;
11+
12+
use OCA\AppAPI\Service\DaemonConfigService;
13+
14+
use Symfony\Component\Console\Command\Command;
15+
use Symfony\Component\Console\Input\InputArgument;
16+
use Symfony\Component\Console\Input\InputInterface;
17+
use Symfony\Component\Console\Output\OutputInterface;
18+
19+
class ListRegistry extends Command {
20+
public function __construct(
21+
private readonly DaemonConfigService $daemonConfigService,
22+
) {
23+
parent::__construct();
24+
}
25+
26+
protected function configure(): void {
27+
$this->setName('app_api:daemon:registry:list');
28+
$this->setDescription('List configured Deploy daemon Docker registry mappings');
29+
$this->addArgument('name', InputArgument::REQUIRED, 'Deploy daemon name');
30+
}
31+
32+
protected function execute(InputInterface $input, OutputInterface $output): int {
33+
$name = $input->getArgument('name');
34+
if (!$name) {
35+
$output->writeln('Daemon name is required.');
36+
return 1;
37+
}
38+
39+
$daemonConfig = $this->daemonConfigService->getDaemonConfigByName($name);
40+
if ($daemonConfig === null) {
41+
$output->writeln('Daemon config not found.');
42+
return 1;
43+
}
44+
45+
if (!isset($daemonConfig->getDeployConfig()['registries']) || count($daemonConfig->getDeployConfig()['registries']) === 0) {
46+
$output->writeln(sprintf('No registries configured for daemon "%s".', $name));
47+
return 0;
48+
}
49+
50+
$registries = $daemonConfig->getDeployConfig()['registries'];
51+
$output->writeln(sprintf('Configured registries for daemon "%s":', $name));
52+
foreach ($registries as $registry) {
53+
$output->writeln(sprintf(' - %s -> %s', $registry['from'], $registry['to']));
54+
}
55+
56+
return 0;
57+
}
58+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\AppAPI\Command\Daemon;
11+
12+
use OCA\AppAPI\Service\DaemonConfigService;
13+
14+
use Symfony\Component\Console\Command\Command;
15+
use Symfony\Component\Console\Input\InputArgument;
16+
use Symfony\Component\Console\Input\InputInterface;
17+
use Symfony\Component\Console\Input\InputOption;
18+
use Symfony\Component\Console\Output\OutputInterface;
19+
20+
class RemoveRegistry extends Command {
21+
22+
public function __construct(
23+
private readonly DaemonConfigService $daemonConfigService,
24+
) {
25+
parent::__construct();
26+
}
27+
28+
protected function configure(): void {
29+
$this->setName('app_api:daemon:registry:remove');
30+
$this->setDescription('Remove Deploy daemon Docker registry mapping');
31+
$this->addArgument('name', InputArgument::REQUIRED, 'Deploy daemon name');
32+
$this->addOption('registry-from', null, InputOption::VALUE_REQUIRED, 'Deploy daemon registry from URL');
33+
$this->addOption('registry-to', null, InputOption::VALUE_REQUIRED, 'Deploy daemon registry to URL');
34+
}
35+
36+
protected function execute(InputInterface $input, OutputInterface $output): int {
37+
$name = $input->getArgument('name');
38+
if (!$name) {
39+
$output->writeln('Daemon name is required.');
40+
return 1;
41+
}
42+
43+
$daemonConfig = $this->daemonConfigService->getDaemonConfigByName($name);
44+
if ($daemonConfig === null) {
45+
$output->writeln('Daemon config not found.');
46+
return 1;
47+
}
48+
49+
if (!isset($daemonConfig->getDeployConfig()['registries']) || count($daemonConfig->getDeployConfig()['registries']) === 0) {
50+
$output->writeln(sprintf('No registries configured for daemon "%s".', $name));
51+
return 0;
52+
}
53+
54+
$registryFrom = $input->getOption('registry-from');
55+
$registryTo = $input->getOption('registry-to');
56+
if (!$registryFrom || !$registryTo) {
57+
$output->writeln('Registry URL pair (from -> to) is required.');
58+
return 1;
59+
}
60+
61+
$daemonConfig = $this->daemonConfigService->removeDockerRegistry($daemonConfig, [
62+
'from' => $registryFrom,
63+
'to' => $registryTo,
64+
]);
65+
if (is_array($daemonConfig) && isset($daemonConfig['error'])) {
66+
$output->writeln(sprintf('Error adding Docker registry: %s', $daemonConfig['error']));
67+
return 1;
68+
}
69+
if ($daemonConfig === null) {
70+
$output->writeln('Failed to remove registry mapping.');
71+
return 1;
72+
}
73+
74+
$output->writeln(sprintf('Removed registry mapping from "%s" to "%s" for daemon "%s".', $registryFrom, $registryTo, $name));
75+
return 0;
76+
}
77+
}

lib/Controller/DaemonConfigController.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,4 +219,30 @@ public function getTestDeployStatus(string $name): Response {
219219
}
220220
return new JSONResponse($exApp->getStatus());
221221
}
222+
223+
#[PasswordConfirmationRequired]
224+
public function addDaemonDockerRegistry(string $name, array $registryMap): JSONResponse {
225+
$daemonConfig = $this->daemonConfigService->getDaemonConfigByName($name);
226+
if (!$daemonConfig) {
227+
return new JSONResponse(['error' => $this->l10n->t('Daemon config not found')], Http::STATUS_NOT_FOUND);
228+
}
229+
$daemonConfig = $this->daemonConfigService->addDockerRegistry($daemonConfig, $registryMap);
230+
if ($daemonConfig === null) {
231+
return new JSONResponse(['error' => $this->l10n->t('Error adding Docker registry')], Http::STATUS_INTERNAL_SERVER_ERROR);
232+
}
233+
return new JSONResponse($daemonConfig, Http::STATUS_OK);
234+
}
235+
236+
#[PasswordConfirmationRequired]
237+
public function removeDaemonDockerRegistry(string $name, array $registryMap): JSONResponse {
238+
$daemonConfig = $this->daemonConfigService->getDaemonConfigByName($name);
239+
if (!$daemonConfig) {
240+
return new JSONResponse(['error' => $this->l10n->t('Daemon config not found')], Http::STATUS_NOT_FOUND);
241+
}
242+
$daemonConfig = $this->daemonConfigService->removeDockerRegistry($daemonConfig, $registryMap);
243+
if ($daemonConfig === null) {
244+
return new JSONResponse(['error' => $this->l10n->t('Error removing Docker registry')], Http::STATUS_INTERNAL_SERVER_ERROR);
245+
}
246+
return new JSONResponse($daemonConfig, Http::STATUS_OK);
247+
}
222248
}

lib/DeployActions/DockerActions.php

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -307,19 +307,62 @@ public function buildApiUrl(string $dockerUrl, string $route): string {
307307
return sprintf('%s/%s/%s', $dockerUrl, self::DOCKER_API_VERSION, $route);
308308
}
309309

310-
public function buildBaseImageName(array $imageParams): string {
310+
public function buildBaseImageName(array $imageParams, DaemonConfig $daemonConfig): string {
311+
$deployConfig = $daemonConfig->getDeployConfig();
312+
if (isset($deployConfig['registries'])) { // custom Docker registry, overrides ExApp's image_src
313+
foreach ($deployConfig['registries'] as $registry) {
314+
if ($registry['from'] === $imageParams['image_src'] && $registry['to'] !== 'local') { // local target skips image pull, imageId should be unchanged
315+
$imageParams['image_src'] = rtrim($registry['to'], '/');
316+
break;
317+
}
318+
}
319+
}
311320
return $imageParams['image_src'] . '/' .
312321
$imageParams['image_name'] . ':' . $imageParams['image_tag'];
313322
}
314323

315324
private function buildExtendedImageName(array $imageParams, DaemonConfig $daemonConfig): ?string {
316-
if (empty($daemonConfig->getDeployConfig()['computeDevice']['id'])) {
325+
$deployConfig = $daemonConfig->getDeployConfig();
326+
if (empty($deployConfig['computeDevice']['id'])) {
317327
return null;
318328
}
329+
if (isset($deployConfig['registries'])) { // custom Docker registry, overrides ExApp's image_src
330+
foreach ($deployConfig['registries'] as $registry) {
331+
if ($registry['from'] === $imageParams['image_src'] && $registry['to'] !== 'local') { // local target skips image pull, imageId should be unchanged
332+
$imageParams['image_src'] = rtrim($registry['to'], '/');
333+
break;
334+
}
335+
}
336+
}
319337
return $imageParams['image_src'] . '/' .
320338
$imageParams['image_name'] . ':' . $imageParams['image_tag'] . '-' . $daemonConfig->getDeployConfig()['computeDevice']['id'];
321339
}
322340

341+
private function shouldPullImage(array $imageParams, DaemonConfig $daemonConfig): bool {
342+
$deployConfig = $daemonConfig->getDeployConfig();
343+
if (isset($deployConfig['registries'])) { // custom Docker registry, overrides ExApp's image_src
344+
foreach ($deployConfig['registries'] as $registry) {
345+
if ($registry['from'] === $imageParams['image_src'] && $registry['to'] === 'local') { // local target skips image pull, imageId should be unchanged
346+
return false;
347+
}
348+
}
349+
}
350+
return true;
351+
}
352+
353+
public function imageExists(string $dockerUrl, string $imageId): bool {
354+
$url = $this->buildApiUrl($dockerUrl, sprintf('images/%s/json', $imageId));
355+
try {
356+
$response = $this->guzzleClient->get($url);
357+
return $response->getStatusCode() === 200;
358+
} catch (GuzzleException $e) {
359+
if ($e->getCode() !== 404) {
360+
$this->logger->error('Failed to check image existence', ['exception' => $e]);
361+
}
362+
return false;
363+
}
364+
}
365+
323366
public function createContainer(string $dockerUrl, string $imageId, DaemonConfig $daemonConfig, array $params = []): array {
324367
$createVolumeResult = $this->createVolume($dockerUrl, $this->buildExAppVolumeName($params['name']));
325368
if (isset($createVolumeResult['error'])) {
@@ -459,28 +502,48 @@ public function removeContainer(string $dockerUrl, string $containerId): string
459502
public function pullImage(
460503
string $dockerUrl, array $params, ExApp $exApp, int $startPercent, int $maxPercent, DaemonConfig $daemonConfig, string &$imageId
461504
): string {
505+
$shouldPull = $this->shouldPullImage($params, $daemonConfig);
462506
$urlToLog = $this->useSocket ? $this->socketAddress : $dockerUrl;
463507
$imageId = $this->buildExtendedImageName($params, $daemonConfig);
508+
464509
if ($imageId) {
465510
try {
466-
$r = $this->pullImageInternal($dockerUrl, $exApp, $startPercent, $maxPercent, $imageId);
467-
if ($r === '') {
468-
$this->logger->info(sprintf('Successfully pulled "extended" image: %s', $imageId));
511+
if ($shouldPull) {
512+
$r = $this->pullImageInternal($dockerUrl, $exApp, $startPercent, $maxPercent, $imageId);
513+
if ($r === '') {
514+
$this->logger->info(sprintf('Successfully pulled "extended" image: %s', $imageId));
515+
return '';
516+
}
517+
$this->logger->info(sprintf('Failed to pull "extended" image(%s): %s', $imageId, $r));
518+
} elseif ($this->imageExists($dockerUrl, $imageId)) {
519+
$this->logger->info('Daemon registry mapping set to "local", skipping image pull');
520+
$this->exAppService->setAppDeployProgress($exApp, $maxPercent);
469521
return '';
522+
} else {
523+
$this->logger->info(sprintf('Image(%s) not found, but daemon registry mapping set to "local", trying base image', $imageId));
470524
}
471-
$this->logger->info(sprintf('Failed to pull "extended" image(%s): %s', $imageId, $r));
472525
} catch (GuzzleException $e) {
473526
$this->logger->info(
474527
sprintf('Failed to pull "extended" image via "%s", GuzzleException occur: %s', $urlToLog, $e->getMessage())
475528
);
476529
}
477530
}
478-
$imageId = $this->buildBaseImageName($params);
479-
$this->logger->info(sprintf('Pulling "base" image: %s', $imageId));
531+
532+
$imageId = $this->buildBaseImageName($params, $daemonConfig);
480533
try {
481-
$r = $this->pullImageInternal($dockerUrl, $exApp, $startPercent, $maxPercent, $imageId);
482-
if ($r === '') {
483-
$this->logger->info(sprintf('Image(%s) pulled successfully.', $imageId));
534+
if ($shouldPull) {
535+
$this->logger->info(sprintf('Pulling "base" image: %s', $imageId));
536+
$r = $this->pullImageInternal($dockerUrl, $exApp, $startPercent, $maxPercent, $imageId);
537+
if ($r === '') {
538+
$this->logger->info(sprintf('Image(%s) pulled successfully.', $imageId));
539+
}
540+
} elseif ($this->imageExists($dockerUrl, $imageId)) {
541+
$this->logger->info('Daemon registry mapping set to "local", skipping image pull');
542+
$this->exAppService->setAppDeployProgress($exApp, $maxPercent);
543+
return '';
544+
} else {
545+
$this->logger->warning(sprintf('Image(%s) not found, but daemon registry mapping set to "local", skipping image pull', $imageId));
546+
return '';
484547
}
485548
} catch (GuzzleException $e) {
486549
$r = sprintf('Failed to pull image via "%s", GuzzleException occur: %s', $urlToLog, $e->getMessage());

0 commit comments

Comments
 (0)