From ef4f57a1bde14383e85fce9585aff2b16f441dae Mon Sep 17 00:00:00 2001 From: Vladislav Nesterov Date: Fri, 25 Sep 2026 22:25:56 +0700 Subject: [PATCH] 13.0.2 --- CHANGELOG.md | 31 ++-- library/decort_vm.py | 21 +++ library/decort_vm_folder.py | 235 +++++++++++++++++++++++++++++ library/decort_vm_folder_access.py | 141 +++++++++++++++++ library/decort_vm_folder_list.py | 71 +++++++++ library/decort_vm_list.py | 8 + module_utils/decort_utils.py | 154 ++++++++++++++++++- requirements.txt | 1 + 8 files changed, 652 insertions(+), 10 deletions(-) create mode 100644 library/decort_vm_folder.py create mode 100644 library/decort_vm_folder_access.py create mode 100644 library/decort_vm_folder_list.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 87c0eaf..eb43716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,33 @@ -# Список изменений в версии 13.0.1 +# Список изменений в версии 13.0.2 ## Изменено -| Идентификатор
задачи | Описание | -| --- | --- | + ## Добавлено +### Глобально +| Идентификатор
задачи | Описание | +| --- | --- | +| BANS-1463 | Добавлен модуль `decort_vm_folder_list`, позволяющий получить список папок для группировки ВМ в ресурсной группе. | +| BANS-1467 | Добавлен модуль `decort_vm_folder_access`, позволяющий управлять правами доступа к папке виртуальных машин. | +| BANS-1464 | Добавлен модуль `decort_vm_folder`, позволяющий привести к целевому состоянию и получить информацию о папке для группировки ВМ в ресурсной группе. | + ### Модуль decort_vm | Идентификатор
задачи | Описание | | --- | --- | -| BANS-1413 | Добавлен параметр `disks.objects.read_only`. | +| BANS-1466 | Добавлены параметр и возвращаемое значение `folder_id`. | +### Модуль decort_vm_list +| Идентификатор
задачи | Описание | +| --- | --- | +| BANS-1468 | Добавлено возвращаемое значение `folder_id`. | +| BANS-1476 | Добавлен параметр `include_descendants`. | +| BANS-1477 | Добавлен параметр `filter.folder_id` для фильтрации виртуальных машин по идентификатору папки для группировки ВМ в ресурсной группе. | + +### Модуль decort_audit_list +| Идентификатор
задачи | Описание | +| --- | --- | +| BANS-1840 | Добавлено возвращаемое значение `vmfolder_id`. | ## Удалено -| Идентификатор
задачи | Описание | -| --- | --- | + ## Исправлено -| Идентификатор
задачи | Описание | -| --- | --- | -| BANS-1423 | Модуль `decort_vm` завершал работу ошибкой запроса к API при подключении к ВМ интерфейса типа `EMPTY`. | diff --git a/library/decort_vm.py b/library/decort_vm.py index 0bb049a..214bb2f 100644 --- a/library/decort_vm.py +++ b/library/decort_vm.py @@ -620,6 +620,12 @@ class decort_vm(DecortController): custom_fields=custom_fields_fields, ) + if self.aparams['folder_id'] is not None: + self.sdk_checkmode(self.api.cloudapi.vmfolder.computes_move)( + compute_ids=[self.comp_info['id']], + folder_id=self.aparams['folder_id'], + ) + # read in Compute facts once more after all initial setup is complete _, self.comp_info, _ = self.compute_find( comp_id=self.comp_id, @@ -679,6 +685,16 @@ class decort_vm(DecortController): **self.compute_update_args, ) + folder_id = self.aparams['folder_id'] + if ( + folder_id is not None + and folder_id != (self.comp_info.get('folder_id') or 0) + ): + self.sdk_checkmode(self.api.cloudapi.vmfolder.computes_move)( + compute_ids=[self.comp_info['id']], + folder_id=folder_id, + ) + if self.amodule.params['rollback_to'] is not None: self.sdk_checkmode(self.api.cloudapi.compute.snapshot_rollback)( vm_id=self.comp_info['id'], @@ -979,6 +995,7 @@ class decort_vm(DecortController): tech_status="", account_id=0, rg_id=0, + folder_id=0, username="", password="", public_ips=[], # direct IPs; this list can be empty @@ -1012,6 +1029,7 @@ class decort_vm(DecortController): ret_dict['tech_status'] = self.comp_info['techStatus'] ret_dict['account_id'] = self.comp_info['accountId'] ret_dict['rg_id'] = self.comp_info['rgId'] + ret_dict['folder_id'] = self.comp_info.get('folder_id') or 0 if self.comp_info['tags']: ret_dict['tags'] = self.comp_info['tags'] # if the VM is an imported VM, then the 'accounts' list may be empty, @@ -1399,6 +1417,9 @@ class decort_vm(DecortController): ), ), ), + folder_id=dict( + type='int', + ), id=dict( type='int', default=0, diff --git a/library/decort_vm_folder.py b/library/decort_vm_folder.py new file mode 100644 index 0000000..20823d5 --- /dev/null +++ b/library/decort_vm_folder.py @@ -0,0 +1,235 @@ +#!/usr/bin/python + +DOCUMENTATION = r''' +--- +module: decort_vm_folder + +description: See L(Module Documentation,https://repository.basistech.ru/BASIS/decort-ansible/wiki/Home). # noqa: E501 +''' + +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.decort_utils import ( + AnsibleModuleParamSet, + DecortController, +) + +from dynamix_sdk import exceptions as sdk_exceptions +from dynamix_sdk import types as sdk_types + + +class VmFolderState(Enum): + absent = 'absent' + + +@dataclass(frozen=True) +class ParamSet(AnsibleModuleParamSet): + id: int | None + name: str | None + parent_id: int | None + rg_id: int | None + state: VmFolderState | None + + +class DecortVmFolder(DecortController): + param_set: ParamSet + facts: dict[str, Any] + _id: int | None = None + _vm_folder_info: sdk_types.CloudapiVmfolderGetResultModel | None = None + + def __init__(self) -> None: + super().__init__(AnsibleModule(**self.amodule_init_args)) + self.param_set = ParamSet.from_dict(self.amodule.params) + self.facts = {} + + @property + def amodule_init_args(self) -> dict[str, Any]: + return self.pack_amodule_init_args( + argument_spec=ParamSet.get_arg_spec(), + supports_check_mode=True, + ) + + @property + def id(self) -> int: + if self._id is None: + raise TypeError + return self._id + + @property + def vm_folder_info(self) -> sdk_types.CloudapiVmfolderGetResultModel: + if self._vm_folder_info is None: + try: + vm_folder_info = self.api.cloudapi.vmfolder.get( + folder_id=self.id, + ) + except sdk_exceptions.RequestException as e: + if ( + e.orig_exception.response is not None + and e.orig_exception.response.status_code == 404 + ): + self.message( + self.MESSAGES.obj_not_found( + obj='vm_folder', + id=self.id, + ) + ) + self.exit(fail=True) + raise e + self._vm_folder_info = vm_folder_info + return self._vm_folder_info + + @DecortController.handle_sdk_exceptions + def run(self) -> None: + if self.param_set.id is not None: + self._id = self.param_set.id + elif self.param_set.name is not None: + self._id = self.find_id_by_name() + + if self._id is not None: + self.get_info() + self.change() + else: + self.check_amodule_args_for_create() + self.create() + + if self.result['changed']: + self.get_info() + self.exit() + + def find_id_by_name(self) -> int | None: + rg_id = self.param_set.rg_id + name = self.param_set.name + if rg_id is None or name is None: + return None + + matches: list[int] = [] + page = 1 + page_size = 100 + while True: + folders = self.api.cloudapi.vmfolder.list( + rg_id=rg_id, + page=page, + size=page_size, + ) + for folder in folders.data: + if folder.name != name: + continue + if ( + self.param_set.parent_id is not None + and folder.parent_id != self.param_set.parent_id + ): + continue + matches.append(folder.id) + + if not folders.data or len(folders.data) < page_size: + break + if ( + folders.entry_count + and page * page_size >= folders.entry_count + ): + break + page += 1 + + if len(matches) > 1: + self.message( + msg='Check for parameter "name" failed: ' + 'multiple vm folders with this name were found; ' + 'specify parent_id or id' + ) + self.exit(fail=True) + if matches: + return matches[0] + return None + + def get_info(self) -> None: + if self._vm_folder_info is not None and not self.amodule.check_mode: + self._vm_folder_info = None + self.facts = self.vm_folder_info.model_dump() + + def check_amodule_args_for_create(self) -> None: + check_errors = False + + if self.param_set.rg_id is None: + check_errors = True + self.message( + msg='Check for parameter "rg_id" failed: ' + 'rg_id must be specified when creating ' + 'a new vm_folder' + ) + + if self.param_set.name is None: + check_errors = True + self.message( + msg='Check for parameter "name" failed: ' + 'name must be specified when creating ' + 'a new vm_folder' + ) + + if self.param_set.state == VmFolderState.absent: + check_errors = True + self.message( + msg='Check for parameter "state" failed: ' + 'state can not be "absent" when creating ' + 'a new vm_folder' + ) + + if check_errors: + self.exit(fail=True) + + def create(self) -> None: + rg_id = self.param_set.rg_id + name = self.param_set.name + if rg_id is None or name is None: + raise TypeError + created_id = self.sdk_checkmode( + self.api.cloudapi.vmfolder.create + )( + rg_id=rg_id, + name=name, + parent_id=self.param_set.parent_id, + ) + if created_id: + self._id = created_id + + def change(self) -> None: + self.change_state() + self.change_params() + + def change_state(self) -> None: + if self.param_set.state == VmFolderState.absent: + self.delete() + + def change_params(self) -> None: + if ( + self.param_set.name is not None + and self.param_set.name != self.vm_folder_info.name + ): + self.sdk_checkmode(self.api.cloudapi.vmfolder.rename)( + folder_id=self.id, + name=self.param_set.name, + ) + if ( + self.param_set.parent_id is not None + and self.param_set.parent_id != self.vm_folder_info.parent_id + ): + self.sdk_checkmode(self.api.cloudapi.vmfolder.move)( + folder_id=self.id, + new_parent_id=self.param_set.parent_id, + ) + + def delete(self) -> None: + self.sdk_checkmode(self.api.cloudapi.vmfolder.delete)( + folder_id=self.id, + ) + self.exit() + + +def main() -> None: + DecortVmFolder().run() + + +if __name__ == '__main__': + main() diff --git a/library/decort_vm_folder_access.py b/library/decort_vm_folder_access.py new file mode 100644 index 0000000..52cd4ae --- /dev/null +++ b/library/decort_vm_folder_access.py @@ -0,0 +1,141 @@ +#!/usr/bin/python + +DOCUMENTATION = r''' +--- +module: decort_vm_folder_access + +description: See L(Module Documentation,https://repository.basistech.ru/BASIS/decort-ansible/wiki/Home). # noqa: E501 +''' + +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.decort_utils import ( + AnsibleModuleParamSet, + DecortController, +) + +from dynamix_sdk import exceptions as sdk_exceptions +from dynamix_sdk import types as sdk_types + + +class VMFolderAccessState(Enum): + absent = 'absent' + + +@dataclass(frozen=True) +class ParamSet(AnsibleModuleParamSet): + folder_id: int + user: str + right: sdk_types.AccessTypeForSet | None + state: VMFolderAccessState | None + + +class DecortVMFolderAccess(DecortController): + param_set: ParamSet + folder_info: sdk_types.CloudapiVmfolderGetResultModel + acl: sdk_types.ACLForVMFolderGetAPIResultNM | None = None + + def __init__(self) -> None: + super().__init__(AnsibleModule(**self.amodule_init_args)) + self.param_set = ParamSet.from_dict(self.amodule.params) + + @property + def amodule_init_args(self) -> dict[str, Any]: + return self.pack_amodule_init_args( + argument_spec=ParamSet.get_arg_spec(), + supports_check_mode=True, + ) + + @DecortController.handle_sdk_exceptions + def run(self) -> None: + self.get_folder() + self.check_amodule_args() + self.change() + if self.result['changed'] and not self.amodule.check_mode: + self.get_folder() + self.package_facts() + self.exit() + + def get_folder(self) -> None: + try: + self.folder_info = self.api.cloudapi.vmfolder.get( + folder_id=self.param_set.folder_id, + ) + except sdk_exceptions.RequestException as e: + if ( + e.orig_exception.response is not None + and e.orig_exception.response.status_code == 404 + ): + self.message( + self.MESSAGES.obj_not_found( + obj='VM folder', + id=self.param_set.folder_id, + ) + ) + self.exit(fail=True) + raise e + self.acl = self.find_acl() + + def find_acl(self) -> sdk_types.ACLForVMFolderGetAPIResultNM | None: + for acl in self.folder_info.acl: + if acl.explicit and acl.user_group_id == self.param_set.user: + return acl + return None + + def check_amodule_args(self) -> None: + if ( + self.param_set.state is None + and self.param_set.right is None + and self.acl is None + ): + self.message( + self.MESSAGES.obj_not_found( + obj='VM folder ACL', + id=self.param_set.user, + ) + ) + self.exit(fail=True) + + def change(self) -> None: + if self.param_set.state == VMFolderAccessState.absent: + self.delete() + return + + right = self.param_set.right + if right is None or ( + self.acl is not None and self.acl.right == right + ): + return + + self.sdk_checkmode(self.api.cloudapi.vmfolder.add_acl_access)( + folder_id=self.param_set.folder_id, + user=self.param_set.user, + right=right, + ) + + def delete(self) -> None: + if self.acl is None: + return + self.sdk_checkmode(self.api.cloudapi.vmfolder.del_acl_access)( + folder_id=self.param_set.folder_id, + user=self.param_set.user, + ) + if not self.amodule.check_mode: + self.acl = None + + def package_facts(self) -> None: + if self.acl is not None: + self.facts = self.acl.model_dump() + else: + self.facts = {} + + +def main() -> None: + DecortVMFolderAccess().run() + + +if __name__ == '__main__': + main() diff --git a/library/decort_vm_folder_list.py b/library/decort_vm_folder_list.py new file mode 100644 index 0000000..0d8ab42 --- /dev/null +++ b/library/decort_vm_folder_list.py @@ -0,0 +1,71 @@ +#!/usr/bin/python + +DOCUMENTATION = r''' +--- +module: decort_vm_folder_list + +description: See L(Module Documentation,https://repository.basistech.ru/BASIS/decort-ansible/wiki/Home). # noqa: E501 +''' + +from dataclasses import dataclass + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.decort_utils import ( + AnsibleModuleParamSet, + DecortController, +) + + +@dataclass(frozen=True) +class FilterParam: + rg_id: int + + +@dataclass(frozen=True) +class PaginationParam: + page: int | None + size: int | None + + +@dataclass(frozen=True) +class ParamSet(AnsibleModuleParamSet): + filter: FilterParam + pagination: PaginationParam + + +class DecortVMFolderList(DecortController): + param_set: ParamSet + + def __init__(self): + super().__init__(AnsibleModule(**self.amodule_init_args)) + self.param_set = ParamSet.from_dict(self.amodule.params) + + @property + def amodule_init_args(self) -> dict: + return self.pack_amodule_init_args( + argument_spec=ParamSet.get_arg_spec(), + supports_check_mode=True, + ) + + @DecortController.handle_sdk_exceptions + def run(self): + self.get_info() + self.exit() + + def get_info(self): + aparam_filter = self.param_set.filter + aparam_pagination = self.param_set.pagination + + self.facts = self.api.cloudapi.vmfolder.list( + rg_id=aparam_filter.rg_id, + page=aparam_pagination.page, + size=aparam_pagination.size, + ).model_dump()['data'] + + +def main(): + DecortVMFolderList().run() + + +if __name__ == '__main__': + main() diff --git a/library/decort_vm_list.py b/library/decort_vm_list.py index 7ee8730..70da617 100644 --- a/library/decort_vm_list.py +++ b/library/decort_vm_list.py @@ -36,12 +36,18 @@ class DecortVMList(DecortController): ext_net_name=dict( type='str', ), + folder_id=dict( + type='int', + ), id=dict( type='int', ), include_deleted=dict( type='bool', ), + include_descendants=dict( + type='bool', + ), ip_addr=dict( type='str', ), @@ -129,8 +135,10 @@ class DecortVMList(DecortController): account_id=aparam_filter['account_id'], ext_net_id=aparam_filter['ext_net_id'], ext_net_name=aparam_filter['ext_net_name'], + folder_id=aparam_filter['folder_id'], id=aparam_filter['id'], include_deleted=aparam_filter['include_deleted'] or False, + include_descendants=aparam_filter['include_descendants'] or False, ip_addr=aparam_filter['ip_addr'], name=aparam_filter['name'], rg_id=aparam_filter['rg_id'], diff --git a/module_utils/decort_utils.py b/module_utils/decort_utils.py index 943684d..a3f27cd 100644 --- a/module_utils/decort_utils.py +++ b/module_utils/decort_utils.py @@ -1,4 +1,5 @@ from copy import deepcopy +from dataclasses import Field, MISSING, dataclass, fields, is_dataclass from datetime import datetime from enum import Enum from functools import wraps @@ -6,19 +7,24 @@ from importlib.metadata import version as get_package_version import inspect import json import re +from types import UnionType from typing import ( Any, Callable, + ClassVar, Iterable, Literal, Optional, ParamSpec, TypeVar, cast, + get_args, + get_origin, ) import time from ansible.module_utils.basic import AnsibleModule, env_fallback +import dacite from dynamix_sdk import BVSAuth, DECS3OAuth, Dynamix from dynamix_sdk import __name__ as SDK_PACKAGE_NAME from dynamix_sdk import exceptions as sdk_exceptions @@ -33,6 +39,152 @@ P = ParamSpec('P') R = TypeVar('R') +@dataclass(frozen=True) +class AnsibleModuleParamSet: + """Dataclass that can generate AnsibleModule argument_spec.""" + + _ANSIBLE_TYPE_BY_PYTHON: ClassVar[dict[type, str]] = { + bool: 'bool', + int: 'int', + str: 'str', + } + _ALLOWED_FIELD_METADATA_KEYS: ClassVar[frozenset[str]] = frozenset({'choices'}) # noqa: E501 + + @classmethod + def get_arg_spec(cls) -> dict[str, Any]: + return cls._dataclass_to_arg_spec(cls) + + @classmethod + def from_dict[T: AnsibleModuleParamSet]( + cls: type[T], data: dict[str, Any] + ) -> T: + return dacite.from_dict( + data_class=cls, + data=data, + config=dacite.Config(cast=[Enum]), + ) + + @classmethod + def _dataclass_to_arg_spec(cls, model_cls: type) -> dict[str, Any]: + spec: dict[str, Any] = {} + for field in fields(model_cls): + spec[field.name] = cls._field_to_arg_spec( + field=field, + model_cls=model_cls, + ) + return spec + + @classmethod + def _field_to_arg_spec( + cls, + field: Field, + model_cls: type, + ) -> dict[str, Any]: + if field.default is None: + raise ValueError( + f'{model_cls.__name__}.{field.name}: ' + 'default=None is not allowed' + ) + + if get_origin(field.type) is UnionType: + args = get_args(field.type) + if len(args) != 2 or type(None) not in args: + raise ValueError( + f'{model_cls.__name__}.{field.name}: ' + f'unsupported annotation {field.type!r}: ' + 'expected T | None' + ) + param_type = args[0] if args[1] is type(None) else args[1] + allows_none = True + else: + param_type = field.type + allows_none = False + has_default = ( + field.default is not MISSING + or field.default_factory is not MISSING + ) + + unknown_metadata_keys = ( + set(field.metadata) - cls._ALLOWED_FIELD_METADATA_KEYS + ) + if unknown_metadata_keys: + raise ValueError( + f'{model_cls.__name__}.{field.name}: ' + f'unsupported metadata keys {sorted(unknown_metadata_keys)!r}' + ) + + try: + entry = cls._type_to_arg_spec(param_type) + except ValueError as exc: + raise ValueError( + f'{model_cls.__name__}.{field.name}: {exc}' + ) + + if entry['type'] == 'dict' and 'options' in entry: + if not allows_none: + entry['apply_defaults'] = True + return entry + + metadata_choices = field.metadata.get('choices') + if metadata_choices is not None: + entry['choices'] = list(metadata_choices) + + if not allows_none and not has_default: + entry['required'] = True + elif has_default: + entry['default'] = cls._resolve_default(field) + + return entry + + @classmethod + def _type_to_arg_spec(cls, type_: Any) -> dict[str, Any]: + if get_origin(type_) is list: + list_args = get_args(type_) + if len(list_args) != 1: + raise ValueError(f'unsupported annotation {type_!r}') + elem_spec = cls._type_to_arg_spec(list_args[0]) + entry: dict[str, Any] = { + 'type': 'list', + 'elements': elem_spec['type'], + } + if 'options' in elem_spec: + entry['options'] = elem_spec['options'] + if 'choices' in elem_spec: + entry['choices'] = elem_spec['choices'] + return entry + + if isinstance(type_, type) and is_dataclass(type_): + return { + 'type': 'dict', + 'options': cls._dataclass_to_arg_spec(type_), + } + if isinstance(type_, type) and issubclass(type_, Enum): + return { + 'type': 'str', + 'choices': list(type_._member_names_), + } + if get_origin(type_) is Literal: + return { + 'type': 'str', + 'choices': list(get_args(type_)), + } + if type_ in cls._ANSIBLE_TYPE_BY_PYTHON: + return {'type': cls._ANSIBLE_TYPE_BY_PYTHON[type_]} + raise ValueError(f'unsupported annotation {type_!r}') + + @staticmethod + def _resolve_default(field: Field) -> Any: + if field.default is not MISSING: + default = field.default + elif field.default_factory is not MISSING: + default = field.default_factory() + else: + raise ValueError(f'{field.name}: no default to resolve') + if isinstance(default, Enum): + return default.name + return default + + class DecortController(object): """DecortController is a utility class that holds target controller context and handles API requests formatting based on the requested authentication type. @@ -53,7 +205,7 @@ class DecortController(object): _api: sdk_types.API | None = None _usermanager_whoami_result: None | dict = None - ANSIBLE_MODULES_VERSION = '13.0.1' + ANSIBLE_MODULES_VERSION = '13.0.2' COMPATIBLE_SDK_MINOR_VERSION = '2.0' VM_RESIZE_NOT = 0 diff --git a/requirements.txt b/requirements.txt index 66ecceb..efd3d1b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ ansible==11.6.0 +dacite==1.9.2 requests==2.32.3 git+https://repository.basistech.ru/BASIS/dynamix-python-sdk.git@2.0.latest