diff --git a/fixtures/manager/example-container-registries-backendai-multiarch.json b/fixtures/manager/example-container-registries-backendai-multiarch.json new file mode 100644 index 0000000000..98ad56fcd4 --- /dev/null +++ b/fixtures/manager/example-container-registries-backendai-multiarch.json @@ -0,0 +1,11 @@ +{ + "container_registries": [ + { + "id": "fe878f09-06cc-4b91-9242-4c71015cce04", + "hostname": "cr.backend.ai", + "url": "https://cr.backend.ai", + "type": "harbor2", + "project": "stable,community,multiarch" + } + ] +} diff --git a/fixtures/manager/example-container-registries-backendai.json b/fixtures/manager/example-container-registries-backendai.json new file mode 100644 index 0000000000..c8cc51864e --- /dev/null +++ b/fixtures/manager/example-container-registries-backendai.json @@ -0,0 +1,11 @@ +{ + "container_registries": [ + { + "id": "fe878f09-06cc-4b91-9242-4c71015cce04", + "hostname": "cr.backend.ai", + "url": "https://cr.backend.ai", + "type": "harbor2", + "project": "stable,community" + } + ] +} diff --git a/fixtures/manager/example-container-registries-dockerhub.json b/fixtures/manager/example-container-registries-dockerhub.json new file mode 100644 index 0000000000..281a58da30 --- /dev/null +++ b/fixtures/manager/example-container-registries-dockerhub.json @@ -0,0 +1,11 @@ +{ + "container_registries": [ + { + "id": "abc42a05-4471-41fa-8772-10bf6452c7d1", + "hostname": "index.docker.io", + "url": "https://registry-1.docker.io", + "type": "docker", + "username": "lablup" + } + ] +} diff --git a/scripts/install-dev.sh b/scripts/install-dev.sh index f8e3f5237a..9ebaa4c614 100755 --- a/scripts/install-dev.sh +++ b/scripts/install-dev.sh @@ -955,6 +955,13 @@ configure_backendai() { # initialize the DB schema show_info "Setting up databases..." ./backend.ai mgr schema oneshot + + if [[ "$(uname -m)" == "aarch64" ]] then + ./backend.ai mgr fixture populate fixtures/manager/example-container-registries-backendai-multiarch.json + else + ./backend.ai mgr fixture populate fixtures/manager/example-container-registries-backendai.json + fi + ./backend.ai mgr fixture populate fixtures/manager/example-users.json ./backend.ai mgr fixture populate fixtures/manager/example-keypairs.json ./backend.ai mgr fixture populate fixtures/manager/example-set-user-main-access-keys.json diff --git a/src/ai/backend/install/context.py b/src/ai/backend/install/context.py index f1efe42676..8db7aa6524 100644 --- a/src/ai/backend/install/context.py +++ b/src/ai/backend/install/context.py @@ -262,8 +262,19 @@ async def install_halfstack(self) -> None: cwd=self.install_info.base_path, ) + def get_container_registry_fixture_filename(self) -> str: + if self.os_info.platform in (Platform.LINUX_ARM64, Platform.MACOS_ARM64): + return "example-container-registries-backendai-multiarch.json" + else: + return "example-container-registries-backendai.json" + async def load_fixtures(self) -> None: await self.run_manager_cli(["mgr", "schema", "oneshot"]) + with self.resource_path( + "ai.backend.install.fixtures", self.get_container_registry_fixture_filename() + ) as path: + await self.run_manager_cli(["mgr", "fixture", "populate", str(path)]) + with self.resource_path("ai.backend.install.fixtures", "example-keypairs.json") as path: await self.run_manager_cli(["mgr", "fixture", "populate", str(path)]) with self.resource_path( @@ -654,6 +665,8 @@ async def alias_image(self, alias: str, target_ref: str, arch: str) -> None: ]) async def populate_images(self) -> None: + halfstack = self.install_info.halfstack_config + data: Any for image_source in self.dist_info.image_sources: match image_source: @@ -661,25 +674,39 @@ async def populate_images(self) -> None: self.log_header( "Scanning and pulling configured Backend.AI container images..." ) - if self.os_info.platform in (Platform.LINUX_ARM64, Platform.MACOS_ARM64): - project = "stable,community,multiarch" - else: - project = "stable,community" + data = { "docker": { "image": { - "auto_pull": "tag", # FIXME: temporary workaround for multiarch - }, - "registry": { - "cr.backend.ai": { - "": "https://cr.backend.ai", - "type": "harbor2", - "project": project, - }, + "auto_pull": "tag", }, }, } await self.etcd_put_json("config", data) + + async with aiotools.closing_async( + await asyncpg.connect( + host=halfstack.postgres_addr.face.host, + port=halfstack.postgres_addr.face.port, + user=halfstack.postgres_user, + password=halfstack.postgres_password, + database="backend", + ) + ) as conn: + if self.os_info.platform in (Platform.LINUX_ARM64, Platform.MACOS_ARM64): + project = "stable,community,multiarch" + else: + project = "stable,community" + + await conn.execute( + "INSERT INTO container_registries (id, url, hostname, type, project) VALUES ($1, $2, $3, $4, $5);", + "fe878f09-06cc-4b91-9242-4c71015cce04", + "https://cr.backend.ai", + "cr.backend.ai", + "harbor2", + project, + ) + await self.run_manager_cli(["mgr", "image", "rescan", "cr.backend.ai"]) if self.os_info.platform in (Platform.LINUX_ARM64, Platform.MACOS_ARM64): await self.alias_image( @@ -700,18 +727,30 @@ async def populate_images(self) -> None: data = { "docker": { "image": { - "auto_pull": "tag", # FIXME: temporary workaround for multiarch - }, - "registry": { - "index.docker.io": { - "": "https://registry-1.docker.io", - "type": "docker", - "username": "lablup", - }, + "auto_pull": "tag", }, }, } await self.etcd_put_json("config", data) + + async with aiotools.closing_async( + await asyncpg.connect( + host=halfstack.postgres_addr.face.host, + port=halfstack.postgres_addr.face.port, + user=halfstack.postgres_user, + password=halfstack.postgres_password, + database="backend", + ) + ) as conn: + await conn.execute( + "INSERT INTO container_registries (id, url, hostname, type, username) VALUES ($1, $2, $3, $4, $5);", + "abc42a05-4471-41fa-8772-10bf6452c7d1", + "https://registry-1.docker.io", + "index.docker.io", + "docker", + "lablup", + ) + for ref in self.dist_info.image_refs: await self.run_manager_cli(["mgr", "image", "rescan", ref]) await self.run_exec([*self.docker_sudo, "docker", "pull", ref]) diff --git a/src/ai/backend/install/fixtures/example-container-registries-backendai-multiarch.json b/src/ai/backend/install/fixtures/example-container-registries-backendai-multiarch.json new file mode 100644 index 0000000000..2ecc3067a9 --- /dev/null +++ b/src/ai/backend/install/fixtures/example-container-registries-backendai-multiarch.json @@ -0,0 +1,11 @@ +{ + "container_registries": [ + { + "id": "fe878f09-06cc-4b91-9242-4c71015cce04", + "hostname": "cr.backend.ai", + "url": "https://cr.backend.ai", + "type": "harbor2", + "project": ["stable", "community", "multiarch"] + } + ] +} diff --git a/src/ai/backend/install/fixtures/example-container-registries-backendai.json b/src/ai/backend/install/fixtures/example-container-registries-backendai.json new file mode 100644 index 0000000000..04fa2fc350 --- /dev/null +++ b/src/ai/backend/install/fixtures/example-container-registries-backendai.json @@ -0,0 +1,11 @@ +{ + "container_registries": [ + { + "id": "fe878f09-06cc-4b91-9242-4c71015cce04", + "hostname": "cr.backend.ai", + "url": "https://cr.backend.ai", + "type": "harbor2", + "project": ["stable", "community"] + } + ] +} diff --git a/src/ai/backend/install/fixtures/example-container-registries-dockerhub.json b/src/ai/backend/install/fixtures/example-container-registries-dockerhub.json new file mode 100644 index 0000000000..281a58da30 --- /dev/null +++ b/src/ai/backend/install/fixtures/example-container-registries-dockerhub.json @@ -0,0 +1,11 @@ +{ + "container_registries": [ + { + "id": "abc42a05-4471-41fa-8772-10bf6452c7d1", + "hostname": "index.docker.io", + "url": "https://registry-1.docker.io", + "type": "docker", + "username": "lablup" + } + ] +} diff --git a/src/ai/backend/manager/cli/image_impl.py b/src/ai/backend/manager/cli/image_impl.py index 4394fb23bf..508361e44d 100644 --- a/src/ai/backend/manager/cli/image_impl.py +++ b/src/ai/backend/manager/cli/image_impl.py @@ -16,7 +16,7 @@ from ..models.image import ImageAliasRow, ImageRow from ..models.image import rescan_images as rescan_images_func from ..models.utils import connect_database -from .context import CLIContext, etcd_ctx, redis_ctx +from .context import CLIContext, redis_ctx log = BraceStyleAdapter(logging.getLogger(__spec__.name)) # type: ignore[name-defined] @@ -153,10 +153,9 @@ async def rescan_images(cli_ctx: CLIContext, registry_or_image: str, local: bool raise click.BadArgumentUsage("Please specify a valid registry or full image name.") async with ( connect_database(cli_ctx.local_config) as db, - etcd_ctx(cli_ctx) as etcd, ): try: - await rescan_images_func(etcd, db, registry_or_image, local=local) + await rescan_images_func(db, registry_or_image, local=local) except Exception: log.exception("An error occurred.") diff --git a/src/ai/backend/manager/config.py b/src/ai/backend/manager/config.py index 2f9bc9e454..e82ec141c8 100644 --- a/src/ai/backend/manager/config.py +++ b/src/ai/backend/manager/config.py @@ -219,7 +219,7 @@ from ..manager.defs import INTRINSIC_SLOTS from .api import ManagerStatus -from .api.exceptions import ObjectNotFound, ServerMisconfiguredError +from .api.exceptions import ServerMisconfiguredError from .models.session import SessionStatus log = BraceStyleAdapter(logging.getLogger(__spec__.name)) # type: ignore[name-defined] @@ -345,33 +345,6 @@ }, } -container_registry_iv = t.Dict({ - t.Key(""): tx.URL, - t.Key("type", default="docker"): t.String, - t.Key("username", default=None): t.Null | t.String, - t.Key("password", default=None): t.Null | t.String(allow_blank=True), - t.Key("project", default=None): ( - t.Null | t.List(t.String) | tx.StringList(empty_str_as_empty_list=True) - ), - tx.AliasedKey(["ssl_verify", "ssl-verify"], default=True): t.ToBool, -}).allow_extra("*") - - -def container_registry_serialize(v: dict[str, Any]) -> dict[str, str]: - raw_data = { - "": str(v[""]), - "type": str(v["type"]), - } - if (username := v.get("username")) is not None: - raw_data["username"] = str(username) - if (password := v.get("password", None)) is not None: - raw_data["password"] = str(password) - if (project := v.get("project", None)) is not None: - raw_data["project"] = ",".join(project) - if (ssl_verify := v.get("ssl_verify", None)) is not None: - raw_data["ssl_verify"] = "1" if ssl_verify else "0" - return raw_data - session_hang_tolerance_iv = t.Dict( { @@ -402,7 +375,6 @@ def container_registry_serialize(v: dict[str, Any]) -> dict[str, str]: }).allow_extra("*"), t.Key("redis", default=_config_defaults["redis"]): config.redis_config_iv, t.Key("docker", default=_config_defaults["docker"]): t.Dict({ - t.Key("registry"): t.Mapping(t.String, container_registry_iv), t.Key("image", default=_config_defaults["docker"]["image"]): t.Dict({ t.Key("auto_pull", default=_config_defaults["docker"]["image"]["auto_pull"]): t.Enum( "digest", "tag", "none" @@ -633,100 +605,6 @@ async def get_raw(self, key: str, allow_null: bool = True) -> Optional[str]: raise ServerMisconfiguredError("A required etcd config is missing.", key) return value - async def list_container_registry(self) -> dict[str, dict[str, Any]]: - registries = await self.etcd.get_prefix(self.ETCD_CONTAINER_REGISTRY_KEY) - return { - hostname: container_registry_iv.check(item) - for hostname, item in registries.items() - # type: ignore - } - - async def get_container_registry(self, hostname: str) -> dict[str, Any]: - registries = await self.list_container_registry() - try: - item = registries[hostname] - except KeyError: - raise ObjectNotFound(object_name="container registry") - return item - - async def add_container_registry(self, hostname: str, config_new: dict[str, Any]) -> None: - updates = self.flatten( - self.ETCD_CONTAINER_REGISTRY_KEY, - {hostname: container_registry_serialize(container_registry_iv.check(config_new))}, - ) - await self.etcd.put_dict(updates) - - async def modify_container_registry( - self, hostname: str, config_updated: dict[str, Any] - ) -> None: - # Fetch the raw registries data and make it a mutable dict. - registries = dict(await self.etcd.get_prefix(self.ETCD_CONTAINER_REGISTRY_KEY)) - # Exclude the target hostname from the raw data. - try: - original_item = registries[hostname] - del registries[hostname] - except KeyError: - raise ObjectNotFound(object_name="container registry") - # Delete all items with having the prefix of the given hostname. - # This will "accidentally" delete any registry sharing the same prefix. - raw_hostname = urllib.parse.quote(hostname, safe="") - await self.etcd.delete_prefix(f"{self.ETCD_CONTAINER_REGISTRY_KEY}/{raw_hostname}") - - # Re-add the "accidentally" deleted items - updates: dict[str, str] = {} - for key, raw_item in registries.items(): - if key.startswith(hostname): - updates.update( - self.flatten( - self.ETCD_CONTAINER_REGISTRY_KEY, - {key: raw_item}, # type: ignore - ) - ) - # Re-add the updated item - if (_ssl_verify := config_updated.pop("ssl-verify", None)) is not None: - # Move "ssl-verify" to "ssl_verify" if exists, for key aliasing compatibility: - # the etcd-stored original item has already the normalized name "ssl_verify", - # while the user input may have either "ssl-verify" or "ssl_verify". - # We should run the IV check after merging the original item and the user input - # to prevent overwriting non-existent fields with the default values in IV. - config_updated["ssl_verify"] = _ssl_verify - updates.update( - self.flatten( - self.ETCD_CONTAINER_REGISTRY_KEY, - { - hostname: container_registry_serialize( - container_registry_iv.check({**original_item, **config_updated}) # type: ignore - ) - }, - ) - ) - await self.etcd.put_dict(updates) - - async def delete_container_registry(self, hostname: str) -> None: - # Fetch the raw registries data and make it a mutable dict. - registries = dict(await self.etcd.get_prefix(self.ETCD_CONTAINER_REGISTRY_KEY)) - # Exclude the target hostname from the raw data. - try: - del registries[hostname] - except KeyError: - raise ObjectNotFound(object_name="container registry") - # Delete all items with having the prefix of the given hostname. - # This will "accidentally" delete any registry sharing the same prefix. - raw_hostname = urllib.parse.quote(hostname, safe="") - await self.etcd.delete_prefix(f"{self.ETCD_CONTAINER_REGISTRY_KEY}/{raw_hostname}") - - # Re-add the "accidentally" deleted items. - updates: dict[str, str] = {} - for key, raw_item in registries.items(): - if key.startswith(hostname): - updates.update( - self.flatten( - self.ETCD_CONTAINER_REGISTRY_KEY, - {key: raw_item}, # type: ignore - ) - ) - await self.etcd.put_dict(updates) - async def register_myself(self) -> None: instance_id = await get_instance_id() manager_info = { diff --git a/src/ai/backend/manager/container_registry/__init__.py b/src/ai/backend/manager/container_registry/__init__.py index a3cf414d1f..b5a22aa19a 100644 --- a/src/ai/backend/manager/container_registry/__init__.py +++ b/src/ai/backend/manager/container_registry/__init__.py @@ -1,16 +1,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Mapping, Type +from typing import TYPE_CHECKING, Type import yarl +from ai.backend.manager.models.container_registry import ContainerRegistryRow + if TYPE_CHECKING: from .base import BaseContainerRegistry -def get_container_registry_cls(registry_info: Mapping[str, Any]) -> Type[BaseContainerRegistry]: - registry_url = yarl.URL(registry_info[""]) - registry_type = registry_info.get("type", "docker") +def get_container_registry_cls(registry_info: ContainerRegistryRow) -> Type[BaseContainerRegistry]: + registry_url = yarl.URL(registry_info.url) + registry_type = registry_info.type cr_cls: Type[BaseContainerRegistry] if registry_url.host is not None and registry_url.host.endswith(".docker.io"): from .docker import DockerHubRegistry diff --git a/src/ai/backend/manager/container_registry/base.py b/src/ai/backend/manager/container_registry/base.py index ae6ede8f0e..43a367b8b7 100644 --- a/src/ai/backend/manager/container_registry/base.py +++ b/src/ai/backend/manager/container_registry/base.py @@ -6,7 +6,7 @@ from abc import ABCMeta, abstractmethod from contextlib import asynccontextmanager as actxmgr from contextvars import ContextVar -from typing import Any, AsyncIterator, Dict, Final, Mapping, Optional, cast +from typing import Any, AsyncIterator, Dict, Final, Optional, cast import aiohttp import aiotools @@ -19,6 +19,7 @@ from ai.backend.common.docker import login as registry_login from ai.backend.common.exception import InvalidImageName, InvalidImageTag from ai.backend.common.logging import BraceStyleAdapter +from ai.backend.manager.models.container_registry import ContainerRegistryRow from ...common.types import SSLContextType from ..models.image import ImageRow, ImageType @@ -35,7 +36,7 @@ class BaseContainerRegistry(metaclass=ABCMeta): db: ExtendedAsyncSAEngine registry_name: str - registry_info: Mapping[str, Any] + registry_info: ContainerRegistryRow registry_url: yarl.URL max_concurrency_per_registry: int base_hdrs: Dict[str, str] @@ -53,7 +54,7 @@ def __init__( self, db: ExtendedAsyncSAEngine, registry_name: str, - registry_info: Mapping[str, Any], + registry_info: ContainerRegistryRow, *, max_concurrency_per_registry: int = 4, ssl_verify: bool = True, @@ -61,7 +62,7 @@ def __init__( self.db = db self.registry_name = registry_name self.registry_info = registry_info - self.registry_url = registry_info[""] + self.registry_url = yarl.URL(registry_info.url) self.max_concurrency_per_registry = max_concurrency_per_registry self.base_hdrs = { "Accept": "application/vnd.docker.distribution.manifest.v2+json", @@ -72,7 +73,7 @@ def __init__( @actxmgr async def prepare_client_session(self) -> AsyncIterator[tuple[yarl.URL, aiohttp.ClientSession]]: ssl_ctx: SSLContextType = True # default - if not self.registry_info["ssl_verify"]: + if not self.registry_info.ssl_verify: ssl_ctx = False connector = aiohttp.TCPConnector(ssl=ssl_ctx) async with aiohttp.ClientSession(connector=connector) as sess: @@ -86,10 +87,10 @@ async def rescan_single_registry( concurrency_sema.set(asyncio.Semaphore(self.max_concurrency_per_registry)) progress_reporter.set(reporter) try: - username = self.registry_info["username"] + username = self.registry_info.username if username is not None: self.credentials["username"] = username - password = self.registry_info["password"] + password = self.registry_info.password if password is not None: self.credentials["password"] = password async with self.prepare_client_session() as (url, client_session): diff --git a/src/ai/backend/manager/container_registry/docker.py b/src/ai/backend/manager/container_registry/docker.py index ce130b2f5b..f6f0c02036 100644 --- a/src/ai/backend/manager/container_registry/docker.py +++ b/src/ai/backend/manager/container_registry/docker.py @@ -33,7 +33,7 @@ async def fetch_repositories_legacy( sess: aiohttp.ClientSession, ) -> AsyncIterator[str]: params = {"page_size": "30"} - username = self.registry_info["username"] + username = self.registry_info.username hub_url = yarl.URL("https://hub.docker.com") repo_list_url: Optional[yarl.URL] repo_list_url = hub_url / f"v2/repositories/{username}/" diff --git a/src/ai/backend/manager/container_registry/harbor.py b/src/ai/backend/manager/container_registry/harbor.py index f5e1f8acf8..0ba7736a5b 100644 --- a/src/ai/backend/manager/container_registry/harbor.py +++ b/src/ai/backend/manager/container_registry/harbor.py @@ -28,7 +28,7 @@ async def fetch_repositories( sess: aiohttp.ClientSession, ) -> AsyncIterator[str]: api_url = self.registry_url / "api" - registry_projects = self.registry_info["project"] + registry_projects = cast(str, self.registry_info.project).split(",") rqst_args = {} if self.credentials: rqst_args["auth"] = aiohttp.BasicAuth( @@ -130,7 +130,7 @@ async def fetch_repositories( sess: aiohttp.ClientSession, ) -> AsyncIterator[str]: api_url = self.registry_url / "api" / "v2.0" - registry_projects = self.registry_info["project"] + registry_projects = cast(str, self.registry_info.project).split(",") rqst_args = {} if self.credentials: rqst_args["auth"] = aiohttp.BasicAuth( diff --git a/src/ai/backend/manager/models/__init__.py b/src/ai/backend/manager/models/__init__.py index a8bd671340..08ca34ffae 100644 --- a/src/ai/backend/manager/models/__init__.py +++ b/src/ai/backend/manager/models/__init__.py @@ -1,5 +1,6 @@ from . import acl as _acl from . import agent as _agent +from . import container_registry as _container_registry from . import domain as _domain from . import dotfile as _dotfile from . import endpoint as _endpoint @@ -24,6 +25,7 @@ "metadata", *_acl.__all__, *_agent.__all__, + *_container_registry.__all__, *_domain.__all__, *_endpoint.__all__, *_group.__all__, @@ -46,6 +48,7 @@ from .acl import * # noqa from .agent import * # noqa +from .container_registry import * # noqa from .domain import * # noqa from .dotfile import * # noqa from .endpoint import * # noqa diff --git a/src/ai/backend/manager/models/container_registry.py b/src/ai/backend/manager/models/container_registry.py new file mode 100644 index 0000000000..d758a6ba8c --- /dev/null +++ b/src/ai/backend/manager/models/container_registry.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +import logging +import uuid +from typing import TYPE_CHECKING, Optional, Sequence + +import graphene +import sqlalchemy as sa +from sqlalchemy.engine.row import Row +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.exc import NoResultFound + +from ai.backend.common.logging_utils import BraceStyleAdapter + +from ..defs import PASSWORD_PLACEHOLDER +from .base import Base, IDColumn, mapper_registry, privileged_mutation +from .gql_relay import AsyncNode +from .user import UserRole + +if TYPE_CHECKING: + from .gql import GraphQueryContext + +log = BraceStyleAdapter(logging.getLogger(__spec__.name)) # type: ignore + +__all__: Sequence[str] = ( + "container_registries", + "ContainerRegistryRow", + "ContainerRegistry", + "CreateContainerRegistry", + "ModifyContainerRegistry", + "DeleteContainerRegistry", +) + +container_registries = sa.Table( + "container_registries", + mapper_registry.metadata, + IDColumn(), + sa.Column("url", sa.String(length=255), index=True), + sa.Column("hostname", sa.String(length=50), index=True), + sa.Column( + "type", + sa.Enum("docker", "harbor", "harbor2", name="container_registry_type"), + default="docker", + index=True, + ), + sa.Column("project", sa.Text, nullable=True), # harbor only + sa.Column("username", sa.String(length=255), nullable=True), + sa.Column("password", sa.String(length=255), nullable=True), + sa.Column("ssl_verify", sa.Boolean, default=True, index=True), +) + + +class ContainerRegistryRow(Base): + __table__ = container_registries + # __tablename__ = "container_registries" + # id = IDColumn() + # url = sa.Column("url", sa.String(length=255), index=True) + # hostname = sa.Column("hostname", sa.String(length=50), index=True) + # type = sa.Column( + # "type", + # sa.Enum("docker", "harbor", "harbor2", name="container_registry_type"), + # default="docker", + # index=True, + # ) + # project = sa.Column("project", sa.Text, nullable=True) # harbor only + # username = sa.Column("username", sa.String(length=255), nullable=True) + # password = sa.Column("password", sa.String(length=255), nullable=True) + # ssl_verify = sa.Column("ssl_verify", sa.Boolean, default=True, index=True) + + def __init__( + self, + id: uuid.UUID, + hostname: str, + url: str, + type: str, + ssl_verify: bool, + username: Optional[str] = None, + password: Optional[str] = None, + project: Optional[str] = None, + ) -> None: + self.id = id + self.hostname = hostname + self.url = url + self.type = type + self.project = project + self.username = username + self.password = password + self.ssl_verify = ssl_verify + + @classmethod + async def get( + cls, + session: AsyncSession, + id: uuid.UUID, + ) -> ContainerRegistryRow: + query = sa.select(ContainerRegistryRow).where(ContainerRegistryRow.id == id) + result = await session.execute(query) + row = result.scalar() + if row is None: + raise NoResultFound + return row + + @classmethod + async def get_by_hostname( + cls, + session: AsyncSession, + hostname: str, + ) -> ContainerRegistryRow: + query = sa.select(ContainerRegistryRow).where(ContainerRegistryRow.hostname == hostname) + result = await session.execute(query) + row = result.scalar() + if row is None: + raise NoResultFound + return row + + +class CreateContainerRegistryInput(graphene.InputObjectType): + url = graphene.String(required=True) + type = graphene.String(required=True) + project = graphene.List(graphene.String) + username = graphene.String() + password = graphene.String() + ssl_verify = graphene.Boolean() + + +class ModifyContainerRegistryInput(graphene.InputObjectType): + url = graphene.String() + type = graphene.String() + project = graphene.List(graphene.String) + username = graphene.String() + password = graphene.String() + ssl_verify = graphene.Boolean() + + +class ContainerRegistryConfig(graphene.ObjectType): + url = graphene.String(required=True) + type = graphene.String(required=True) + project = graphene.List(graphene.String) + username = graphene.String() + password = graphene.String() + ssl_verify = graphene.Boolean() + + +class ContainerRegistry(graphene.ObjectType): + hostname = graphene.String() + config = graphene.Field(ContainerRegistryConfig) + + class Meta: + interfaces = (AsyncNode,) + + # TODO: `get_node()` should be implemented to query a scalar object directly by ID + # (https://docs.graphene-python.org/en/latest/relay/nodes/#nodes) + # @classmethod + # def get_node(cls, info: graphene.ResolveInfo, id): + # raise NotImplementedError + + @classmethod + def from_row(cls, ctx: GraphQueryContext, row: Row) -> ContainerRegistry: + return cls( + id=row["id"], + hostname=row["name"], + config=ContainerRegistryConfig( + url=row["url"], + type=row["type"], + project=row["project"], + username=row["username"], + password=PASSWORD_PLACEHOLDER if row["password"] is not None else None, + ssl_verify=row["ssl_verify"], + ), + ) + + @classmethod + async def load_all( + cls, + ctx: GraphQueryContext, + ) -> Sequence[ContainerRegistry]: + async with ctx.db.begin_readonly_session() as session: + rows = await session.execute(sa.select(ContainerRegistryRow)) + return [cls.from_row(ctx, row) for row in rows] + + @classmethod + async def load_registry(cls, ctx: GraphQueryContext, hostname: str) -> ContainerRegistry: + async with ctx.db.begin_readonly_session() as session: + return cls.from_row( + ctx, + await ContainerRegistryRow.get_by_hostname( + session, + hostname, + ), + ) + + +class CreateContainerRegistry(graphene.Mutation): + allowed_roles = (UserRole.SUPERADMIN,) + container_registry = graphene.Field(ContainerRegistry) + + class Arguments: + hostname = graphene.String(required=True) + props = CreateContainerRegistryInput(required=True) + + @classmethod + @privileged_mutation( + UserRole.SUPERADMIN, + lambda id, **kwargs: (None, id), + ) + async def mutate( + cls, root, info: graphene.ResolveInfo, hostname: str, props: CreateContainerRegistryInput + ) -> CreateContainerRegistry: + ctx: GraphQueryContext = info.context + data = { + "hostname": hostname, + "url": props.url, + "type": props.type, + "project": props.project, + "username": props.username, + "password": props.password, + "ssl_verify": props.ssl_verify, + } + + async with ctx.db.begin_session() as session: + await session.execute(sa.insert(container_registries).values(data)) + + container_registry = await ContainerRegistry.load_registry(ctx, hostname) + return cls(container_registry=container_registry) + + +class ModifyContainerRegistry(graphene.Mutation): + allowed_roles = (UserRole.SUPERADMIN,) + container_registry = graphene.Field(ContainerRegistry) + + class Arguments: + hostname = graphene.String(required=True) + props = ModifyContainerRegistryInput(required=True) + + @classmethod + @privileged_mutation( + UserRole.SUPERADMIN, + lambda id, **kwargs: (None, id), + ) + async def mutate( + cls, + root, + info: graphene.ResolveInfo, + hostname: str, + props: ModifyContainerRegistryInput, + ) -> ModifyContainerRegistry: + ctx: GraphQueryContext = info.context + data = { + "hostname": hostname, + "url": props.url, + "type": props.type, + "project": props.project, + "username": props.username, + "password": props.password, + "ssl_verify": props.ssl_verify, + } + + async with ctx.db.begin_session() as session: + await session.execute( + sa.update(container_registries) + .where(ContainerRegistryRow.hostname == hostname) + .values(data) + ) + container_registry = await ContainerRegistry.load_registry(ctx, hostname) + return cls(container_registry=container_registry) + + +class DeleteContainerRegistry(graphene.Mutation): + allowed_roles = (UserRole.SUPERADMIN,) + container_registry = graphene.Field(ContainerRegistry) + + class Arguments: + hostname = graphene.String(required=True) + + @classmethod + @privileged_mutation( + UserRole.SUPERADMIN, + lambda id, **kwargs: (None, id), + ) + async def mutate( + cls, + root, + info: graphene.ResolveInfo, + hostname: str, + ) -> DeleteContainerRegistry: + ctx: GraphQueryContext = info.context + container_registry = await ContainerRegistry.load_registry(ctx, hostname) + async with ctx.db.begin_session() as session: + await session.execute( + sa.delete(container_registries).where(ContainerRegistryRow.hostname == hostname) + ) + + return cls(container_registry=container_registry) diff --git a/src/ai/backend/manager/models/etcd.py b/src/ai/backend/manager/models/etcd.py deleted file mode 100644 index 8139820b0f..0000000000 --- a/src/ai/backend/manager/models/etcd.py +++ /dev/null @@ -1,207 +0,0 @@ -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING, Any, Dict, Mapping, Sequence - -import graphene - -from ai.backend.common.logging import BraceStyleAdapter - -from ..defs import PASSWORD_PLACEHOLDER -from . import UserRole -from .base import privileged_mutation, set_if_set -from .gql_relay import AsyncNode - -if TYPE_CHECKING: - from .gql import GraphQueryContext - -log = BraceStyleAdapter(logging.getLogger("ai.backend.manager.models.etcd")) # type: ignore[name-defined] - -__all__: Sequence[str] = ( - "ContainerRegistry", - "CreateContainerRegistry", - "ModifyContainerRegistry", - "DeleteContainerRegistry", -) - - -class CreateContainerRegistryInput(graphene.InputObjectType): - url = graphene.String(required=True) - type = graphene.String(required=True) - project = graphene.List(graphene.String) - username = graphene.String() - password = graphene.String() - ssl_verify = graphene.Boolean() - - -class ModifyContainerRegistryInput(graphene.InputObjectType): - url = graphene.String() - type = graphene.String() - project = graphene.List(graphene.String) - username = graphene.String() - password = graphene.String() - ssl_verify = graphene.Boolean() - - -class ContainerRegistryConfig(graphene.ObjectType): - url = graphene.String(required=True) - type = graphene.String(required=True) - project = graphene.List(graphene.String) - username = graphene.String() - password = graphene.String() - ssl_verify = graphene.Boolean() - - -class ContainerRegistry(graphene.ObjectType): - hostname = graphene.String() - config = graphene.Field(ContainerRegistryConfig) - - class Meta: - interfaces = (AsyncNode,) - - # TODO: `get_node()` should be implemented to query a scalar object directly by ID - # (https://docs.graphene-python.org/en/latest/relay/nodes/#nodes) - # @classmethod - # def get_node(cls, info: graphene.ResolveInfo, id): - # raise NotImplementedError - - @classmethod - def from_row(cls, hostname: str, config: Mapping[str, str | list | None]) -> ContainerRegistry: - password = config.get("password", None) - return cls( - id=hostname, - hostname=hostname, - config=ContainerRegistryConfig( - url=config.get(""), - type=config.get("type"), - project=config.get("project", None), - username=config.get("username", None), - password=PASSWORD_PLACEHOLDER if password is not None else None, - ssl_verify=config.get("ssl_verify", None), - ), - ) - - @classmethod - async def load_all( - cls, - ctx: GraphQueryContext, - ) -> Sequence[ContainerRegistry]: - log.info( - "ETCD.LIST_CONTAINER_REGISTRY (ak:{})", - ctx.access_key, - ) - registries = await ctx.shared_config.list_container_registry() - return [cls.from_row(hostname, config) for hostname, config in registries.items()] - - @classmethod - async def load_registry(cls, ctx: GraphQueryContext, hostname: str) -> ContainerRegistry: - log.info( - "ETCD.GET_CONTAINER_REGISTRY (ak:{}, hostname:{})", - ctx.access_key, - hostname, - ) - item = await ctx.shared_config.get_container_registry(hostname) - return cls.from_row(hostname, item) - - -class CreateContainerRegistry(graphene.Mutation): - allowed_roles = (UserRole.SUPERADMIN,) - container_registry = graphene.Field(ContainerRegistry) - - class Arguments: - hostname = graphene.String(required=True) - props = CreateContainerRegistryInput(required=True) - - @classmethod - @privileged_mutation( - UserRole.SUPERADMIN, - lambda id, **kwargs: (None, id), - ) - async def mutate( - cls, root, info: graphene.ResolveInfo, hostname: str, props: CreateContainerRegistryInput - ) -> CreateContainerRegistry: - ctx: GraphQueryContext = info.context - input_config: Dict[str, Any] = {"": props.url, "type": props.type} - set_if_set(props, input_config, "project") - set_if_set(props, input_config, "username") - set_if_set(props, input_config, "password") - set_if_set(props, input_config, "ssl_verify") - log.info( - "ETCD.CREATE_CONTAINER_REGISTRY (ak:{}, hostname:{}, config:{})", - ctx.access_key, - hostname, - input_config, - ) - await ctx.shared_config.add_container_registry(hostname, input_config) - container_registry = await ContainerRegistry.load_registry(ctx, hostname) - return cls(container_registry=container_registry) - - -class ModifyContainerRegistry(graphene.Mutation): - allowed_roles = (UserRole.SUPERADMIN,) - container_registry = graphene.Field(ContainerRegistry) - - class Arguments: - hostname = graphene.String(required=True) - props = ModifyContainerRegistryInput(required=True) - - @classmethod - @privileged_mutation( - UserRole.SUPERADMIN, - lambda id, **kwargs: (None, id), - ) - async def mutate( - cls, - root, - info: graphene.ResolveInfo, - hostname: str, - props: ModifyContainerRegistryInput, - ) -> ModifyContainerRegistry: - ctx: GraphQueryContext = info.context - input_config: Dict[str, Any] = {} - set_if_set(props, input_config, "url") - set_if_set(props, input_config, "type") - set_if_set(props, input_config, "project") - set_if_set(props, input_config, "username") - set_if_set(props, input_config, "password") - set_if_set(props, input_config, "ssl_verify") - if "url" in input_config: - input_config[""] = input_config.pop("url") - log.info( - "ETCD.MODIFY_CONTAINER_REGISTRY (ak:{}, hostname:{}, config:{})", - ctx.access_key, - hostname, - input_config, - ) - await ctx.shared_config.modify_container_registry(hostname, input_config) - container_registry = await ContainerRegistry.load_registry(ctx, hostname) - return cls(container_registry=container_registry) - - -class DeleteContainerRegistry(graphene.Mutation): - allowed_roles = (UserRole.SUPERADMIN,) - container_registry = graphene.Field(ContainerRegistry) - - class Arguments: - hostname = graphene.String(required=True) - - @classmethod - @privileged_mutation( - UserRole.SUPERADMIN, - lambda id, **kwargs: (None, id), - ) - async def mutate( - cls, - root, - info: graphene.ResolveInfo, - hostname: str, - ) -> DeleteContainerRegistry: - ctx: GraphQueryContext = info.context - log.info( - "ETCD.DELETE_CONTAINER_REGISTRY (ak:{}, hostname:{})", - ctx.access_key, - hostname, - ) - container_registry = await ContainerRegistry.load_registry(ctx, hostname) - await ctx.shared_config.delete_container_registry(hostname) - return cls(container_registry=container_registry) diff --git a/src/ai/backend/manager/models/gql.py b/src/ai/backend/manager/models/gql.py index 4a37ec3b3b..b832aed6a7 100644 --- a/src/ai/backend/manager/models/gql.py +++ b/src/ai/backend/manager/models/gql.py @@ -15,7 +15,7 @@ from ai.backend.manager.defs import DEFAULT_IMAGE_ARCH from ai.backend.manager.models.gql_relay import AsyncNode, ConnectionResolverResult -from .etcd import ( +from .container_registry import ( ContainerRegistry, CreateContainerRegistry, DeleteContainerRegistry, diff --git a/src/ai/backend/manager/models/image.py b/src/ai/backend/manager/models/image.py index 6e28793d1b..0e8ef81001 100644 --- a/src/ai/backend/manager/models/image.py +++ b/src/ai/backend/manager/models/image.py @@ -15,7 +15,6 @@ Sequence, Tuple, Union, - cast, ) import aiotools @@ -29,10 +28,10 @@ from ai.backend.common import redis_helper from ai.backend.common.docker import ImageRef -from ai.backend.common.etcd import AsyncEtcd from ai.backend.common.exception import UnknownImageReference from ai.backend.common.logging import BraceStyleAdapter from ai.backend.common.types import BinarySize, ImageAlias, ResourceSlot +from ai.backend.manager.models.container_registry import ContainerRegistryRow from ..api.exceptions import ImageNotFound from ..container_registry import get_container_registry_cls @@ -77,16 +76,12 @@ async def rescan_images( - etcd: AsyncEtcd, db: ExtendedAsyncSAEngine, registry_or_image: str | None = None, *, local: bool | None = False, reporter: ProgressReporter | None = None, ) -> None: - # cannot import ai.backend.manager.config at start due to circular import - from ..config import container_registry_iv - if local: registries = { "local": { @@ -98,13 +93,10 @@ async def rescan_images( }, } else: - registry_config_iv = t.Mapping(t.String, container_registry_iv) - latest_registry_config = cast( - dict[str, Any], - registry_config_iv.check( - await etcd.get_prefix("config/docker/registry"), - ), - ) + async with db.begin_readonly_session() as session: + result = await session.execute(sa.select(ContainerRegistryRow)) + latest_registry_config = {row.hostname: row for row in result.scalars().all()} + # TODO: delete images from registries removed from the previous config? if registry_or_image is None: # scan all configured registries @@ -740,7 +732,7 @@ async def mutate( ctx: GraphQueryContext = info.context async def _rescan_task(reporter: ProgressReporter) -> None: - await rescan_images(ctx.etcd, ctx.db, registry, reporter=reporter) + await rescan_images(ctx.db, registry, reporter=reporter) task_id = await ctx.background_task_manager.start(_rescan_task) return RescanImages(ok=True, msg="", task_id=task_id) diff --git a/tests/manager/conftest.py b/tests/manager/conftest.py index 6453dfb41f..026f6ebc8a 100644 --- a/tests/manager/conftest.py +++ b/tests/manager/conftest.py @@ -249,15 +249,7 @@ def etcd_fixture( }, "nodes": {}, "config": { - "docker": { - "registry": { - "cr.backend.ai": { - "": "https://cr.backend.ai", - "type": "harbor2", - "project": "stable", - }, - }, - }, + "docker": {}, "redis": { "addr": f"{redis_addr.host}:{redis_addr.port}", }, diff --git a/tests/manager/models/test_etcd.py b/tests/manager/models/test_etcd.py deleted file mode 100644 index 7996e7b3e1..0000000000 --- a/tests/manager/models/test_etcd.py +++ /dev/null @@ -1,238 +0,0 @@ -import pytest -from graphene import Schema -from graphene.test import Client - -from ai.backend.manager.config import SharedConfig -from ai.backend.manager.defs import PASSWORD_PLACEHOLDER -from ai.backend.manager.models.gql import GraphQueryContext, Mutations, Queries -from ai.backend.testutils.bootstrap import etcd_container # noqa: F401 - -CONTAINER_REGISTRY_FIELDS = """ - container_registry { - hostname - config { - url - type - project - username - password - ssl_verify - } - } -""" - - -@pytest.fixture(scope="module") -def client() -> Client: - return Client(Schema(query=Queries, mutation=Mutations, auto_camelcase=False)) - - -@pytest.fixture(scope="module") -def context(etcd_container) -> GraphQueryContext: # noqa: F811 - shared_config = SharedConfig( - etcd_addr=etcd_container[1], - etcd_user="", - etcd_password="", - namespace="local", - ) - return GraphQueryContext( - schema=None, # type: ignore - dataloader_manager=None, # type: ignore - local_config=None, # type: ignore - shared_config=shared_config, # type: ignore - etcd=None, # type: ignore - user={"domain": "default", "role": "superadmin"}, - access_key="AKIAIOSFODNN7EXAMPLE", - db=None, # type: ignore - redis_stat=None, # type: ignore - redis_image=None, # type: ignore - redis_live=None, # type: ignore - manager_status=None, # type: ignore - known_slot_types=None, # type: ignore - background_task_manager=None, # type: ignore - storage_manager=None, # type: ignore - registry=None, # type: ignore - idle_checker_host=None, # type: ignore - ) - - -@pytest.mark.dependency() -@pytest.mark.asyncio -async def test_create_container_registry(client: Client, context: GraphQueryContext): - query = """ - mutation CreateContainerRegistry($hostname: String!, $props: CreateContainerRegistryInput!) { - create_container_registry(hostname: $hostname, props: $props) { - $CONTAINER_REGISTRY_FIELDS - } - } - """.replace("$CONTAINER_REGISTRY_FIELDS", CONTAINER_REGISTRY_FIELDS) - - variables = { - "hostname": "cr.example.com", - "props": { - "url": "http://cr.example.com", - "type": "dockerhub", - "project": ["default"], - "username": "username", - "password": "password", - "ssl_verify": False, - }, - } - - response = await client.execute_async(query, variables=variables, context_value=context) - container_registry = response["data"]["create_container_registry"]["container_registry"] - assert container_registry["hostname"] == "cr.example.com" - assert container_registry["config"] == { - "url": "http://cr.example.com", - "type": "dockerhub", - "project": ["default"], - "username": "username", - "password": PASSWORD_PLACEHOLDER, - "ssl_verify": False, - } - - -@pytest.mark.dependency(depends=["test_create_container_registry"]) -@pytest.mark.asyncio -async def test_modify_container_registry(client: Client, context: GraphQueryContext): - query = """ - mutation ModifyContainerRegistry($hostname: String!, $props: ModifyContainerRegistryInput!) { - modify_container_registry(hostname: $hostname, props: $props) { - $CONTAINER_REGISTRY_FIELDS - } - } - """.replace("$CONTAINER_REGISTRY_FIELDS", CONTAINER_REGISTRY_FIELDS) - - variables = { - "hostname": "cr.example.com", - "props": { - "username": "username2", - }, - } - - response = await client.execute_async(query, variables=variables, context_value=context) - container_registry = response["data"]["modify_container_registry"]["container_registry"] - assert container_registry["hostname"] == "cr.example.com" - assert container_registry["config"]["url"] == "http://cr.example.com" - assert container_registry["config"]["type"] == "dockerhub" - assert container_registry["config"]["project"] == ["default"] - assert container_registry["config"]["username"] == "username2" - assert container_registry["config"]["ssl_verify"] is False - - variables = { - "hostname": "cr.example.com", - "props": { - "url": "http://cr2.example.com", - "type": "harbor2", - "project": ["default", "example"], - }, - } - - response = await client.execute_async(query, variables=variables, context_value=context) - container_registry = response["data"]["modify_container_registry"]["container_registry"] - assert container_registry["hostname"] == "cr.example.com" - assert container_registry["config"]["url"] == "http://cr2.example.com" - assert container_registry["config"]["type"] == "harbor2" - assert container_registry["config"]["project"] == ["default", "example"] - assert container_registry["config"]["username"] == "username2" - assert container_registry["config"]["ssl_verify"] is False - - -@pytest.mark.dependency(depends=["test_modify_container_registry"]) -@pytest.mark.asyncio -async def test_modify_container_registry_allows_empty_string( - client: Client, context: GraphQueryContext -): - query = """ - mutation ModifyContainerRegistry($hostname: String!, $props: ModifyContainerRegistryInput!) { - modify_container_registry(hostname: $hostname, props: $props) { - $CONTAINER_REGISTRY_FIELDS - } - } - """.replace("$CONTAINER_REGISTRY_FIELDS", CONTAINER_REGISTRY_FIELDS) - - # Given an empty string to password - variables = { - "hostname": "cr.example.com", - "props": { - "password": "", - }, - } - - # Then password is set to empty string - response = await client.execute_async(query, variables=variables, context_value=context) - container_registry = response["data"]["modify_container_registry"]["container_registry"] - assert container_registry["hostname"] == "cr.example.com" - assert container_registry["config"]["url"] == "http://cr2.example.com" - assert container_registry["config"]["type"] == "harbor2" - assert container_registry["config"]["project"] == ["default", "example"] - assert container_registry["config"]["username"] == "username2" - assert container_registry["config"]["ssl_verify"] is False - - # Direct access to the etcd to reveal that the password is actually set as an empty string - raw_container_registry = await context.shared_config.get_container_registry("cr.example.com") - assert raw_container_registry["password"] == "" - - -@pytest.mark.dependency(depends=["test_modify_container_registry_allows_empty_string"]) -@pytest.mark.asyncio -async def test_modify_container_registry_allows_null_for_unset( - client: Client, context: GraphQueryContext -): - query = """ - mutation ModifyContainerRegistry($hostname: String!, $props: ModifyContainerRegistryInput!) { - modify_container_registry(hostname: $hostname, props: $props) { - $CONTAINER_REGISTRY_FIELDS - } - } - """.replace("$CONTAINER_REGISTRY_FIELDS", CONTAINER_REGISTRY_FIELDS) - - # Given a null to password - variables = { - "hostname": "cr.example.com", - "props": { - "password": None, - }, - } - - # Then password is unset - response = await client.execute_async(query, variables=variables, context_value=context) - container_registry = response["data"]["modify_container_registry"]["container_registry"] - assert container_registry["hostname"] == "cr.example.com" - assert container_registry["config"]["url"] == "http://cr2.example.com" - assert container_registry["config"]["type"] == "harbor2" - assert container_registry["config"]["project"] == ["default", "example"] - assert container_registry["config"]["username"] == "username2" - assert container_registry["config"]["password"] is None - assert container_registry["config"]["ssl_verify"] is False - - -@pytest.mark.dependency(depends=["test_modify_container_registry_allows_null_for_unset"]) -@pytest.mark.asyncio -async def test_delete_container_registry(client: Client, context: GraphQueryContext): - query = """ - mutation DeleteContainerRegistry($hostname: String!) { - delete_container_registry(hostname: $hostname) { - $CONTAINER_REGISTRY_FIELDS - } - } - """.replace("$CONTAINER_REGISTRY_FIELDS", CONTAINER_REGISTRY_FIELDS) - - variables = { - "hostname": "cr.example.com", - } - - response = await client.execute_async(query, variables=variables, context_value=context) - container_registry = response["data"]["delete_container_registry"]["container_registry"] - assert container_registry["hostname"] == "cr.example.com" - - query = """ - query ContainerRegistry($hostname: String!) { - container_registry(hostname: $hostname) { - $CONTAINER_REGISTRY_FIELDS - } - } - """.replace("$CONTAINER_REGISTRY_FIELDS", CONTAINER_REGISTRY_FIELDS) - - response = await client.execute_async(query, variables=variables, context_value=context) - assert response["data"] is None diff --git a/tests/manager/test_config.py b/tests/manager/test_config.py index ba494c3e73..6c52b9facd 100644 --- a/tests/manager/test_config.py +++ b/tests/manager/test_config.py @@ -5,8 +5,6 @@ from ai.backend.manager.config import ( SharedConfig, - container_registry_iv, - container_registry_serialize, ) @@ -43,207 +41,3 @@ def test_shared_config_flatten(): "key": [0, 1, 2], # undefined serialization }, ) - - -def test_container_registry_iv() -> None: - data = container_registry_iv.check({ - "": "http://user:passwd@example.com:8080/registry", - "username": "hello", - "password": "world", - "project": "", - }) - assert isinstance(data[""], yarl.URL) - assert data["project"] == [] - assert data["ssl_verify"] is True - - data = container_registry_iv.check({ - "": "http://user:passwd@example.com:8080/registry", - "username": "hello", - "password": "world", - "project": "a,b,c", - "ssl_verify": "false", # accepts various true/false expressions in strings - }) - assert isinstance(data[""], yarl.URL) - assert data["project"] == ["a", "b", "c"] - assert data["ssl_verify"] is False - - data = container_registry_iv.check({ - "": "http://user:passwd@example.com:8080/registry", - "type": "harbor2", - "project": ["x", "y", "z"], # already structured - }) - assert isinstance(data[""], yarl.URL) - assert data["type"] == "harbor2" - assert data["project"] == ["x", "y", "z"] - assert data["ssl_verify"] is True - - serialized_data = container_registry_serialize(data) - assert isinstance(serialized_data[""], str) - assert serialized_data["type"] == "harbor2" - assert serialized_data["project"] == "x,y,z" - assert serialized_data["ssl_verify"] == "1" - deserialized_data = container_registry_iv.check(serialized_data) - assert isinstance(deserialized_data[""], yarl.URL) - assert deserialized_data["type"] == "harbor2" - assert deserialized_data["project"] == ["x", "y", "z"] - assert deserialized_data["ssl_verify"] is True - - -@pytest.mark.asyncio -async def test_shared_config_add_and_list_container_registry(test_ns, etcd_container) -> None: - container_id, etcd_host_port = etcd_container - shared_config = SharedConfig(etcd_host_port, None, None, test_ns) - - items = await shared_config.list_container_registry() - assert len(items) == 0 - - await shared_config.add_container_registry( - "docker.internal:8080/registry", # special chars are auto-quoted - { - "": "https://docker.internal:8080/registry", - "project": "wow,bar,baz", - "username": "admin", - "password": "dummy", - "ssl_verify": "0", # accepts various true/false expressions in strings - }, - ) - items = await shared_config.list_container_registry() - # The results are automatically unquoted and parsed. - pprint(items) - assert len(items) == 1 - assert isinstance(items["docker.internal:8080/registry"][""], yarl.URL) - assert items["docker.internal:8080/registry"]["project"] == ["wow", "bar", "baz"] - assert items["docker.internal:8080/registry"]["username"] == "admin" - assert items["docker.internal:8080/registry"]["password"] == "dummy" - assert items["docker.internal:8080/registry"]["ssl_verify"] is False - - -@pytest.mark.asyncio -async def test_shared_config_modify_container_registry(test_ns, etcd_container) -> None: - container_id, etcd_host_port = etcd_container - shared_config = SharedConfig(etcd_host_port, None, None, test_ns) - - await shared_config.add_container_registry( - "docker.internal:8080/registry", - { - "": "https://docker.internal:8080/registry", - "project": "wow", - "username": "admin", - "password": "dummy", - "ssl_verify": "true", - }, - ) - await shared_config.add_container_registry( - "docker.internal:8080/registry2", # shares the prefix - { - "": "https://docker.internal:8080/registry2", - "project": "wow", - "username": "admin", - "password": "dummy", - "ssl-verify": "1", # test the key aliasing - }, - ) - - items = await shared_config.list_container_registry() - print("--> before modification") - pprint(items) - assert len(items) == 2 - assert items["docker.internal:8080/registry"][""] == yarl.URL( - "https://docker.internal:8080/registry" - ) - assert items["docker.internal:8080/registry"]["project"] == ["wow"] - assert items["docker.internal:8080/registry"]["username"] == "admin" - assert items["docker.internal:8080/registry"]["password"] == "dummy" - assert items["docker.internal:8080/registry"]["ssl_verify"] is True - assert items["docker.internal:8080/registry2"][""] == yarl.URL( - "https://docker.internal:8080/registry2" - ) - assert items["docker.internal:8080/registry2"]["project"] == ["wow"] - assert items["docker.internal:8080/registry2"]["username"] == "admin" - assert items["docker.internal:8080/registry2"]["password"] == "dummy" - assert items["docker.internal:8080/registry2"]["ssl_verify"] is True - - # modify the first registry - await shared_config.modify_container_registry( - "docker.internal:8080/registry", - { - "": "https://docker.internal:8080/registry_first", - "project": "foo,bar", - "ssl-verify": "0", # test the key aliasing - }, - ) - - items = await shared_config.list_container_registry() - print("--> after modification") - pprint(items) - assert len(items) == 2 - assert items["docker.internal:8080/registry"][""] == yarl.URL( - "https://docker.internal:8080/registry_first" # modified - ) - assert items["docker.internal:8080/registry"]["project"] == ["foo", "bar"] # modified - assert items["docker.internal:8080/registry"]["username"] == "admin" # unmodified - assert items["docker.internal:8080/registry"]["password"] == "dummy" # unmodified - assert items["docker.internal:8080/registry"]["ssl_verify"] is False # modified - assert items["docker.internal:8080/registry2"][""] == yarl.URL( - "https://docker.internal:8080/registry2" # should not be modified - ) - assert items["docker.internal:8080/registry2"]["project"] == ["wow"] - assert items["docker.internal:8080/registry2"]["username"] == "admin" - assert items["docker.internal:8080/registry2"]["password"] == "dummy" # should not be modified - assert items["docker.internal:8080/registry2"]["ssl_verify"] is True # should not be modified - - -@pytest.mark.asyncio -async def test_shared_config_delete_container_registry(test_ns, etcd_container) -> None: - container_id, etcd_host_port = etcd_container - shared_config = SharedConfig(etcd_host_port, None, None, test_ns) - - await shared_config.add_container_registry( - "docker.internal:8080/registry", - { - "": "https://docker.internal:8080/registry", - "project": "wow", - "username": "admin", - "password": "dummy", - }, - ) - await shared_config.add_container_registry( - "docker.internal:8080/registry2", # shares the prefix - { - "": "https://docker.internal:8080/registry2", - "project": "wow", - "username": "admin", - "password": "waldo", - }, - ) - - items = await shared_config.list_container_registry() - print("--> before deletion") - pprint(items) - assert len(items) == 2 - assert items["docker.internal:8080/registry"][""] == yarl.URL( - "https://docker.internal:8080/registry" - ) - assert items["docker.internal:8080/registry"]["project"] == ["wow"] - assert items["docker.internal:8080/registry"]["username"] == "admin" - assert items["docker.internal:8080/registry"]["password"] == "dummy" - assert items["docker.internal:8080/registry2"][""] == yarl.URL( - "https://docker.internal:8080/registry2" - ) - assert items["docker.internal:8080/registry2"]["project"] == ["wow"] - assert items["docker.internal:8080/registry2"]["username"] == "admin" - assert items["docker.internal:8080/registry2"]["password"] == "waldo" - - # delete the first registry - await shared_config.delete_container_registry("docker.internal:8080/registry") - - items = await shared_config.list_container_registry() - print("--> after deletion") - pprint(items) - assert len(items) == 1 - assert items["docker.internal:8080/registry2"][""] == yarl.URL( - "https://docker.internal:8080/registry2" # should not be modified - ) - assert items["docker.internal:8080/registry2"]["project"] == ["wow"] - assert items["docker.internal:8080/registry2"]["username"] == "admin" - assert items["docker.internal:8080/registry2"]["password"] == "waldo"