diff --git a/lib/DeployActions/DockerActions.php b/lib/DeployActions/DockerActions.php index 9c3724301..86120e27d 100644 --- a/lib/DeployActions/DockerActions.php +++ b/lib/DeployActions/DockerActions.php @@ -17,6 +17,7 @@ use OCA\AppAPI\Db\ExApp; use OCA\AppAPI\Service\AppAPICommonService; +use OCA\AppAPI\Service\DaemonConfigService; use OCA\AppAPI\Service\ExAppDeployOptionsService; use OCA\AppAPI\Service\ExAppService; use OCA\AppAPI\Service\HarpService; @@ -433,16 +434,7 @@ public function buildApiUrl(string $dockerUrl, string $route): string { } public function buildBaseImageName(array $imageParams, DaemonConfig $daemonConfig): string { - $deployConfig = $daemonConfig->getDeployConfig(); - if (isset($deployConfig['registries'])) { // custom Docker registry, overrides ExApp's image_src - foreach ($deployConfig['registries'] as $registry) { - if ($registry['from'] === $imageParams['image_src'] && $registry['to'] !== 'local') { // local target skips image pull, imageId should be unchanged - $imageParams['image_src'] = rtrim($registry['to'], '/'); - break; - } - } - } - return $imageParams['image_src'] . '/' + return DaemonConfigService::resolveImageRegistry($daemonConfig->getDeployConfig(), $imageParams['image_src']) . '/' . $imageParams['image_name'] . ':' . $imageParams['image_tag']; } @@ -451,28 +443,13 @@ private function buildExtendedImageName(array $imageParams, DaemonConfig $daemon if (empty($deployConfig['computeDevice']['id'])) { return null; } - if (isset($deployConfig['registries'])) { // custom Docker registry, overrides ExApp's image_src - foreach ($deployConfig['registries'] as $registry) { - if ($registry['from'] === $imageParams['image_src'] && $registry['to'] !== 'local') { // local target skips image pull, imageId should be unchanged - $imageParams['image_src'] = rtrim($registry['to'], '/'); - break; - } - } - } - return $imageParams['image_src'] . '/' - . $imageParams['image_name'] . ':' . $imageParams['image_tag'] . '-' . $daemonConfig->getDeployConfig()['computeDevice']['id']; + return DaemonConfigService::resolveImageRegistry($deployConfig, $imageParams['image_src']) . '/' + . $imageParams['image_name'] . ':' . $imageParams['image_tag'] . '-' . $deployConfig['computeDevice']['id']; } private function shouldPullImage(array $imageParams, DaemonConfig $daemonConfig): bool { - $deployConfig = $daemonConfig->getDeployConfig(); - if (isset($deployConfig['registries'])) { // custom Docker registry, overrides ExApp's image_src - foreach ($deployConfig['registries'] as $registry) { - if ($registry['from'] === $imageParams['image_src'] && $registry['to'] === 'local') { // local target skips image pull, imageId should be unchanged - return false; - } - } - } - return true; + return DaemonConfigService::resolveRegistryTarget($daemonConfig->getDeployConfig(), $imageParams['image_src']) + !== DaemonConfigService::LOCAL_REGISTRY; } public function imageExists(string $dockerUrl, string $imageId): bool { diff --git a/lib/DeployActions/KubernetesActions.php b/lib/DeployActions/KubernetesActions.php index bf7c6eb21..e278d5ada 100644 --- a/lib/DeployActions/KubernetesActions.php +++ b/lib/DeployActions/KubernetesActions.php @@ -16,6 +16,7 @@ use OCA\AppAPI\Db\DaemonConfig; use OCA\AppAPI\Db\ExApp; use OCA\AppAPI\Service\AppAPICommonService; +use OCA\AppAPI\Service\DaemonConfigService; use OCA\AppAPI\Service\ExAppDeployOptionsService; use OCA\AppAPI\Service\ExAppService; use OCP\App\IAppManager; @@ -85,13 +86,13 @@ public function deployExApp(ExApp $exApp, DaemonConfig $daemonConfig, array $par $roles = $params['k8s_service_roles'] ?? []; if (empty($roles)) { - return $this->deploySingleExApp($exApp, $harpUrl, $params); + return $this->deploySingleExApp($exApp, $daemonConfig, $harpUrl, $params); } - return $this->deployMultiRoleExApp($exApp, $harpUrl, $params, $roles); + return $this->deployMultiRoleExApp($exApp, $daemonConfig, $harpUrl, $params, $roles); } - private function deploySingleExApp(ExApp $exApp, string $harpUrl, array $params): string { + private function deploySingleExApp(ExApp $exApp, DaemonConfig $daemonConfig, string $harpUrl, array $params): string { $exAppName = $params['container_params']['name']; $instanceId = ''; @@ -112,7 +113,7 @@ private function deploySingleExApp(ExApp $exApp, string $harpUrl, array $params) $this->exAppService->setAppDeployProgress($exApp, 50); - $error = $this->createExApp($harpUrl, $exAppName, $instanceId, $params); + $error = $this->createExApp($daemonConfig, $harpUrl, $exAppName, $instanceId, $params); if ($error) { return $error; } @@ -146,7 +147,7 @@ private function deploySingleExApp(ExApp $exApp, string $harpUrl, array $params) * * @param array $roles Array of role definitions from k8s-service-roles */ - private function deployMultiRoleExApp(ExApp $exApp, string $harpUrl, array $params, array $roles): string { + private function deployMultiRoleExApp(ExApp $exApp, DaemonConfig $daemonConfig, string $harpUrl, array $params, array $roles): string { $exAppName = $params['container_params']['name']; $instanceId = ''; $totalRoles = count($roles); @@ -184,7 +185,7 @@ private function deployMultiRoleExApp(ExApp $exApp, string $harpUrl, array $para $this->logger->info(sprintf('Creating K8s deployment for ExApp "%s" role "%s" (%d/%d).', $exAppName, $roleSuffix, $roleIndex + 1, $totalRoles)); - $error = $this->createExApp($harpUrl, $exAppName, $instanceId, $roleParams, $roleSuffix); + $error = $this->createExApp($daemonConfig, $harpUrl, $exAppName, $instanceId, $roleParams, $roleSuffix); if ($error) { $this->rollbackDeployedRoles($harpUrl, $exAppName, $deployedRoles); return $error; @@ -342,14 +343,17 @@ private function checkExists(string $harpUrl, string $exAppName, string $instanc } } - private function createExApp(string $harpUrl, string $exAppName, string $instanceId, array $params, string $roleSuffix = ''): string { + private function createExApp(DaemonConfig $daemonConfig, string $harpUrl, string $exAppName, string $instanceId, array $params, string $roleSuffix = ''): string { $computeDevice = 'cpu'; if (isset($params['container_params']['computeDevice']['id'])) { $computeDevice = $params['container_params']['computeDevice']['id']; } $createPayload = $this->buildNamePayload($exAppName, $instanceId, $roleSuffix); - $createPayload['image'] = $this->buildImageName($params['image_params']); + $createPayload['image'] = $this->buildImageName($params['image_params'], $daemonConfig); + if (DaemonConfigService::resolveRegistryTarget($daemonConfig->getDeployConfig(), $params['image_params']['image_src']) === DaemonConfigService::LOCAL_REGISTRY) { + $createPayload['image_pull_policy'] = 'Never'; // HaRP without support for this field keeps IfNotPresent + } $createPayload['environment_variables'] = $params['container_params']['env'] ?? []; $createPayload['compute_device'] = $computeDevice; @@ -767,8 +771,9 @@ public function buildHarpK8sUrl(DaemonConfig $daemonConfig): string { return rtrim($url, '/') . '/exapps/app_api/k8s'; } - private function buildImageName(array $imageParams): string { - return $imageParams['image_src'] . '/' . $imageParams['image_name'] . ':' . $imageParams['image_tag']; + public function buildImageName(array $imageParams, DaemonConfig $daemonConfig): string { + return DaemonConfigService::resolveImageRegistry($daemonConfig->getDeployConfig(), $imageParams['image_src']) . '/' + . $imageParams['image_name'] . ':' . $imageParams['image_tag']; } public function initGuzzleClient(DaemonConfig $daemonConfig): void { diff --git a/lib/Service/DaemonConfigService.php b/lib/Service/DaemonConfigService.php index 53a11eabe..7050be19b 100644 --- a/lib/Service/DaemonConfigService.php +++ b/lib/Service/DaemonConfigService.php @@ -25,6 +25,9 @@ * Daemon configuration (daemons) */ readonly class DaemonConfigService { + /** Registry mapping target that keeps the image name and tells the daemon not to pull the image. */ + public const LOCAL_REGISTRY = 'local'; + public function __construct( private LoggerInterface $logger, private DaemonConfigMapper $mapper, @@ -174,30 +177,31 @@ public function updateDaemonConfig(DaemonConfig $daemonConfig): ?DaemonConfig { public function addDockerRegistry(DaemonConfig $daemonConfig, array $registryMap): DaemonConfig|array|null { try { + $from = $registryMap['from'] ?? null; + $to = $registryMap['to'] ?? null; + if (!is_string($from) || !is_string($to)) { + return ['error' => 'The source and target registry cannot be empty']; + } + $from = rtrim(trim($from), '/'); + $to = rtrim(trim($to), '/'); + if ($from === '' || $to === '') { + return ['error' => 'The source and target registry cannot be empty']; + } + $deployConfig = $daemonConfig->getDeployConfig(); if (!isset($deployConfig['registries'])) { $deployConfig['registries'] = []; } - $fromExists = false; - foreach ($deployConfig['registries'] as $registry) { - if ($registry['from'] === $registryMap['from']) { - $fromExists = true; - break; - } - } - if ($fromExists) { - return ['error' => sprintf('This Docker registry map from "%s" already exists', $registryMap['from'])]; + if (self::resolveRegistryTarget($deployConfig, $from) !== null) { + return ['error' => sprintf('This Docker registry map from "%s" already exists', $from)]; } - if ($registryMap['from'] === $registryMap['to']) { + if ($from === $to) { return ['error' => 'The source and target registry cannot be the same']; } - if (empty($registryMap['from']) || empty($registryMap['to'])) { - return ['error' => 'The source and target registry cannot be empty']; - } - $deployConfig['registries'][] = $registryMap; + $deployConfig['registries'] = [...array_values($deployConfig['registries']), ['from' => $from, 'to' => $to]]; $daemonConfig->setDeployConfig($deployConfig); return $this->mapper->update($daemonConfig); @@ -211,12 +215,12 @@ public function removeDockerRegistry(DaemonConfig $daemonConfig, array $registry try { $deployConfig = $daemonConfig->getDeployConfig(); - if (!in_array($registryMap, $deployConfig['registries'])) { + if (!in_array($registryMap, $deployConfig['registries'] ?? [])) { return ['error' => 'This Docker registry map does not exist']; } - $deployConfig['registries'] = array_filter($deployConfig['registries'], function ($registry) use ($registryMap) { - return !($registry['from'] === $registryMap['from'] && $registry['to'] === $registryMap['to']); - }); + $deployConfig['registries'] = array_values(array_filter($deployConfig['registries'], function ($registry) use ($registryMap) { + return !(($registry['from'] ?? null) === ($registryMap['from'] ?? null) && ($registry['to'] ?? null) === ($registryMap['to'] ?? null)); + })); $daemonConfig->setDeployConfig($deployConfig); return $this->mapper->update($daemonConfig); @@ -225,4 +229,33 @@ public function removeDockerRegistry(DaemonConfig $daemonConfig, array $registry return null; } } + + /** + * Effective target of the daemon's registry mappings for the registry an ExApp image comes from: + * the registry to take the image from instead, LOCAL_REGISTRY, or null when no usable mapping exists. + * + * The first mapping of the registry with a usable target wins. A target is usable when it is a non-empty + * string once surrounding whitespace and trailing slashes are dropped, so "local/" is LOCAL_REGISTRY as well. + */ + public static function resolveRegistryTarget(array $deployConfig, string $imageRegistry): ?string { + foreach ($deployConfig['registries'] ?? [] as $registry) { + $target = $registry['to'] ?? null; + if (($registry['from'] ?? null) !== $imageRegistry || !is_string($target)) { + continue; + } + $target = rtrim(trim($target), '/'); + if ($target !== '') { + return $target; + } + } + return null; + } + + /** + * Registry to take an ExApp image from, after the registry mappings of the daemon are applied. + */ + public static function resolveImageRegistry(array $deployConfig, string $imageRegistry): string { + $target = self::resolveRegistryTarget($deployConfig, $imageRegistry); + return $target === null || $target === self::LOCAL_REGISTRY ? $imageRegistry : $target; + } } diff --git a/tests/php/DeployActions/DockerActionsTest.php b/tests/php/DeployActions/DockerActionsTest.php index 0f81aaeb6..1aa3d6665 100644 --- a/tests/php/DeployActions/DockerActionsTest.php +++ b/tests/php/DeployActions/DockerActionsTest.php @@ -10,6 +10,7 @@ namespace OCA\AppAPI\Tests\php\DeployActions; use OCA\AppAPI\AppInfo\Application; +use OCA\AppAPI\Db\DaemonConfig; use OCA\AppAPI\DeployActions\DockerActions; use OCA\AppAPI\Service\AppAPICommonService; use OCA\AppAPI\Service\ExAppDeployOptionsService; @@ -21,9 +22,11 @@ use OCP\ITempManager; use OCP\IURLGenerator; use OCP\Security\ICrypto; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +use ReflectionMethod; class DockerActionsTest extends TestCase { private DockerActions $dockerActions; @@ -99,4 +102,75 @@ public function testBuildApiUrlWithContainerRoute(): void { self::assertSame('http://localhost:8780/v1.41/containers/nc_app_test/json', $url); } + + public static function imageNameProvider(): array { + return [ + 'no registry mappings' => [[], 'ghcr.io'], + 'mapped registry' => [[['from' => 'ghcr.io', 'to' => 'registry.example.com/']], 'registry.example.com'], + 'mapping of another registry' => [[['from' => 'docker.io', 'to' => 'registry.example.com']], 'ghcr.io'], + 'local keeps the image name' => [[['from' => 'ghcr.io', 'to' => 'local']], 'ghcr.io'], + ]; + } + + #[DataProvider('imageNameProvider')] + public function testBuildBaseImageName(array $registries, string $expectedRegistry): void { + $imageParams = ['image_src' => 'ghcr.io', 'image_name' => 'nextcloud/test-deploy', 'image_tag' => 'release']; + $daemonConfig = new DaemonConfig(['deploy_config' => ['registries' => $registries]]); + + self::assertSame( + $expectedRegistry . '/nextcloud/test-deploy:release', + $this->dockerActions->buildBaseImageName($imageParams, $daemonConfig), + ); + } + + #[DataProvider('imageNameProvider')] + public function testBuildExtendedImageName(array $registries, string $expectedRegistry): void { + $imageParams = ['image_src' => 'ghcr.io', 'image_name' => 'nextcloud/test-deploy', 'image_tag' => 'release']; + $daemonConfig = new DaemonConfig([ + 'deploy_config' => ['registries' => $registries, 'computeDevice' => ['id' => 'cuda']], + ]); + + $buildExtendedImageName = new ReflectionMethod($this->dockerActions, 'buildExtendedImageName'); + self::assertSame( + $expectedRegistry . '/nextcloud/test-deploy:release-cuda', + $buildExtendedImageName->invoke($this->dockerActions, $imageParams, $daemonConfig), + ); + } + + public function testBuildExtendedImageNameWithoutComputeDevice(): void { + $imageParams = ['image_src' => 'ghcr.io', 'image_name' => 'nextcloud/test-deploy', 'image_tag' => 'release']; + $daemonConfig = new DaemonConfig(['deploy_config' => ['registries' => []]]); + + $buildExtendedImageName = new ReflectionMethod($this->dockerActions, 'buildExtendedImageName'); + self::assertNull($buildExtendedImageName->invoke($this->dockerActions, $imageParams, $daemonConfig)); + } + + public static function shouldPullImageProvider(): array { + $local = ['from' => 'ghcr.io', 'to' => 'local']; + return [ + 'no registry mappings' => [[], true], + 'mapped to a mirror' => [[['from' => 'ghcr.io', 'to' => 'registry.example.com']], true], + 'mapped to local' => [[$local], false], + 'local mapping of another registry' => [[['from' => 'docker.io', 'to' => 'local']], true], + 'malformed entries are ignored' => [['ghcr.io', ['from' => 'ghcr.io'], ['to' => 'local'], $local], false], + 'legacy duplicate source: the first entry wins' => [ + [$local, ['from' => 'ghcr.io', 'to' => 'registry.example.com']], + false, + ], + 'local with a trailing slash' => [[['from' => 'ghcr.io', 'to' => 'local/']], false], + 'legacy duplicate source, mirror first: the mirror is pulled' => [ + [['from' => 'ghcr.io', 'to' => 'registry.example.com'], $local], + true, + ], + ]; + } + + #[DataProvider('shouldPullImageProvider')] + public function testShouldPullImage(array $registries, bool $expected): void { + $imageParams = ['image_src' => 'ghcr.io', 'image_name' => 'nextcloud/test-deploy', 'image_tag' => 'release']; + $daemonConfig = new DaemonConfig(['deploy_config' => ['registries' => $registries]]); + + $shouldPullImage = new ReflectionMethod($this->dockerActions, 'shouldPullImage'); + self::assertSame($expected, $shouldPullImage->invoke($this->dockerActions, $imageParams, $daemonConfig)); + } } diff --git a/tests/php/DeployActions/KubernetesActionsTest.php b/tests/php/DeployActions/KubernetesActionsTest.php new file mode 100644 index 000000000..ed5d5d70c --- /dev/null +++ b/tests/php/DeployActions/KubernetesActionsTest.php @@ -0,0 +1,202 @@ + 'ghcr.io', + 'image_name' => 'nextcloud/test-deploy', + 'image_tag' => 'release', + ]; + + private KubernetesActions $kubernetesActions; + + protected function setUp(): void { + parent::setUp(); + + $this->kubernetesActions = new KubernetesActions( + $this->createMock(LoggerInterface::class), + $this->createMock(IConfig::class), + $this->createMock(ICertificateManager::class), + $this->createMock(IAppManager::class), + $this->createMock(IURLGenerator::class), + $this->createMock(AppAPICommonService::class), + $this->createMock(ExAppService::class), + $this->createMock(ICrypto::class), + $this->createMock(ExAppDeployOptionsService::class), + ); + } + + public static function buildImageNameProvider(): array { + return [ + 'no registry mappings' => [[], 'ghcr.io/nextcloud/test-deploy:release'], + 'mapped registry' => [ + [['from' => 'ghcr.io', 'to' => 'registry.example.com']], + 'registry.example.com/nextcloud/test-deploy:release', + ], + 'mapping of another registry' => [ + [['from' => 'docker.io', 'to' => 'registry.example.com']], + 'ghcr.io/nextcloud/test-deploy:release', + ], + 'local keeps the image name' => [ + [['from' => 'ghcr.io', 'to' => 'local']], + 'ghcr.io/nextcloud/test-deploy:release', + ], + ]; + } + + #[DataProvider('buildImageNameProvider')] + public function testBuildImageName(array $registries, string $expected): void { + $daemonConfig = new DaemonConfig([ + 'accepts_deploy_id' => KubernetesActions::DEPLOY_ID, + 'deploy_config' => ['registries' => $registries], + ]); + + self::assertSame($expected, $this->kubernetesActions->buildImageName(self::IMAGE_PARAMS, $daemonConfig)); + } + + public function testBuildImageNameWithoutRegistriesInDeployConfig(): void { + $daemonConfig = new DaemonConfig(['deploy_config' => ['kubernetes' => ['expose_type' => 'clusterip']]]); + + self::assertSame( + 'ghcr.io/nextcloud/test-deploy:release', + $this->kubernetesActions->buildImageName(self::IMAGE_PARAMS, $daemonConfig), + ); + } + + public function testDeployExAppSendsTheMappedImageToHarp(): void { + $createPayloads = $this->deployWithMappedRegistry([]); + + self::assertCount(1, $createPayloads); + self::assertSame('registry.example.com/nextcloud/test-deploy:release', $createPayloads[0]['image']); + self::assertArrayNotHasKey('role_suffix', $createPayloads[0]); + } + + public function testDeployExAppNeverPullsForALocalMapping(): void { + $createPayloads = $this->deployWithMappedRegistry([], [['from' => 'ghcr.io', 'to' => 'local']]); + + self::assertSame('ghcr.io/nextcloud/test-deploy:release', $createPayloads[0]['image']); + self::assertSame('Never', $createPayloads[0]['image_pull_policy']); + } + + public static function nonLocalRegistriesProvider(): array { + return [ + 'no mapping' => [[]], + 'mirror mapping' => [[['from' => 'ghcr.io', 'to' => 'registry.example.com']]], + ]; + } + + #[DataProvider('nonLocalRegistriesProvider')] + public function testDeployExAppLeavesThePullPolicyToHarpWithoutALocalMapping(array $registries): void { + $createPayloads = $this->deployWithMappedRegistry([], $registries); + + self::assertArrayNotHasKey('image_pull_policy', $createPayloads[0]); + } + + public function testDeployExAppSendsTheMappedImageForEveryRole(): void { + $createPayloads = $this->deployWithMappedRegistry([ + ['name' => 'web', 'env' => 'ROLE=web', 'expose' => true], + ['name' => 'worker', 'env' => 'ROLE=worker', 'expose' => false], + ]); + + self::assertSame(['web', 'worker'], array_column($createPayloads, 'role_suffix')); + foreach ($createPayloads as $payload) { + self::assertSame('registry.example.com/nextcloud/test-deploy:release', $payload['image']); + } + } + + /** + * Runs deployExApp() against queued HaRP responses and returns the payloads of the /exapp/create requests. + */ + private function deployWithMappedRegistry(array $roles, ?array $registries = null): array { + $deployments = max(1, count($roles)); + $responses = [new Response(200, [], json_encode(['kubernetes' => ['enabled' => true, 'reachable' => true]]))]; + for ($i = 0; $i < $deployments; $i++) { + $responses[] = new Response(200, [], json_encode(['exists' => false])); + } + for ($i = 0; $i < $deployments; $i++) { + $responses[] = new Response(201, [], json_encode(['name' => 'nc-app-test-deploy'])); + $responses[] = new Response(204); + $responses[] = new Response(204); + } + for ($i = 0; $i < $deployments; $i++) { + $responses[] = new Response(200, [], json_encode(['started' => true])); + } + + $requests = []; + $mockHandler = new MockHandler($responses); + $handlerStack = HandlerStack::create($mockHandler); + $handlerStack->push(Middleware::history($requests)); + + $kubernetesActions = $this->getMockBuilder(KubernetesActions::class) + ->setConstructorArgs([ + $this->createMock(LoggerInterface::class), + $this->createMock(IConfig::class), + $this->createMock(ICertificateManager::class), + $this->createMock(IAppManager::class), + $this->createMock(IURLGenerator::class), + $this->createMock(AppAPICommonService::class), + $this->createMock(ExAppService::class), + $this->createMock(ICrypto::class), + $this->createMock(ExAppDeployOptionsService::class), + ]) + ->onlyMethods(['initGuzzleClient']) + ->getMock(); + (new ReflectionProperty(KubernetesActions::class, 'guzzleClient')) + ->setValue($kubernetesActions, new Client(['handler' => $handlerStack])); + + $daemonConfig = new DaemonConfig([ + 'accepts_deploy_id' => KubernetesActions::DEPLOY_ID, + 'protocol' => 'http', + 'host' => 'harp:8780', + 'deploy_config' => ['registries' => $registries ?? [['from' => 'ghcr.io', 'to' => 'registry.example.com']]], + ]); + $params = [ + 'image_params' => self::IMAGE_PARAMS, + 'container_params' => ['name' => 'test-deploy', 'env' => ['APP_ID=test-deploy']], + ]; + if ($roles !== []) { + $params['k8s_service_roles'] = $roles; + } + + self::assertSame('', $kubernetesActions->deployExApp(new ExApp(['appid' => 'test-deploy']), $daemonConfig, $params)); + self::assertSame(0, $mockHandler->count()); + + $createPayloads = []; + foreach ($requests as $transaction) { + if (str_ends_with($transaction['request']->getUri()->getPath(), '/exapp/create')) { + $createPayloads[] = json_decode((string)$transaction['request']->getBody(), true); + } + } + self::assertCount($deployments, $createPayloads); + return $createPayloads; + } +} diff --git a/tests/php/Service/DaemonConfigServiceTest.php b/tests/php/Service/DaemonConfigServiceTest.php new file mode 100644 index 000000000..5d0f4361e --- /dev/null +++ b/tests/php/Service/DaemonConfigServiceTest.php @@ -0,0 +1,215 @@ + 'ghcr.io', 'to' => 'registry.example.com']; + return [ + 'no registries key' => [[], 'ghcr.io', 'ghcr.io'], + 'empty registries' => [['registries' => []], 'ghcr.io', 'ghcr.io'], + 'matching mapping' => [['registries' => [$mirror]], 'ghcr.io', 'registry.example.com'], + 'other registry is left alone' => [['registries' => [$mirror]], 'docker.io', 'docker.io'], + 'the matching mapping is picked among several' => [ + ['registries' => [['from' => 'docker.io', 'to' => 'hub.example.com'], $mirror]], + 'ghcr.io', + 'registry.example.com', + ], + 'trailing slashes of the target are dropped' => [ + ['registries' => [['from' => 'ghcr.io', 'to' => 'registry.example.com//']]], + 'ghcr.io', + 'registry.example.com', + ], + 'target with port and path' => [ + ['registries' => [['from' => 'ghcr.io', 'to' => 'registry.example.com:5000/mirror/ghcr']]], + 'ghcr.io', + 'registry.example.com:5000/mirror/ghcr', + ], + 'local keeps the registry' => [['registries' => [['from' => 'ghcr.io', 'to' => 'local']]], 'ghcr.io', 'ghcr.io'], + 'legacy duplicate source: the first entry wins' => [ + ['registries' => [['from' => 'ghcr.io', 'to' => 'local'], $mirror]], + 'ghcr.io', + 'ghcr.io', + ], + 'local with a trailing slash keeps the registry' => [ + ['registries' => [['from' => 'ghcr.io', 'to' => 'local/']]], + 'ghcr.io', + 'ghcr.io', + ], + 'malformed entries are ignored' => [ + ['registries' => ['ghcr.io', ['from' => 'ghcr.io'], ['from' => 'ghcr.io', 'to' => 5000], ['from' => 'ghcr.io', 'to' => '/'], $mirror]], + 'ghcr.io', + 'registry.example.com', + ], + 'only an exact match counts' => [['registries' => [$mirror]], 'my.ghcr.io', 'my.ghcr.io'], + 'match is case sensitive' => [['registries' => [$mirror]], 'GHCR.IO', 'GHCR.IO'], + 'registries with gaps in their keys' => [['registries' => [2 => $mirror]], 'ghcr.io', 'registry.example.com'], + ]; + } + + #[DataProvider('resolveImageRegistryProvider')] + public function testResolveImageRegistry(array $deployConfig, string $imageRegistry, string $expected): void { + self::assertSame($expected, DaemonConfigService::resolveImageRegistry($deployConfig, $imageRegistry)); + } + + public static function resolveRegistryTargetProvider(): array { + $mirror = ['from' => 'ghcr.io', 'to' => 'registry.example.com']; + $local = ['from' => 'ghcr.io', 'to' => 'local']; + return [ + 'no registries key' => [[], null], + 'no mapping of the registry' => [['registries' => [['from' => 'docker.io', 'to' => 'local']]], null], + 'mirror' => [['registries' => [$mirror]], 'registry.example.com'], + 'mirror with trailing slashes' => [['registries' => [['from' => 'ghcr.io', 'to' => 'registry.example.com//']]], 'registry.example.com'], + 'local' => [['registries' => [$local]], 'local'], + 'local with a trailing slash' => [['registries' => [['from' => 'ghcr.io', 'to' => 'local/']]], 'local'], + 'first usable entry wins' => [['registries' => [$local, $mirror]], 'local'], + 'first usable entry wins, mirror first' => [['registries' => [$mirror, $local]], 'registry.example.com'], + 'unusable entries are skipped' => [ + ['registries' => ['ghcr.io', ['from' => 'ghcr.io'], ['from' => 'ghcr.io', 'to' => 5000], ['from' => 'ghcr.io', 'to' => '/'], $mirror]], + 'registry.example.com', + ], + 'only unusable entries' => [['registries' => [['from' => 'ghcr.io', 'to' => '//']]], null], + 'whitespace-only target is unusable' => [['registries' => [['from' => 'ghcr.io', 'to' => ' ']]], null], + 'stored target with surrounding whitespace' => [['registries' => [['from' => 'ghcr.io', 'to' => ' registry.example.com ']]], 'registry.example.com'], + ]; + } + + #[DataProvider('resolveRegistryTargetProvider')] + public function testResolveRegistryTarget(array $deployConfig, ?string $expected): void { + self::assertSame($expected, DaemonConfigService::resolveRegistryTarget($deployConfig, 'ghcr.io')); + } + + private function createService(bool $expectUpdate = true): DaemonConfigService { + $mapper = $this->createMock(DaemonConfigMapper::class); + $mapper->expects($expectUpdate ? self::once() : self::never())->method('update')->willReturnArgument(0); + return new DaemonConfigService( + $this->createMock(LoggerInterface::class), + $mapper, + $this->createMock(ExAppService::class), + $this->createMock(ICrypto::class), + ); + } + + public function testAddDockerRegistryKeepsAList(): void { + $stored = ['from' => 'docker.io', 'to' => 'hub.example.com']; + $added = ['from' => 'ghcr.io', 'to' => 'registry.example.com']; + $daemonConfig = new DaemonConfig(['deploy_config' => ['registries' => [1 => $stored]]]); + + $result = $this->createService()->addDockerRegistry($daemonConfig, $added); + + self::assertInstanceOf(DaemonConfig::class, $result); + self::assertSame([$stored, $added], $result->getDeployConfig()['registries']); + } + + public static function unusableRegistryMapProvider(): array { + return [ + 'no source' => [['to' => 'registry.example.com']], + 'no target' => [['from' => 'ghcr.io']], + 'empty source' => [['from' => '', 'to' => 'registry.example.com']], + 'empty target' => [['from' => 'ghcr.io', 'to' => '']], + 'target of slashes only' => [['from' => 'ghcr.io', 'to' => '//']], + 'whitespace only' => [['from' => ' ', 'to' => "\t"]], + 'target is not a string' => [['from' => 'ghcr.io', 'to' => 5000]], + 'source is not a string' => [['from' => ['ghcr.io'], 'to' => 'registry.example.com']], + ]; + } + + #[DataProvider('unusableRegistryMapProvider')] + public function testAddDockerRegistryRejectsAnUnusableMap(array $registryMap): void { + $daemonConfig = new DaemonConfig(['deploy_config' => ['registries' => []]]); + + $result = $this->createService(expectUpdate: false)->addDockerRegistry($daemonConfig, $registryMap); + + self::assertSame(['error' => 'The source and target registry cannot be empty'], $result); + self::assertSame([], $daemonConfig->getDeployConfig()['registries']); + } + + public function testAddDockerRegistryRejectsADuplicateSource(): void { + $daemonConfig = new DaemonConfig(['deploy_config' => ['registries' => ['junk', ['from' => 'ghcr.io', 'to' => 'local']]]]); + + $result = $this->createService(expectUpdate: false) + ->addDockerRegistry($daemonConfig, ['from' => 'ghcr.io', 'to' => 'registry.example.com']); + + self::assertSame(['error' => 'This Docker registry map from "ghcr.io" already exists'], $result); + } + + public function testAddDockerRegistryIgnoresAnUnusableStoredEntryOfTheSameSource(): void { + $unusable = ['from' => 'ghcr.io', 'to' => '/']; + $added = ['from' => 'ghcr.io', 'to' => 'registry.example.com']; + $daemonConfig = new DaemonConfig(['deploy_config' => ['registries' => [$unusable]]]); + + $result = $this->createService()->addDockerRegistry($daemonConfig, $added); + + self::assertInstanceOf(DaemonConfig::class, $result); + self::assertSame([$unusable, $added], $result->getDeployConfig()['registries']); + self::assertSame('registry.example.com', DaemonConfigService::resolveImageRegistry($result->getDeployConfig(), 'ghcr.io')); + } + + public function testAddDockerRegistryStoresNormalisedSourceAndTarget(): void { + $daemonConfig = new DaemonConfig(['deploy_config' => []]); + + $result = $this->createService() + ->addDockerRegistry($daemonConfig, ['from' => ' ghcr.io/ ', 'to' => 'registry.example.com//', 'extra' => 'x']); + + self::assertInstanceOf(DaemonConfig::class, $result); + self::assertSame([['from' => 'ghcr.io', 'to' => 'registry.example.com']], $result->getDeployConfig()['registries']); + } + + public function testAddDockerRegistryStoresLocalWithoutATrailingSlash(): void { + $daemonConfig = new DaemonConfig(['deploy_config' => []]); + + $result = $this->createService()->addDockerRegistry($daemonConfig, ['from' => 'ghcr.io', 'to' => 'local/']); + + self::assertInstanceOf(DaemonConfig::class, $result); + self::assertSame([['from' => 'ghcr.io', 'to' => 'local']], $result->getDeployConfig()['registries']); + } + + public function testRemoveDockerRegistryWithoutAnyMapping(): void { + $daemonConfig = new DaemonConfig(['deploy_config' => ['net' => 'host']]); + + $result = $this->createService(expectUpdate: false) + ->removeDockerRegistry($daemonConfig, ['from' => 'ghcr.io', 'to' => 'registry.example.com']); + + self::assertSame(['error' => 'This Docker registry map does not exist'], $result); + } + + public function testRemoveDockerRegistryKeepsAList(): void { + $service = $this->createService(); + $first = ['from' => 'docker.io', 'to' => 'hub.example.com']; + $second = ['from' => 'ghcr.io', 'to' => 'registry.example.com']; + $daemonConfig = new DaemonConfig(['deploy_config' => ['registries' => [$first, $second]]]); + + $result = $service->removeDockerRegistry($daemonConfig, $first); + + self::assertInstanceOf(DaemonConfig::class, $result); + self::assertSame([$second], $result->getDeployConfig()['registries']); + } + + public function testRemoveDockerRegistryToleratesMalformedEntries(): void { + $first = ['from' => 'docker.io', 'to' => 'hub.example.com']; + $second = ['from' => 'ghcr.io', 'to' => 'registry.example.com']; + $daemonConfig = new DaemonConfig(['deploy_config' => ['registries' => ['junk', ['from' => 'quay.io'], $first, $second]]]); + + $result = $this->createService()->removeDockerRegistry($daemonConfig, $first); + + self::assertInstanceOf(DaemonConfig::class, $result); + self::assertSame(['junk', ['from' => 'quay.io'], $second], $result->getDeployConfig()['registries']); + } +}