Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NAS-130876 / 25.04 / Allow logging in to different docker registries #15346

Merged
merged 1 commit into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""
App registries support

Revision ID: 799718dc329e
Revises: 899852cb2a92
Create Date: 2025-01-13 20:25:41.855489+00:00

"""
from alembic import op
import sqlalchemy as sa


revision = '799718dc329e'
down_revision = '899852cb2a92'
branch_labels = None
depends_on = None


def upgrade():
op.create_table(
'app_registry',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.String(length=512), nullable=True, default=None),
sa.Column('username', sa.String(length=255), nullable=False),
sa.Column('password', sa.String(length=255), nullable=False),
sa.Column('uri', sa.String(length=512), nullable=False, unique=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_app_registry')),
sqlite_autoincrement=True,
)


def downgrade():
pass
1 change: 1 addition & 0 deletions src/middlewared/middlewared/api/v25_04_0/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .app import * # noqa
from .app_image import * # noqa
from .app_ix_volume import * # noqa
from .app_registry import * # noqa
from .auth import * # noqa
from .boot_environments import * # noqa
from .catalog import * # noqa
Expand Down
1 change: 1 addition & 0 deletions src/middlewared/middlewared/api/v25_04_0/app_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class AppImageDockerhubRateLimitResult(BaseModel):
class AppImageAuthConfig(BaseModel):
username: str
password: str
registry_uri: str | None = None


@single_argument_args('image_pull')
Expand Down
52 changes: 52 additions & 0 deletions src/middlewared/middlewared/api/v25_04_0/app_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from pydantic import Secret

from middlewared.api.base import BaseModel, Excluded, excluded_field, ForUpdateMetaclass


__all__ = [
'AppRegistryEntry', 'AppRegistryCreateArgs', 'AppRegistryCreateResult', 'AppRegistryUpdateArgs',
'AppRegistryUpdateResult', 'AppRegistryDeleteArgs', 'AppRegistryDeleteResult',
]


class AppRegistryEntry(BaseModel):
id: int
name: str
description: str | None = None
username: Secret[str]
password: Secret[str]
uri: str


class AppRegistryCreate(AppRegistryEntry):
id: Excluded = excluded_field()
uri: str = 'https://registry-1.docker.io/'


class AppRegistryCreateArgs(BaseModel):
app_registry_create: AppRegistryCreate


class AppRegistryCreateResult(BaseModel):
result: AppRegistryEntry


class AppRegistryUpdate(AppRegistryCreate, metaclass=ForUpdateMetaclass):
pass


class AppRegistryUpdateArgs(BaseModel):
id: int
data: AppRegistryUpdate


class AppRegistryUpdateResult(BaseModel):
result: AppRegistryEntry


class AppRegistryDeleteArgs(BaseModel):
id: int


class AppRegistryDeleteResult(BaseModel):
result: None
15 changes: 15 additions & 0 deletions src/middlewared/middlewared/etc_files/docker/config.json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import json
import os

from middlewared.plugins.app_registry.utils import generate_docker_auth_config
from middlewared.plugins.etc import FileShouldNotExist


def render(service, middleware):
config = middleware.call_sync('docker.config')
if not config['pool']:
raise FileShouldNotExist()

os.makedirs('/etc/docker', exist_ok=True)

return json.dumps(generate_docker_auth_config(middleware.call_sync('app.registry.query')))
Empty file.
94 changes: 94 additions & 0 deletions src/middlewared/middlewared/plugins/app_registry/crud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import middlewared.sqlalchemy as sa
from middlewared.api import api_method
from middlewared.api.current import (
AppRegistryEntry, AppRegistryCreateArgs, AppRegistryCreateResult, AppRegistryUpdateArgs,
AppRegistryUpdateResult, AppRegistryDeleteArgs, AppRegistryDeleteResult,
)
from middlewared.service import CRUDService, private, ValidationErrors

from .validate_registry import validate_registry_credentials


class AppRegistryModel(sa.Model):
__tablename__ = 'app_registry'

id = sa.Column(sa.Integer(), primary_key=True)
name = sa.Column(sa.String(255), nullable=False)
description = sa.Column(sa.String(512), nullable=True, default=None)
username = sa.Column(sa.EncryptedText(), nullable=False)
password = sa.Column(sa.EncryptedText(), nullable=False)
uri = sa.Column(sa.String(512), nullable=False, unique=True)


class AppRegistryService(CRUDService):

class Config:
namespace = 'app.registry'
datastore = 'app.registry'
cli_namespace = 'app.registry'
entry = AppRegistryEntry
role_prefix = 'APPS'

@private
async def validate(self, data, old=None, schema='app_registry_create'):
verrors = ValidationErrors()

filters = [['id', '!=', old['id']]] if old else []
if await self.query([['name', '=', data['name']]] + filters):
verrors.add(f'{schema}.name', 'Name must be unique')

if data['uri'].startswith('http') and not data['uri'].endswith('/'):
# We can have 2 formats basically
# https://index.docker.io/v1/
# registry-1.docker.io
# We would like to have a trailing slash here because we are not able to pull images without it
# if http based url is provided
data['uri'] = data['uri'] + '/'

if await self.query([['uri', '=', data['uri']]] + filters):
verrors.add(f'{schema}.uri', 'URI must be unique')

if not verrors and await self.middleware.run_in_thread(
validate_registry_credentials, data['uri'], data['username'], data['password']
) is False:
verrors.add(f'{schema}.uri', 'Invalid credentials for registry')

verrors.check()

@api_method(AppRegistryCreateArgs, AppRegistryCreateResult, roles=['APPS_WRITE'])
async def do_create(self, data):
"""
Create an app registry entry.
"""
await self.middleware.call('docker.state.validate')
await self.validate(data)
id_ = await self.middleware.call('datastore.insert', 'app.registry', data)
await self.middleware.call('etc.generate', 'app_registry')
return await self.get_instance(id_)

@api_method(AppRegistryUpdateArgs, AppRegistryUpdateResult, roles=['APPS_WRITE'])
async def do_update(self, id_, data):
"""
Update an app registry entry.
"""
await self.middleware.call('docker.state.validate')
old = await self.get_instance(id_)
new = old.copy()
new.update(data)

await self.validate(new, old=old, schema='app_registry_update')

await self.middleware.call('datastore.update', 'app.registry', id_, new)

await self.middleware.call('etc.generate', 'app_registry')
return await self.get_instance(id_)

@api_method(AppRegistryDeleteArgs, AppRegistryDeleteResult, roles=['APPS_WRITE'])
async def do_delete(self, id_):
"""
Delete an app registry entry.
"""
await self.middleware.call('docker.state.validate')
await self.get_instance(id_)
await self.middleware.call('datastore.delete', 'app.registry', id_)
await self.middleware.call('etc.generate', 'app_registry')
14 changes: 14 additions & 0 deletions src/middlewared/middlewared/plugins/app_registry/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import base64


def generate_docker_auth_config(auth_list: list[dict[str, str]]) -> dict:
auths = {}
for auth in auth_list:
auths[auth['uri']] = {
# Encode username:password in base64
'auth': base64.b64encode(f'{auth["username"]}:{auth["password"]}'.encode()).decode(),
}

return {
'auths': auths,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from docker.errors import APIError, DockerException

from middlewared.plugins.apps.ix_apps.docker.utils import get_docker_client


def validate_registry_credentials(registry: str, username: str, password: str) -> bool:
"""
Validates Docker registry credentials using the Docker SDK.

Args:
registry (str): The URL of the Docker registry (e.g., "registry1.example.com").
username (str): The username for the registry.
password (str): The password for the registry.

Returns:
bool: True if the credentials are valid, False otherwise.
"""
with get_docker_client() as client:
try:
client.login(username=username, password=password, registry=registry)
except (APIError, DockerException):
return False
else:
return True

return False
2 changes: 1 addition & 1 deletion src/middlewared/middlewared/plugins/apps/compose_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def compose_action(
raise CallError(f'Invalid action {action!r} for app {app_name!r}')

# TODO: We will likely have a configurable timeout on this end
cp = run(['docker', 'compose'] + compose_files + args, timeout=1200)
cp = run(['docker', '--config', '/etc/docker', 'compose'] + compose_files + args, timeout=1200)
if cp.returncode != 0:
logger.error('Failed %r action for %r app: %s', action, app_name, cp.stderr)
err_msg = f'Failed {action!r} action for {app_name!r} app.'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ def list_images() -> list[dict]:


def pull_image(
image_tag: str, callback: typing.Callable = None, username: str | None = None, password: str | None = None
image_tag: str, callback: typing.Callable = None, username: str | None = None, password: str | None = None,
registry_uri: str | None = None,
):
if username and not password:
raise CallError('Password is required when username is provided')
Expand All @@ -29,6 +30,7 @@ def pull_image(
auth_config = {
'username': username,
'password': password,
'registry': registry_uri,
} if username else None

with get_docker_client() as client:
Expand Down
17 changes: 14 additions & 3 deletions src/middlewared/middlewared/plugins/apps_images/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from middlewared.service import CRUDService, filterable, job
from middlewared.utils import filter_list

from .utils import parse_tags
from .utils import get_normalized_auth_config, parse_tags


class AppImageService(CRUDService):
Expand Down Expand Up @@ -77,9 +77,20 @@ def callback(entry):
job.set_progress((progress['current']/progress['total']) * 90, 'Pulling image')

self.middleware.call_sync('docker.state.validate')
auth_config = data['auth_config'] or {}
image_tag = data['image']
pull_image(image_tag, callback, auth_config.get('username'), auth_config.get('password'))
auth_config = data['auth_config'] or {}
if not auth_config:
# If user has not provided any auth creds, we will try to see if the registry to which the image
# belongs to, we already have it's creds and if yes we will try to use that when pulling the image
app_registries = {
registry['uri']: registry for registry in self.middleware.call_sync('app.registry.query')
}
auth_config = get_normalized_auth_config(app_registries, image_tag)

pull_image(
image_tag, callback, auth_config.get('username'), auth_config.get('password'),
auth_config.get('registry_uri'),
)
job.set_progress(100, f'{image_tag!r} image pulled successfully')

@api_method(AppImageDeleteArgs, AppImageDeleteResult)
Expand Down
15 changes: 15 additions & 0 deletions src/middlewared/middlewared/plugins/apps_images/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,18 @@ def normalize_docker_limits_header(headers: dict) -> dict:
'remaining_time_limit_in_secs': int(remaining_time_limit),
'error': None,
}


def get_normalized_auth_config(registry_info: dict[str, dict], image_tag: str) -> dict:
if not registry_info:
return {}

user_wants_registry = normalize_reference(image_tag)['registry']
if user_wants_registry not in registry_info:
return {}

return {
'registry_uri': user_wants_registry,
'username': registry_info[user_wants_registry]['username'],
'password': registry_info[user_wants_registry]['password'],
}
3 changes: 3 additions & 0 deletions src/middlewared/middlewared/plugins/etc.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ class EtcService(Service):
]

},
'app_registry': [
{'type': 'py', 'path': 'docker/config.json'},
],
'docker': [
{'type': 'py', 'path': 'docker/daemon.json'},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

class DockerService(SimpleService):
name = 'docker'
etc = ['docker']
etc = ['app_registry', 'docker']
systemd_unit = 'docker'

async def before_start(self):
Expand Down
Loading