diff --git a/src/middlewared/middlewared/api/base/decorator.py b/src/middlewared/middlewared/api/base/decorator.py index b26d7bb276434..290cc1675c652 100644 --- a/src/middlewared/middlewared/api/base/decorator.py +++ b/src/middlewared/middlewared/api/base/decorator.py @@ -22,6 +22,9 @@ def api_method( cli_private: bool = False, authentication_required: bool = True, authorization_required: bool = True, + pass_app: bool = False, + pass_app_require: bool = False, + pass_app_rest: bool = False, ): """ Mark a `Service` class method as an API method. @@ -58,6 +61,13 @@ def api_method( raise TypeError("`returns` model must only have one field called `result`") def wrapper(func): + if pass_app: + # Pass the application instance as parameter to the method + func._pass_app = { + 'require': pass_app_require, + 'rest': pass_app_rest, + } + args_index = calculate_args_index(func, audit_callback) if asyncio.iscoroutinefunction(func): diff --git a/src/middlewared/middlewared/api/v25_04_0/__init__.py b/src/middlewared/middlewared/api/v25_04_0/__init__.py index a490c0e769439..0409e79f782b5 100644 --- a/src/middlewared/middlewared/api/v25_04_0/__init__.py +++ b/src/middlewared/middlewared/api/v25_04_0/__init__.py @@ -6,6 +6,7 @@ from .api_key import * # noqa from .app import * # noqa from .app_image import * # noqa +from .app_ix_volume import * # noqa from .auth import * # noqa from .boot_environments import * # noqa from .catalog import * # noqa diff --git a/src/middlewared/middlewared/api/v25_04_0/app.py b/src/middlewared/middlewared/api/v25_04_0/app.py index 84bc8ad73626a..8ee85c3a97431 100644 --- a/src/middlewared/middlewared/api/v25_04_0/app.py +++ b/src/middlewared/middlewared/api/v25_04_0/app.py @@ -1,13 +1,372 @@ -from middlewared.api.base import BaseModel, NonEmptyString +from typing import Literal, TypeAlias + +from pydantic import ConfigDict, Field, RootModel, Secret + +from middlewared.api.base import BaseModel, LongString, NonEmptyString, single_argument_args, single_argument_result from .catalog import CatalogAppInfo __all__ = [ 'AppCategoriesArgs', 'AppCategoriesResult', 'AppSimilarArgs', 'AppSimilarResult', 'AppAvailableResponse', + 'AppEntry', 'AppCreateArgs', 'AppCreateResult', 'AppUpdateArgs', 'AppUpdateResult', 'AppDeleteArgs', + 'AppDeleteResult', 'AppConfigArgs', 'AppConfigResult', 'AppConvertToCustomArgs', 'AppConvertToCustomResult', + 'AppStopArgs', 'AppStopResult', 'AppStartArgs', 'AppStartResult', 'AppRedeployArgs', 'AppRedeployResult', + 'AppOutdatedDockerImagesArgs', 'AppOutdatedDockerImagesResult', 'AppPullImagesArgs', 'AppPullImagesResult', + 'AppContainerIDArgs', 'AppContainerIDResult', 'AppContainerConsoleChoiceArgs', 'AppContainerConsoleChoiceResult', + 'AppCertificateChoicesArgs', 'AppCertificateChoicesResult', 'AppCertificateAuthorityArgs', + 'AppCertificateAuthorityResult', 'AppUsedPortsArgs', 'AppUsedPortsResult', 'AppIPChoicesArgs', 'AppIPChoicesResult', + 'AppAvailableSpaceArgs', 'AppAvailableSpaceResult', 'AppGpuChoicesArgs', 'AppGpuChoicesResult', 'AppRollbackArgs', + 'AppRollbackResult', 'AppRollbackVersionsArgs', 'AppRollbackVersionsResult', 'AppUpgradeArgs', 'AppUpgradeResult', + 'AppUpgradeSummaryArgs', 'AppUpgradeSummaryResult', ] +CONTAINER_STATES: TypeAlias = Literal['crashed', 'created', 'exited', 'running', 'starting'] + + +class HostPorts(BaseModel): + host_port: int + host_ip: str + + +class UsedPorts(BaseModel): + container_port: int + protocol: str + host_ports: list[HostPorts] + + +class AppVolumes(BaseModel): + source: str + destination: str + mode: str + type_: str = Field(alias='type') + + +class AppContainerDetails(BaseModel): + id: str + service_name: str + image: str + port_config: list[UsedPorts] + state: CONTAINER_STATES + volume_mounts: list[AppVolumes] + + +class AppNetworks(BaseModel): + Name: str + Id: str + Labels: dict + + model_config = ConfigDict(extra='allow') + + +class AppActiveWorkloads(BaseModel): + containers: int + used_ports: list[UsedPorts] + container_details: list[AppContainerDetails] + volumes: list[AppVolumes] + images: list[NonEmptyString] + networks: list[AppNetworks] + + +class AppEntry(BaseModel): + name: NonEmptyString + id: NonEmptyString + state: Literal['CRASHED', 'DEPLOYING', 'RUNNING', 'STOPPED', 'STOPPING'] + upgrade_available: bool + latest_version: NonEmptyString | None + image_updates_available: bool + custom_app: bool + migrated: bool + human_version: NonEmptyString + version: NonEmptyString + metadata: dict + active_workloads: AppActiveWorkloads + notes: LongString | None + portals: dict + version_details: dict | None = None + + +@single_argument_args('app_create') +class AppCreateArgs(BaseModel): + custom_app: bool = False + values: Secret[dict] = Field(default_factory=dict) + custom_compose_config: Secret[dict] = Field(default_factory=dict) + custom_compose_config_string: Secret[LongString] = '' + catalog_app: str | None = None + app_name: str = Field(pattern=r'^[a-z]([-a-z0-9]*[a-z0-9])?$', min_length=1, max_length=40) + ''' + Application name must have the following: + 1) Lowercase alphanumeric characters can be specified + 2) Name must start with an alphabetic character and can end with alphanumeric character + 3) Hyphen '-' is allowed but not as the first or last character + e.g abc123, abc, abcd-1232 + ''' + train: NonEmptyString = 'stable' + version: NonEmptyString = 'latest' + + +class AppCreateResult(BaseModel): + result: AppEntry + + +class AppUpdate(BaseModel): + values: Secret[dict] = Field(default_factory=dict) + custom_compose_config: Secret[dict] = Field(default_factory=dict) + custom_compose_config_string: Secret[LongString] = '' + + +class AppUpdateArgs(BaseModel): + app_name: NonEmptyString + update: AppUpdate = AppUpdate() + + +class AppUpdateResult(BaseModel): + result: AppEntry + + +class AppDelete(BaseModel): + remove_images: bool = True + remove_ix_volumes: bool = False + force_remove_ix_volumes: bool = False + force_remove_custom_app: bool = False + + +class AppDeleteArgs(BaseModel): + app_name: NonEmptyString + options: AppDelete = AppDelete() + + +class AppDeleteResult(BaseModel): + result: Literal[True] + + +class AppConfigArgs(BaseModel): + app_name: NonEmptyString + + +class AppConfigResult(BaseModel): + result: dict + + +class AppConvertToCustomArgs(BaseModel): + app_name: NonEmptyString + + +class AppConvertToCustomResult(BaseModel): + result: AppEntry + + +class AppStopArgs(BaseModel): + app_name: NonEmptyString + + +class AppStopResult(BaseModel): + result: None + + +class AppStartArgs(BaseModel): + app_name: NonEmptyString + + +class AppStartResult(BaseModel): + result: None + + +class AppRedeployArgs(BaseModel): + app_name: NonEmptyString + + +class AppRedeployResult(BaseModel): + result: AppEntry + + +class AppOutdatedDockerImagesArgs(BaseModel): + app_name: NonEmptyString + + +class AppOutdatedDockerImagesResult(BaseModel): + result: list[NonEmptyString] + + +class AppPullImages(BaseModel): + redeploy: bool = True + + +class AppPullImagesArgs(BaseModel): + app_name: NonEmptyString + options: AppPullImages = AppPullImages() + + +class AppPullImagesResult(BaseModel): + result: None + + +class AppContainerIDOptions(BaseModel): + alive_only: bool = True + + +class AppContainerIDArgs(BaseModel): + app_name: NonEmptyString + options: AppContainerIDOptions = AppContainerIDOptions() + + +class ContainerDetails(BaseModel): + id: NonEmptyString + service_name: NonEmptyString + image: NonEmptyString + state: CONTAINER_STATES + + +class AppContainerResponse(RootModel[dict[str, ContainerDetails]]): + pass + + +class AppContainerIDResult(BaseModel): + result: AppContainerResponse + + +class AppContainerConsoleChoiceArgs(BaseModel): + app_name: NonEmptyString + + +class AppContainerConsoleChoiceResult(BaseModel): + result: AppContainerResponse + + +class AppCertificateChoicesArgs(BaseModel): + pass + + +class AppCertificate(BaseModel): + id: int + name: NonEmptyString + + +class AppCertificateChoicesResult(BaseModel): + result: list[AppCertificate] + + +class AppCertificateAuthorityArgs(BaseModel): + pass + + +class AppCertificateAuthorityResult(BaseModel): + result: list[AppCertificate] + + +class AppUsedPortsArgs(BaseModel): + pass + + +class AppUsedPortsResult(BaseModel): + result: list[int] + + +class AppIPChoicesArgs(BaseModel): + pass + + +class AppIPChoicesResult(BaseModel): + result: dict[NonEmptyString, NonEmptyString] + + +class AppAvailableSpaceArgs(BaseModel): + pass + + +class AppAvailableSpaceResult(BaseModel): + result: int + + +class AppGpuChoicesArgs(BaseModel): + pass + + +class GPU(BaseModel): + vendor: NonEmptyString | None + description: LongString | None + error: NonEmptyString | None + vendor_specific_config: dict + gpu_details: dict + pci_slot: NonEmptyString | None + + +class AppGPUResponse(RootModel[dict[str, GPU]]): + pass + + +class AppGpuChoicesResult(BaseModel): + result: AppGPUResponse + + +class AppRollbackOptions(BaseModel): + app_version: NonEmptyString + rollback_snapshot: bool = True + + +class AppRollbackArgs(BaseModel): + app_name: NonEmptyString + options: AppRollbackOptions + + +class AppRollbackResult(BaseModel): + result: AppEntry + + +class AppRollbackVersionsArgs(BaseModel): + app_name: NonEmptyString + + +class AppRollbackVersionsResult(BaseModel): + result: list[NonEmptyString] + + +class UpgradeOptions(BaseModel): + app_version: NonEmptyString = 'latest' + values: Secret[dict] = Field(default_factory=dict) + + +class AppUpgradeArgs(BaseModel): + app_name: NonEmptyString + options: UpgradeOptions = UpgradeOptions() + + +class AppUpgradeResult(BaseModel): + result: AppEntry + + +class UpgradeSummaryOptions(BaseModel): + app_version: NonEmptyString = 'latest' + + +class AppUpgradeSummaryArgs(BaseModel): + app_name: NonEmptyString + options: UpgradeSummaryOptions = UpgradeSummaryOptions() + + +class AppVersionInfo(BaseModel): + version: str + '''Version of the app''' + human_version: str + '''Human readable version of the app''' + + +@single_argument_result +class AppUpgradeSummaryResult(BaseModel): + latest_version: str + '''Latest version available for the app''' + latest_human_version: str + '''Latest human readable version available for the app''' + upgrade_version: str + '''Version user has requested to be upgraded at''' + upgrade_human_version: str + '''Human readable version user has requested to be upgraded at''' + available_versions_for_upgrade: list[AppVersionInfo] + '''List of available versions for upgrade''' + changelog: LongString | None + + class AppAvailableResponse(CatalogAppInfo): catalog: NonEmptyString installed: bool diff --git a/src/middlewared/middlewared/api/v25_04_0/app_ix_volume.py b/src/middlewared/middlewared/api/v25_04_0/app_ix_volume.py new file mode 100644 index 0000000000000..069d161b58c8c --- /dev/null +++ b/src/middlewared/middlewared/api/v25_04_0/app_ix_volume.py @@ -0,0 +1,17 @@ +from middlewared.api.base import BaseModel, NonEmptyString + + +__all__ = ['AppIXVolumeEntry', 'AppIXVolumeExistsArgs', 'AppIXVolumeExistsResult'] + + +class AppIXVolumeEntry(BaseModel): + app_name: NonEmptyString + name: NonEmptyString + + +class AppIXVolumeExistsArgs(BaseModel): + name: NonEmptyString + + +class AppIXVolumeExistsResult(BaseModel): + result: bool diff --git a/src/middlewared/middlewared/plugins/apps/app_scale.py b/src/middlewared/middlewared/plugins/apps/app_scale.py index bce0a2914e53c..81830095b180a 100644 --- a/src/middlewared/middlewared/plugins/apps/app_scale.py +++ b/src/middlewared/middlewared/plugins/apps/app_scale.py @@ -1,4 +1,7 @@ -from middlewared.schema import accepts, Str, returns +from middlewared.api import api_method +from middlewared.api.current import ( + AppStartArgs, AppStartResult, AppStopArgs, AppStopResult, AppRedeployArgs, AppRedeployResult, +) from middlewared.service import job, Service from .compose_utils import compose_action @@ -12,8 +15,7 @@ class Config: namespace = 'app' cli_namespace = 'app' - @accepts(Str('app_name'), roles=['APPS_WRITE']) - @returns() + @api_method(AppStopArgs, AppStopResult, roles=['APPS_WRITE']) @job(lock=lambda args: f'app_stop_{args[0]}') def stop(self, job, app_name): """ @@ -39,8 +41,7 @@ def stop(self, job, app_name): ) self.middleware.call_sync('cache.pop', cache_key) - @accepts(Str('app_name'), roles=['APPS_WRITE']) - @returns() + @api_method(AppStartArgs, AppStartResult, roles=['APPS_WRITE']) @job(lock=lambda args: f'app_start_{args[0]}') def start(self, job, app_name): """ @@ -51,8 +52,7 @@ def start(self, job, app_name): compose_action(app_name, app_config['version'], 'up', force_recreate=True, remove_orphans=True) job.set_progress(100, f'Started {app_name!r} app') - @accepts(Str('app_name'), roles=['APPS_WRITE']) - @returns() + @api_method(AppRedeployArgs, AppRedeployResult, roles=['APPS_WRITE']) @job(lock=lambda args: f'app_redeploy_{args[0]}') async def redeploy(self, job, app_name): """ diff --git a/src/middlewared/middlewared/plugins/apps/crud.py b/src/middlewared/middlewared/plugins/apps/crud.py index c7e2efeb5b6b5..be3fd5cb3a14b 100644 --- a/src/middlewared/middlewared/plugins/apps/crud.py +++ b/src/middlewared/middlewared/plugins/apps/crud.py @@ -1,17 +1,18 @@ import contextlib import errno -import os import shutil -import textwrap from catalog_reader.custom_app import get_version_details -from middlewared.schema import accepts, Bool, Dict, Int, List, Ref, returns, Str +from middlewared.api import api_method +from middlewared.api.current import ( + AppEntry, AppCreateArgs, AppCreateResult, AppUpdateArgs, AppUpdateResult, AppDeleteArgs, AppDeleteResult, + AppConfigArgs, AppConfigResult, AppConvertToCustomArgs, AppConvertToCustomResult, +) from middlewared.service import ( - CallError, CRUDService, filterable, InstanceNotFound, job, pass_app, private, ValidationErrors + CallError, CRUDService, filterable_api_method, InstanceNotFound, job, private, ValidationErrors ) from middlewared.utils import filter_list -from middlewared.validators import Match, Range from .compose_utils import compose_action from .custom_app_utils import validate_payload @@ -20,7 +21,6 @@ from .ix_apps.path import get_app_parent_volume_ds, get_installed_app_path, get_installed_app_version_path from .ix_apps.query import list_apps from .ix_apps.setup import setup_install_app_dir -from .ix_apps.utils import AppState from .version_utils import get_latest_version_from_app_versions @@ -31,55 +31,9 @@ class Config: event_send = False cli_namespace = 'app' role_prefix = 'APPS' + entry = AppEntry - ENTRY = Dict( - 'app_entry', - Str('name'), - Str('id'), - Str('state', enum=[state.value for state in AppState]), - Bool('upgrade_available'), - Str('human_version'), - Str('version'), - Dict('metadata', additional_attrs=True), - Dict( - 'active_workloads', - Int('containers'), - List('used_ports', items=[Dict( - 'used_port', - Str('container_port'), - Str('protocol'), - List('host_ports', items=[Dict( - 'host_port', - Str('host_port'), - Str('host_ip'), - )]), - additional_attrs=True, - )]), - List('container_details', items=[Dict( - 'container_detail', - Str('id'), - Str('service_name'), - Str('image'), - List('port_config'), - Str('state', enum=['running', 'starting', 'exited']), - List('volume_mounts'), - additional_attrs=True, - )]), - List('volumes', items=[Dict( - 'volume', - Str('source'), - Str('destination'), - Str('mode'), - Str('type'), - additional_attrs=True, - )]), - additional_attrs=True, - ), - additional_attrs=True, - ) - - @filterable - @pass_app(rest=True) + @filterable_api_method(item=AppEntry, pass_app=True, pass_app_rest=True) def query(self, app, filters, options): """ Query all apps with `query-filters` and `query-options`. @@ -133,8 +87,7 @@ def query(self, app, filters, options): return filter_list(apps, filters, options) - @accepts(Str('app_name'), roles=['APPS_READ']) - @returns(Dict('app_config', additional_attrs=True)) + @api_method(AppConfigArgs, AppConfigResult, roles=['APPS_READ']) def config(self, app_name): """ Retrieve user specified configuration of `app_name`. @@ -142,8 +95,7 @@ def config(self, app_name): app = self.get_instance__sync(app_name) return get_current_app_config(app_name, app['version']) - @accepts(Str('app_name'), roles=['APPS_WRITE']) - @returns(Ref('app_entry')) + @api_method(AppConvertToCustomArgs, AppConvertToCustomResult, roles=['APPS_WRITE']) @job(lock=lambda args: f'app_start_{args[0]}') async def convert_to_custom(self, job, app_name): """ @@ -151,32 +103,7 @@ async def convert_to_custom(self, job, app_name): """ return await self.middleware.call('app.custom.convert', job, app_name) - @accepts( - Dict( - 'app_create', - Bool('custom_app', default=False), - Dict('values', additional_attrs=True, private=True), - Dict('custom_compose_config', additional_attrs=True, private=True), - Str('custom_compose_config_string', private=True, max_length=2**31), - Str('catalog_app', required=False), - Str( - 'app_name', required=True, validators=[Match( - r'^[a-z]([-a-z0-9]*[a-z0-9])?$', - explanation=textwrap.dedent( - ''' - Application name must have the following: - 1) Lowercase alphanumeric characters can be specified - 2) Name must start with an alphabetic character and can end with alphanumeric character - 3) Hyphen '-' is allowed but not as the first or last character - e.g abc123, abc, abcd-1232 - ''' - ) - ), Range(min_=1, max_=40)] - ), - Str('train', default='stable'), - Str('version', default='latest'), - ) - ) + @api_method(AppCreateArgs, AppCreateResult, roles=['APPS_WRITE']) @job(lock=lambda args: f'app_create_{args[0].get("app_name")}') def do_create(self, job, data): """ @@ -277,15 +204,7 @@ def remove_failed_resources(self, app_name, version, remove_ds=False): self.middleware.call_sync('app.metadata.generate').wait_sync(raise_error=True) self.middleware.send_event('app.query', 'REMOVED', id=app_name) - @accepts( - Str('app_name'), - Dict( - 'app_update', - Dict('values', additional_attrs=True, private=True), - Dict('custom_compose_config', additional_attrs=True, private=True), - Str('custom_compose_config_string', private=True, max_length=2**31), - ) - ) + @api_method(AppUpdateArgs, AppUpdateResult, roles=['APPS_WRITE']) @job(lock=lambda args: f'app_update_{args[0]}') def do_update(self, job, app_name, data): """ @@ -335,16 +254,7 @@ def update_internal(self, job, app, data, progress_keyword='Update', trigger_com job.set_progress(100, f'{progress_keyword} completed for {app_name!r}') return self.get_instance__sync(app_name) - @accepts( - Str('app_name'), - Dict( - 'options', - Bool('remove_images', default=True), - Bool('remove_ix_volumes', default=False), - Bool('force_remove_ix_volumes', default=False), - Bool('force_remove_custom_app', default=False), - ) - ) + @api_method(AppDeleteArgs, AppDeleteResult, roles=['APPS_WRITE']) @job(lock=lambda args: f'app_delete_{args[0]}') def do_delete(self, job, app_name, options): """ diff --git a/src/middlewared/middlewared/plugins/apps/custom_app.py b/src/middlewared/middlewared/plugins/apps/custom_app.py index e4705ed101556..2bd57f59ea0c2 100644 --- a/src/middlewared/middlewared/plugins/apps/custom_app.py +++ b/src/middlewared/middlewared/plugins/apps/custom_app.py @@ -1,6 +1,3 @@ -import contextlib -import shutil - from catalog_reader.custom_app import get_version_details from middlewared.service import CallError, Service @@ -9,7 +6,6 @@ from .custom_app_utils import validate_payload from .ix_apps.lifecycle import get_rendered_template_config_of_app, update_app_config from .ix_apps.metadata import update_app_metadata -from .ix_apps.path import get_installed_app_path from .ix_apps.setup import setup_install_app_dir diff --git a/src/middlewared/middlewared/plugins/apps/ix_volumes.py b/src/middlewared/middlewared/plugins/apps/ix_volumes.py index 4229b1593dd72..21e45d8a66095 100644 --- a/src/middlewared/middlewared/plugins/apps/ix_volumes.py +++ b/src/middlewared/middlewared/plugins/apps/ix_volumes.py @@ -1,7 +1,8 @@ import collections -from middlewared.schema import accepts, Bool, Dict, returns, Str -from middlewared.service import filterable, filterable_returns, Service +from middlewared.api import api_method +from middlewared.api.current import AppIXVolumeEntry, AppIXVolumeExistsArgs, AppIXVolumeExistsResult +from middlewared.service import filterable_api_method, Service from middlewared.utils import filter_list from .ix_apps.path import get_app_mounts_ds @@ -13,14 +14,9 @@ class Config: namespace = 'app.ix_volume' event_send = False cli_namespace = 'app.ix_volume' + entry = AppIXVolumeEntry - @filterable(roles=['APPS_READ']) - @filterable_returns(Dict( - 'ix-volumes_query', - Str('app_name'), - Str('name'), - additional_attrs=True, - )) + @filterable_api_method(item=AppIXVolumeEntry, roles=['APPS_READ']) async def query(self, filters, options): """ Query ix-volumes with `filters` and `options`. @@ -49,8 +45,7 @@ async def query(self, filters, options): return filter_list(volumes, filters, options) - @accepts(Str('app_name')) - @returns(Bool('ix_volumes_exist')) + @api_method(AppIXVolumeExistsArgs, AppIXVolumeExistsResult, roles=['APPS_READ']) async def exists(self, app_name): """ Check if ix-volumes exist for `app_name`. diff --git a/src/middlewared/middlewared/plugins/apps/pull_images.py b/src/middlewared/middlewared/plugins/apps/pull_images.py index 682c3a0a69913..24a1b22ed1fb5 100644 --- a/src/middlewared/middlewared/plugins/apps/pull_images.py +++ b/src/middlewared/middlewared/plugins/apps/pull_images.py @@ -1,5 +1,8 @@ +from middlewared.api import api_method +from middlewared.api.current import ( + AppOutdatedDockerImagesArgs, AppOutdatedDockerImagesResult, AppPullImagesArgs, AppPullImagesResult, +) from middlewared.plugins.apps_images.utils import normalize_reference -from middlewared.schema import accepts, Bool, Dict, List, returns, Str from middlewared.service import job, private, Service from .compose_utils import compose_action @@ -11,8 +14,7 @@ class Config: namespace = 'app' cli_namespace = 'app' - @accepts(Str('name'), roles=['APPS_READ']) - @returns(List('images', items=[Str('image')])) + @api_method(AppOutdatedDockerImagesArgs, AppOutdatedDockerImagesResult, roles=['APPS_READ']) async def outdated_docker_images(self, app_name): """ Returns a list of outdated docker images for the specified app `name`. @@ -26,15 +28,7 @@ async def outdated_docker_images(self, app_name): return images - @accepts( - Str('name'), - Dict( - 'options', - Bool('redeploy', default=True), - ), - roles=['APPS_WRITE'] - ) - @returns() + @api_method(AppPullImagesArgs, AppPullImagesResult, roles=['APPS_WRITE']) @job(lock=lambda args: f'pull_images_{args[0]}') def pull_images(self, job, app_name, options): """ diff --git a/src/middlewared/middlewared/plugins/apps/resources.py b/src/middlewared/middlewared/plugins/apps/resources.py index 4c833d170d3e5..e6256042382b0 100644 --- a/src/middlewared/middlewared/plugins/apps/resources.py +++ b/src/middlewared/middlewared/plugins/apps/resources.py @@ -1,6 +1,11 @@ -from middlewared.schema import accepts, Bool, Dict, Int, List, Ref, returns, Str +from middlewared.api import api_method +from middlewared.api.current import ( + AppContainerIDArgs, AppContainerIDResult, AppContainerConsoleChoiceArgs, AppContainerConsoleChoiceResult, + AppCertificateChoicesArgs, AppCertificateChoicesResult, AppCertificateAuthorityArgs, AppCertificateAuthorityResult, + AppUsedPortsArgs, AppUsedPortsResult, AppIPChoicesArgs, AppIPChoicesResult, AppAvailableSpaceArgs, + AppAvailableSpaceResult, AppGpuChoicesArgs, AppGpuChoicesResult, +) from middlewared.service import private, Service - from middlewared.utils.gpu import get_nvidia_gpus from .ix_apps.utils import ContainerState @@ -14,25 +19,7 @@ class Config: namespace = 'app' cli_namespace = 'app' - @accepts( - Str('app_name'), - Dict( - 'options', - Bool('alive_only', default=True), - ), - roles=['APPS_READ'] - ) - @returns(Dict( - additional_attrs=True, - example={ - 'afb901dc53a29016c385a9de43f089117e399622c042674f82c10c911848baba': { - 'service_name': 'jellyfin', - 'image': 'jellyfin/jellyfin:10.9.7', - 'state': 'running', - 'id': 'afb901dc53a29016c385a9de43f089117e399622c042674f82c10c911848baba', - } - } - )) + @api_method(AppContainerIDArgs, AppContainerIDResult, roles=['APPS_READ']) async def container_ids(self, app_name, options): """ Returns container IDs for `app_name`. @@ -50,26 +37,14 @@ async def container_ids(self, app_name, options): ) } - @accepts(Str('app_name'), roles=['APPS_READ']) - @returns(Dict( - additional_attrs=True, - example={ - 'afb901dc53a29016c385a9de43f089117e399622c042674f82c10c911848baba': { - 'service_name': 'jellyfin', - 'image': 'jellyfin/jellyfin:10.9.7', - 'state': 'running', - 'id': 'afb901dc53a29016c385a9de43f089117e399622c042674f82c10c911848baba', - } - } - )) + @api_method(AppContainerConsoleChoiceArgs, AppContainerConsoleChoiceResult, roles=['APPS_READ']) async def container_console_choices(self, app_name): """ Returns container console choices for `app_name`. """ return await self.container_ids(app_name, {'alive_only': True}) - @accepts(roles=['APPS_READ']) - @returns(List(items=[Ref('certificate_entry')])) + @api_method(AppCertificateChoicesArgs, AppCertificateChoicesResult, roles=['APPS_READ']) async def certificate_choices(self): """ Returns certificates which can be used by applications. @@ -79,8 +54,7 @@ async def certificate_choices(self): {'select': ['name', 'id']} ) - @accepts(roles=['APPS_READ']) - @returns(List(items=[Ref('certificateauthority_entry')])) + @api_method(AppCertificateAuthorityArgs, AppCertificateAuthorityResult, roles=['APPS_READ']) async def certificate_authority_choices(self): """ Returns certificate authorities which can be used by applications. @@ -89,8 +63,7 @@ async def certificate_authority_choices(self): 'certificateauthority.query', [['revoked', '=', False], ['parsed', '=', True]], {'select': ['name', 'id']} ) - @accepts(roles=['APPS_READ']) - @returns(List(items=[Int('used_port')])) + @api_method(AppUsedPortsArgs, AppUsedPortsResult, roles=['APPS_READ']) async def used_ports(self): """ Returns ports in use by applications. @@ -102,8 +75,7 @@ async def used_ports(self): for host_port in port_entry['host_ports'] }))) - @accepts(roles=['APPS_READ']) - @returns(Dict(Str('ip_choice'))) + @api_method(AppIPChoicesArgs, AppIPChoicesResult, roles=['APPS_READ']) async def ip_choices(self): """ Returns IP choices which can be used by applications. @@ -113,8 +85,7 @@ async def ip_choices(self): for ip in await self.middleware.call('interface.ip_in_use', {'static': True, 'any': True}) } - @accepts(roles=['CATALOG_READ']) - @returns(Int()) + @api_method(AppAvailableSpaceArgs, AppAvailableSpaceResult, roles=['CATALOG_READ']) async def available_space(self): """ Returns space available in bytes in the configured apps pool which apps can consume @@ -122,15 +93,16 @@ async def available_space(self): await self.middleware.call('docker.state.validate') return (await self.middleware.call('filesystem.statfs', IX_APPS_MOUNT_PATH))['avail_bytes'] - @accepts(roles=['APPS_READ']) - @returns(Dict('gpu_choices', additional_attrs=True)) + @api_method(AppGpuChoicesArgs, AppGpuChoicesResult, roles=['APPS_READ']) async def gpu_choices(self): """ Returns GPU choices which can be used by applications. """ return { gpu['pci_slot']: { - k: gpu[k] for k in ('vendor', 'description', 'vendor_specific_config', 'pci_slot') + k: gpu[k] for k in ( + 'vendor', 'description', 'vendor_specific_config', 'pci_slot', 'error', 'gpu_details', + ) } for gpu in await self.gpu_choices_internal() if not gpu['error'] diff --git a/src/middlewared/middlewared/plugins/apps/rollback.py b/src/middlewared/middlewared/plugins/apps/rollback.py index 627a37e87ef61..c1a36b267c58d 100644 --- a/src/middlewared/middlewared/plugins/apps/rollback.py +++ b/src/middlewared/middlewared/plugins/apps/rollback.py @@ -1,4 +1,7 @@ -from middlewared.schema import accepts, Bool, Dict, List, Ref, returns, Str +from middlewared.api import api_method +from middlewared.api.current import ( + AppRollbackArgs, AppRollbackResult, AppRollbackVersionsArgs, AppRollbackVersionsResult, +) from middlewared.service import job, Service, ValidationErrors from .compose_utils import compose_action @@ -14,16 +17,7 @@ class Config: namespace = 'app' cli_namespace = 'app' - @accepts( - Str('app_name'), - Dict( - 'options', - Str('app_version', empty=False, required=True), - Bool('rollback_snapshot', default=True), - ), - roles=['APPS_WRITE'], - ) - @returns(Ref('app_entry')) + @api_method(AppRollbackArgs, AppRollbackResult, roles=['APPS_WRITE']) @job(lock=lambda args: f'app_rollback_{args[0]}') def rollback(self, job, app_name, options): """ @@ -90,8 +84,7 @@ def rollback(self, job, app_name, options): return self.middleware.call_sync('app.get_instance', app_name) - @accepts(Str('app_name'), roles=['APPS_READ']) - @returns(List('rollback_versions', items=[Str('version')])) + @api_method(AppRollbackVersionsArgs, AppRollbackVersionsResult, roles=['APPS_READ']) def rollback_versions(self, app_name): """ Retrieve versions available for rollback for `app_name` app. diff --git a/src/middlewared/middlewared/plugins/apps/upgrade.py b/src/middlewared/middlewared/plugins/apps/upgrade.py index 1efd9240e62b6..b81cd52fa9dfe 100644 --- a/src/middlewared/middlewared/plugins/apps/upgrade.py +++ b/src/middlewared/middlewared/plugins/apps/upgrade.py @@ -1,6 +1,9 @@ from pkg_resources import parse_version -from middlewared.schema import accepts, Dict, List, Str, Ref, returns +from middlewared.api import api_method +from middlewared.api.current import ( + AppUpgradeArgs, AppUpgradeResult, AppUpgradeSummaryArgs, AppUpgradeSummaryResult, +) from middlewared.service import CallError, job, private, Service, ValidationErrors from .compose_utils import compose_action @@ -16,16 +19,7 @@ class Config: namespace = 'app' cli_namespace = 'app' - @accepts( - Str('app_name'), - Dict( - 'options', - Dict('values', additional_attrs=True, private=True), - Str('app_version', empty=False, default='latest'), - ), - roles=['APPS_WRITE'], - ) - @returns(Ref('app_entry')) + @api_method(AppUpgradeArgs, AppUpgradeResult, roles=['APPS_WRITE']) @job(lock=lambda args: f'app_upgrade_{args[0]}') def upgrade(self, job, app_name, options): """ @@ -115,28 +109,7 @@ def upgrade(self, job, app_name, options): return new_app_instance - @accepts( - Str('app_name'), - Dict( - 'options', - Str('app_version', empty=False, default='latest'), - ), - roles=['APPS_READ'], - ) - @returns(Dict( - Str('latest_version', description='Latest version available for the app'), - Str('latest_human_version', description='Latest human readable version available for the app'), - Str('upgrade_version', description='Version user has requested to be upgraded at'), - Str('upgrade_human_version', description='Human readable version user has requested to be upgraded at'), - Str('changelog', max_length=None, null=True, description='Changelog for the upgrade version'), - List('available_versions_for_upgrade', items=[ - Dict( - 'version_info', - Str('version', description='Version of the app'), - Str('human_version', description='Human readable version of the app'), - ) - ], description='List of available versions for upgrade'), - )) + @api_method(AppUpgradeSummaryArgs, AppUpgradeSummaryResult, roles=['APPS_READ']) async def upgrade_summary(self, app_name, options): """ Retrieve upgrade summary for `app_name`. diff --git a/src/middlewared/middlewared/service/crud_service.py b/src/middlewared/middlewared/service/crud_service.py index 590d380f187cc..80a4b535b1fbf 100644 --- a/src/middlewared/middlewared/service/crud_service.py +++ b/src/middlewared/middlewared/service/crud_service.py @@ -68,9 +68,11 @@ def __new__(cls, name, bases, attrs): klass.ENTRY = None # FIXME: Remove `wraps` handling when we get rid of `@filterable` in `CRUDService.query` definition query_result_model = query_result(klass._config.entry) - klass.query = api_method(QueryArgs, query_result_model)( - klass.query.wraps if hasattr(klass.query, "wraps") else klass.query - ) + if not hasattr(klass.query, '_filterable') or klass.query._filterable is False: + # No need to inject api method if filterable has been explicitly specified + klass.query = api_method(QueryArgs, query_result_model)( + klass.query.wraps if hasattr(klass.query, "wraps") else klass.query + ) # FIXME: Remove `wraps` handling when we get rid of `@accepts` in `CRUDService.get_instance` definition get_instance_args_model = get_instance_args(klass._config.entry) get_instance_result_model = get_instance_result(klass._config.entry) diff --git a/src/middlewared/middlewared/service/decorators.py b/src/middlewared/middlewared/service/decorators.py index 1f1e3bc35f0eb..2ca5f2b012ee0 100644 --- a/src/middlewared/middlewared/service/decorators.py +++ b/src/middlewared/middlewared/service/decorators.py @@ -52,7 +52,8 @@ def filterable_internal(fn): def filterable_api_method( - fn=None, /, *, roles=None, item=None, private=False, cli_private=False, authorization_required=True + fn=None, /, *, roles=None, item=None, private=False, cli_private=False, authorization_required=True, + pass_app=False, pass_app_require=False, pass_app_rest=False, ): def filterable_internal(fn): fn._filterable = True @@ -69,7 +70,8 @@ def filterable_internal(fn): return api_method( QueryArgs, returns, private=private, roles=roles, cli_private=cli_private, - authorization_required=authorization_required + authorization_required=authorization_required, pass_app=pass_app, pass_app_require=pass_app_require, + pass_app_rest=pass_app_rest, )(fn) # See if we're being called as @filterable or @filterable().