From 458292ae0b941b3eb995a852027a7b73e8fb811f Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Wed, 6 Mar 2024 02:47:03 +0000 Subject: [PATCH 001/208] ADCM-5294 Rework task preparation/running processes NOTE: not everything will work with this commit, further work is required, there's still a lot of things to consider, do and test. Broken: 1. Migration is not working when there are existing `TaskLog` objects. 2. Service deletion via plugin followed by job won't work for now, because task related object will be deleted. Added: 1. Script `runner.py` as an alternative for `task_runner.py`. 2. `Runner` and `Executor` entities to rule the task/job running process. 3. `core.job` with basic types and interfaces for updated task running process. 4. `BROKEN` state for tasks to represent that something unhandled happened during task preparation/execution. Changed: 1. `JobLog` unlinked from `Action` and `SubAction`, data migration is provided. 2. Task creation process: now tasks for actions should be created only with new functions, because they should be properly synced for further task running. Otherwise, there's a big chance of incorrect name/display_name/other stuff detection. Caveats: 1. Task creation altered not in all tests. 2. Some functions used in preparation/final task/job running steps used AS IS, no rework or code movement performed. 3. Changing MM via actions should be checked (especially when task is failed). --- python/api/job/serializers.py | 55 +-- python/api/job/views.py | 36 +- python/api/tests/test_job.py | 5 +- python/api_v2/action/filters.py | 2 +- python/api_v2/log_storage/utils.py | 8 +- python/api_v2/task/serializers.py | 28 +- .../tests/bundles/cluster_actions/config.yaml | 1 + python/api_v2/tests/test_actions.py | 2 +- python/api_v2/tests/test_audit/test_task.py | 8 +- python/api_v2/tests/test_jobs.py | 7 +- python/api_v2/tests/test_tasks.py | 74 ++-- python/audit/cases/common.py | 15 +- python/cm/ansible_plugin.py | 4 +- python/cm/api.py | 4 +- python/cm/converters.py | 8 +- python/cm/job.py | 139 +++--- .../cm/migrations/0116_autonomous_joblogs.py | 215 ++++++++++ python/cm/models.py | 47 ++- python/cm/services/job/config.py | 13 +- python/cm/services/job/prepare.py | 29 ++ python/cm/services/job/run/__init__.py | 15 + python/cm/services/job/run/_impl.py | 71 ++++ .../cm/services/job/run/_target_factories.py | 181 ++++++++ python/cm/services/job/run/executors.py | 107 +++++ python/cm/services/job/run/repo.py | 349 ++++++++++++++++ python/cm/services/job/run/runners.py | 394 ++++++++++++++++++ python/cm/services/job/utils.py | 55 +-- python/cm/stack.py | 2 +- python/cm/status_api.py | 18 +- .../test_inventory/test_action_config.py | 51 +-- .../cm/tests/test_inventory/test_inventory.py | 1 + python/cm/tests/test_job.py | 43 +- python/cm/tests/test_message_template.py | 9 +- python/cm/tests/test_task_log.py | 78 ++-- python/cm/tests/utils.py | 1 - python/core/job/__init__.py | 12 + python/core/job/dto.py | 47 +++ python/core/job/executors.py | 125 ++++++ python/core/job/repo.py | 63 +++ python/core/job/runners.py | 120 ++++++ python/core/job/task.py | 67 +++ python/core/job/types.py | 113 +++++ python/core/types.py | 18 + python/job_runner.py | 17 +- python/runner.py | 65 +++ python/task_runner.py | 4 +- 46 files changed, 2330 insertions(+), 396 deletions(-) create mode 100644 python/cm/migrations/0116_autonomous_joblogs.py create mode 100644 python/cm/services/job/prepare.py create mode 100644 python/cm/services/job/run/__init__.py create mode 100644 python/cm/services/job/run/_impl.py create mode 100644 python/cm/services/job/run/_target_factories.py create mode 100644 python/cm/services/job/run/executors.py create mode 100644 python/cm/services/job/run/repo.py create mode 100644 python/cm/services/job/run/runners.py create mode 100644 python/core/job/__init__.py create mode 100644 python/core/job/dto.py create mode 100644 python/core/job/executors.py create mode 100644 python/core/job/repo.py create mode 100644 python/core/job/runners.py create mode 100644 python/core/job/task.py create mode 100644 python/core/job/types.py create mode 100755 python/runner.py diff --git a/python/api/job/serializers.py b/python/api/job/serializers.py index e681da1296..cb199a9c7b 100644 --- a/python/api/job/serializers.py +++ b/python/api/job/serializers.py @@ -40,19 +40,11 @@ class Meta: @staticmethod def get_display_name(obj: JobLog) -> str | None: - if obj.sub_action: - return obj.sub_action.display_name - elif obj.action: - return obj.action.display_name - else: - return None + return obj.display_name @staticmethod def get_terminatable(obj: JobLog): - if obj.sub_action is None: - return False - - return obj.sub_action.allowed_to_terminate + return obj.allow_to_terminate class TaskSerializer(HyperlinkedModelSerializer): @@ -156,6 +148,9 @@ def create(self, validated_data): class JobSerializer(HyperlinkedModelSerializer): + action_id = SerializerMethodField() + sub_action_id = SerializerMethodField() + class Meta: model = JobLog fields = ( @@ -171,12 +166,22 @@ class Meta: ) extra_kwargs = {"url": {"lookup_url_kwarg": "job_pk"}} + def get_action_id(self, obj: JobLog): + try: + return obj.action.id + except AttributeError: + return None + + def get_sub_action_id(self, _: JobLog): + return None + class JobRetrieveSerializer(HyperlinkedModelSerializer): + action_id = SerializerMethodField() + sub_action_id = SerializerMethodField() action = ActionJobSerializer() - display_name = SerializerMethodField() + selector = SerializerMethodField() objects = SerializerMethodField() - selector = JSONField() log_dir = SerializerMethodField() log_files = SerializerMethodField() action_url = SerializerMethodField() @@ -186,11 +191,18 @@ class JobRetrieveSerializer(HyperlinkedModelSerializer): ) terminatable = SerializerMethodField() + def get_selector(self, obj: JobLog): + return obj.task.selector + + def get_action_id(self, obj: JobLog): + return obj.action.id + + def get_sub_action_id(self, _: JobLog): + return None + @staticmethod def get_terminatable(obj: JobLog): - if obj.sub_action is None: - return False - return obj.sub_action.allowed_to_terminate + return obj.allow_to_terminate class Meta: model = JobLog @@ -212,21 +224,12 @@ class Meta: def get_objects(obj: JobLog) -> list | None: return [{"type": k, **v} for k, v in obj.task.selector.items()] - @staticmethod - def get_display_name(obj: JobLog) -> str | None: - if obj.sub_action: - return obj.sub_action.display_name - elif obj.action: - return obj.action.display_name - else: - return None - def get_action_url(self, obj: JobLog) -> str | None: - if not obj.action_id: + if not obj.action: return None return reverse( - viewname="v1:action-detail", kwargs={"action_pk": obj.action_id}, request=self.context["request"] + viewname="v1:action-detail", kwargs={"action_pk": obj.action.id}, request=self.context["request"] ) @staticmethod diff --git a/python/api/job/views.py b/python/api/job/views.py index 96352f78fe..b32c18ec47 100644 --- a/python/api/job/views.py +++ b/python/api/job/views.py @@ -23,6 +23,14 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.http import HttpResponse +from django_filters.rest_framework import ( + CharFilter, + DateTimeFilter, + DjangoFilterBackend, + FilterSet, + NumberFilter, + OrderingFilter, +) from guardian.mixins import PermissionListMixin from rest_framework.decorators import action from rest_framework.mixins import ListModelMixin, RetrieveModelMixin @@ -105,11 +113,9 @@ def get_task_download_archive_file_handler(task: TaskLog) -> io.BytesIO: with tarfile.open(fileobj=file_handler, mode="w:gz") as tar_file: for job in jobs: if task_dir_name_suffix is None: - dir_name_suffix = "" - if job.sub_action: - dir_name_suffix = str_remove_non_alnum(value=job.sub_action.display_name) or str_remove_non_alnum( - value=job.sub_action.name, - ) + dir_name_suffix = str_remove_non_alnum(value=job.display_name or "") or str_remove_non_alnum( + value=job.name + ) else: dir_name_suffix = task_dir_name_suffix @@ -133,10 +139,26 @@ def get_task_download_archive_file_handler(task: TaskLog) -> io.BytesIO: return file_handler +class JobFilter(FilterSet): + action_id = NumberFilter(field_name="action_id", method="filter_by_action_id") + task_id = NumberFilter(field_name="task_id") + pid = NumberFilter(field_name="pid") + status = CharFilter(field_name="status") + start_date = DateTimeFilter(field_name="start_date") + finish_date = DateTimeFilter(field_name="finish_date") + ordering = OrderingFilter( + fields={"status": "status", "start_date": "start_date", "finish_date": "finish_date"}, label="ordering" + ) + + def filter_by_action_id(self, queryset, name, value): # noqa: ARG002 + return queryset.filter(task__action_id=value) + + class JobViewSet(PermissionListMixin, ListModelMixin, RetrieveModelMixin, GenericUIViewSet): - queryset = JobLog.objects.select_related("task", "action").all() + queryset = JobLog.objects.select_related("task__action").all() serializer_class = JobSerializer - filterset_fields = ("action_id", "task_id", "pid", "status", "start_date", "finish_date") + filter_backends = (DjangoFilterBackend,) + filterset_class = JobFilter ordering_fields = ("status", "start_date", "finish_date") ordering = ["-id"] permission_required = ["cm.view_joblog"] diff --git a/python/api/tests/test_job.py b/python/api/tests/test_job.py index 52d1be7964..3045072cf5 100644 --- a/python/api/tests/test_job.py +++ b/python/api/tests/test_job.py @@ -51,7 +51,6 @@ def setUp(self) -> None: status="failed", start_date=timezone.now() + timedelta(days=1), finish_date=timezone.now() + timedelta(days=2), - action=self.action, task=self.task, pid=self.job_1.pid + 1, ) @@ -175,7 +174,7 @@ def test_log_files(self): self.assertEqual(response.status_code, HTTP_201_CREATED) - job = JobLog.objects.get(action=action) + job = JobLog.objects.get(task__action=action) response: Response = self.client.get( path=reverse(viewname="v1:joblog-detail", kwargs={"job_pk": job.pk}), @@ -210,7 +209,7 @@ def test_task_permissions(self): response: Response = self.client.get(path=reverse(viewname="v1:joblog-list")) self.assertIn( - JobLog.objects.get(action=action).pk, + JobLog.objects.get(task__action=action).pk, {job_data["id"] for job_data in response.data["results"]}, ) diff --git a/python/api_v2/action/filters.py b/python/api_v2/action/filters.py index cb5fe9de91..ebf4966e78 100644 --- a/python/api_v2/action/filters.py +++ b/python/api_v2/action/filters.py @@ -21,7 +21,7 @@ class ActionFilter(FilterSet): name = CharFilter(label="Action Name", field_name="name", lookup_expr="icontains") - dispaly_name = CharFilter(label="Action Display Name", field_name="display_name", lookup_expr="icontains") + display_name = CharFilter(label="Action Display Name", field_name="display_name", lookup_expr="icontains") is_host_own_action = BooleanFilter( label="Is Host Own Action", field_name="host_action", method="filter_is_host_own_action" ) diff --git a/python/api_v2/log_storage/utils.py b/python/api_v2/log_storage/utils.py index ee0e9cd931..236af366fd 100644 --- a/python/api_v2/log_storage/utils.py +++ b/python/api_v2/log_storage/utils.py @@ -65,11 +65,9 @@ def get_task_download_archive_file_handler(task: TaskLog) -> io.BytesIO: with tarfile.open(fileobj=file_handler, mode="w:gz") as tar_file: for job in jobs: if task_dir_name_suffix is None: - dir_name_suffix = "" - if job.sub_action: - dir_name_suffix = str_remove_non_alnum(value=job.sub_action.display_name) or str_remove_non_alnum( - value=job.sub_action.name, - ) + dir_name_suffix = str_remove_non_alnum(value=job.display_name or "") or str_remove_non_alnum( + value=job.name + ) else: dir_name_suffix = task_dir_name_suffix diff --git a/python/api_v2/task/serializers.py b/python/api_v2/task/serializers.py index 442fd86ed9..0e1e4bc0ef 100644 --- a/python/api_v2/task/serializers.py +++ b/python/api_v2/task/serializers.py @@ -10,7 +10,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from cm.models import Action, JobLog, JobStatus, SubAction, TaskLog +from cm.models import JobLog, JobStatus, TaskLog from rest_framework.fields import CharField, DateTimeField, SerializerMethodField from rest_framework.serializers import ModelSerializer @@ -27,8 +27,6 @@ class JobListSerializer(ModelSerializer): - name = SerializerMethodField() - display_name = SerializerMethodField() is_terminatable = SerializerMethodField() start_time = DateTimeField(source="start_date") end_time = DateTimeField(source="finish_date") @@ -46,31 +44,9 @@ class Meta: "is_terminatable", ) - @classmethod - def get_display_name(cls, obj: JobLog) -> str | None: - job_action = cls._get_job_action_obj(obj) - return job_action.display_name if job_action else None - - @classmethod - def get_name(cls, obj: JobLog) -> str | None: - job_action = cls._get_job_action_obj(obj) - return job_action.name if job_action else None - - @staticmethod - def _get_job_action_obj(obj: JobLog) -> Action | SubAction | None: - if obj.sub_action: - return obj.sub_action - elif obj.action: - return obj.action - else: - return None - @staticmethod def get_is_terminatable(obj: JobLog): - if obj.sub_action is None: - return False - - return obj.sub_action.allowed_to_terminate + return obj.allow_to_terminate class TaskSerializer(ModelSerializer): diff --git a/python/api_v2/tests/bundles/cluster_actions/config.yaml b/python/api_v2/tests/bundles/cluster_actions/config.yaml index 6284bf7c9a..19fca4ea67 100644 --- a/python/api_v2/tests/bundles/cluster_actions/config.yaml +++ b/python/api_v2/tests/bundles/cluster_actions/config.yaml @@ -14,6 +14,7 @@ ansible_tags: simple_action host_action: true allow_in_maintenance_mode: true + allow_to_terminate: true states: available: any diff --git a/python/api_v2/tests/test_actions.py b/python/api_v2/tests/test_actions.py index c5dd4c3375..5e8f180a88 100644 --- a/python/api_v2/tests/test_actions.py +++ b/python/api_v2/tests/test_actions.py @@ -285,7 +285,7 @@ def test_adcm_4535_job_cant_be_terminated_success(self) -> None: ) self.assertEqual(response.status_code, HTTP_200_OK) - job = JobLog.objects.filter(action__name="cluster_host_action_allowed").first() + job = JobLog.objects.filter(task__action__name="cluster_host_action_allowed").first() response = self.client.post(path=reverse(viewname="v2:joblog-terminate", kwargs={"pk": job.pk}), data={}) diff --git a/python/api_v2/tests/test_audit/test_task.py b/python/api_v2/tests/test_audit/test_task.py index ce3197687b..2b5f72da55 100644 --- a/python/api_v2/tests/test_audit/test_task.py +++ b/python/api_v2/tests/test_audit/test_task.py @@ -13,7 +13,7 @@ from datetime import timedelta from unittest.mock import patch -from cm.models import ADCM, Action, ActionType, JobLog, JobStatus, SubAction, TaskLog +from cm.models import ADCM, Action, ActionType, JobLog, JobStatus, TaskLog from django.contrib.contenttypes.models import ContentType from django.utils import timezone from rest_framework.reverse import reverse @@ -47,13 +47,9 @@ def setUp(self) -> None: status=JobStatus.RUNNING, start_date=timezone.now() + timedelta(days=1), finish_date=timezone.now() + timedelta(days=2), - action=self.action, task=self.task_for_job, pid=9999, - sub_action=SubAction.objects.create( - action=self.action, - allow_to_terminate=True, - ), + allow_to_terminate=True, ) self.task = TaskLog.objects.create( object_id=self.adcm.pk, diff --git a/python/api_v2/tests/test_jobs.py b/python/api_v2/tests/test_jobs.py index e954aed728..5616a6ae77 100644 --- a/python/api_v2/tests/test_jobs.py +++ b/python/api_v2/tests/test_jobs.py @@ -21,7 +21,6 @@ JobLog, JobStatus, LogStorage, - SubAction, TaskLog, ) from django.conf import settings @@ -62,13 +61,9 @@ def setUp(self) -> None: status=JobStatus.RUNNING, start_date=timezone.now() + timedelta(days=1), finish_date=timezone.now() + timedelta(days=2), - action=self.action, task=self.task, pid=9999, - sub_action=SubAction.objects.create( - action=self.action, - allow_to_terminate=True, - ), + allow_to_terminate=True, ) self.log_1 = LogStorage.objects.create( job=self.job_1, diff --git a/python/api_v2/tests/test_tasks.py b/python/api_v2/tests/test_tasks.py index 8207315af0..90a5a8b57a 100644 --- a/python/api_v2/tests/test_tasks.py +++ b/python/api_v2/tests/test_tasks.py @@ -14,7 +14,7 @@ from operator import itemgetter from unittest.mock import patch -from cm.job import create_task +from cm.converters import model_name_to_core_type from cm.models import ( ADCM, Action, @@ -26,6 +26,9 @@ ServiceComponent, TaskLog, ) +from cm.services.job.prepare import prepare_task_for_action +from core.job.dto import TaskPayloadDTO +from core.types import ADCMCoreType, CoreObjectDescriptor from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.utils import timezone @@ -47,35 +50,32 @@ def setUp(self) -> None: self.cluster_action = Action.objects.filter(name="action", prototype=self.cluster_1.prototype).first() service_1_action = Action.objects.filter(name="action", prototype=service_1.prototype).first() component_1_action = Action.objects.filter(name="action_1_comp_1", prototype=component_1.prototype).first() - self.cluster_task = create_task( - action=self.cluster_action, - obj=self.cluster_1, - conf={}, - attr={}, - hostcomponent=[], - hosts=[], - verbose=False, - post_upgrade_hc=[], + cluster_object = CoreObjectDescriptor(id=self.cluster_1.pk, type=ADCMCoreType.CLUSTER) + self.cluster_task = TaskLog.objects.get( + id=prepare_task_for_action( + target=cluster_object, + owner=cluster_object, + action=self.cluster_action.pk, + payload=TaskPayloadDTO(), + ).id ) - self.service_task = create_task( - action=service_1_action, - obj=service_1, - conf={}, - attr={}, - hostcomponent=[], - hosts=[], - verbose=False, - post_upgrade_hc=[], + service_object = CoreObjectDescriptor(id=service_1.pk, type=ADCMCoreType.SERVICE) + self.service_task = TaskLog.objects.get( + id=prepare_task_for_action( + target=service_object, + owner=service_object, + action=service_1_action.pk, + payload=TaskPayloadDTO(), + ).id ) - self.component_task = create_task( - action=component_1_action, - obj=component_1, - conf={}, - attr={}, - hostcomponent=[], - hosts=[], - verbose=False, - post_upgrade_hc=[], + component_object = CoreObjectDescriptor(id=component_1.pk, type=ADCMCoreType.COMPONENT) + self.component_task = TaskLog.objects.get( + id=prepare_task_for_action( + target=component_object, + owner=component_object, + action=component_1_action.pk, + payload=TaskPayloadDTO(), + ).id ) self.adcm_task = TaskLog.objects.create( object_id=self.adcm.pk, @@ -260,14 +260,12 @@ def create_task( host: Host | None = None, ): action = Action.objects.get(name=action_name, prototype=object_.prototype) - hosts = [] if not host else [host.pk] - return create_task( - action=action, - obj=host or object_, - conf={}, - attr={}, - hostcomponent=[], - hosts=hosts, - verbose=False, - post_upgrade_hc=[], + + owner = CoreObjectDescriptor( + id=object_.pk, type=model_name_to_core_type(model_name=object_.__class__.__name__.lower()) ) + target = CoreObjectDescriptor(id=host.pk, type=ADCMCoreType.HOST) if host else owner + + launch = prepare_task_for_action(target=target, owner=owner, action=action.pk, payload=TaskPayloadDTO()) + + return TaskLog.objects.get(id=launch.id) diff --git a/python/audit/cases/common.py b/python/audit/cases/common.py index e3a0c18618..3828750cd7 100644 --- a/python/audit/cases/common.py +++ b/python/audit/cases/common.py @@ -79,15 +79,12 @@ def _job_case(job_pk: str, version=1) -> tuple[AuditOperation, AuditObject | Non operation_name = "" if job: - if job.sub_action: - if version == 1: - operation_name = f'Job "{job.sub_action.display_name}"' - if job.action: - operation_name += f' of action "{job.action.display_name}"' - else: - operation_name = job.sub_action.display_name - elif job.action: - operation_name = job.action.display_name + if version == 1: + operation_name = f'Job "{job.display_name or job.name}"' + if job.action: + operation_name += f' of action "{job.action.display_name}"' + else: + operation_name = job.display_name if not operation_name: operation_name = "Job" diff --git a/python/cm/ansible_plugin.py b/python/cm/ansible_plugin.py index d086696f36..94436c1d7c 100644 --- a/python/cm/ansible_plugin.py +++ b/python/cm/ansible_plugin.py @@ -361,8 +361,8 @@ def change_hc(job_id, cluster_id, operations): For use in ansible plugin adcm_hc """ file_descriptor = job_lock(job_id) - job = JobLog.objects.get(id=job_id) - action = Action.objects.get(id=job.action_id) + action_id = JobLog.objects.values_list("task__action_id", flat=True).get(id=job_id) + action = Action.objects.get(id=action_id) if action.hostcomponentmap: raise AdcmEx("ACTION_ERROR", "You can not change hc in plugin for action with hc_acl") diff --git a/python/cm/api.py b/python/cm/api.py index 8d9ecfde9a..2719ec5215 100644 --- a/python/cm/api.py +++ b/python/cm/api.py @@ -611,10 +611,10 @@ def save_hc( hc_queryset.delete() host_component_list = [] - for proto, host, comp in host_comp_list: + for service, host, comp in host_comp_list: host_component = HostComponent( cluster=cluster, - service=proto, + service=service, host=host, component=comp, ) diff --git a/python/cm/converters.py b/python/cm/converters.py index d3f6b61884..db0e5a6ebd 100644 --- a/python/cm/converters.py +++ b/python/cm/converters.py @@ -12,12 +12,12 @@ from core.types import ADCMCoreType -from cm.models import Cluster, ClusterObject, Host, HostProvider, ServiceComponent +from cm.models import ADCM, Cluster, ClusterObject, Host, HostProvider, ServiceComponent def core_type_to_model( core_type: ADCMCoreType, -) -> type[Cluster | ClusterObject | ServiceComponent | HostProvider | Host]: +) -> type[Cluster | ClusterObject | ServiceComponent | HostProvider | Host | ADCM]: match core_type: case ADCMCoreType.CLUSTER: return Cluster @@ -29,6 +29,8 @@ def core_type_to_model( return HostProvider case ADCMCoreType.HOST: return Host + case ADCMCoreType.ADCM: + return ADCM case _: raise ValueError(f"Can't convert {core_type} to ORM model") @@ -45,6 +47,8 @@ def core_type_to_db_record_type(core_type: ADCMCoreType) -> str: return "provider" case ADCMCoreType.HOST: return "host" + case ADCMCoreType.ADCM: + return "adcm" case _: raise ValueError(f"Can't convert {core_type} to type name in DB") diff --git a/python/cm/job.py b/python/cm/job.py index 03057b296d..b1d86fed50 100644 --- a/python/cm/job.py +++ b/python/cm/job.py @@ -9,7 +9,6 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - from collections.abc import Hashable from configparser import ConfigParser from dataclasses import dataclass, field @@ -28,7 +27,8 @@ AuditLogOperationResult, AuditLogOperationType, ) -from core.types import CoreObjectDescriptor +from core.job.dto import TaskPayloadDTO +from core.types import ADCMCoreType, CoreObjectDescriptor from django.conf import settings from django.db.transaction import atomic, on_commit from django.utils import timezone @@ -65,7 +65,6 @@ from cm.models import ( ADCM, Action, - ActionType, ADCMEntity, Cluster, ClusterObject, @@ -76,11 +75,9 @@ HostProvider, JobLog, JobStatus, - LogStorage, MaintenanceMode, Prototype, ServiceComponent, - SubAction, TaskLog, Upgrade, get_object_cluster, @@ -90,7 +87,7 @@ from cm.services.job.inventory import get_inventory_data from cm.services.job.inventory._config import update_configuration_for_inventory_inplace from cm.services.job.types import HcAclAction -from cm.services.job.utils import JobScope, get_selector +from cm.services.job.utils import JobScope from cm.services.status.notify import reset_objects_in_mm from cm.status_api import ( send_object_update_event, @@ -115,6 +112,9 @@ def run_action( payload: ActionRunPayload, hosts: list[int], ) -> TaskLog: + # todo resolve circular dependency + from cm.services.job.prepare import prepare_task_for_action + cluster: Cluster | None = get_object_cluster(obj=obj) if hosts: @@ -151,16 +151,40 @@ def run_action( host_map, post_upgrade_hc = check_hostcomponentmap(cluster=cluster, action=action, new_hc=payload.hostcomponent) with atomic(): - task = create_task( - action=action, - obj=obj, - conf=payload.conf, - attr=payload.attr, - verbose=payload.verbose, - hosts=hosts, - hostcomponent=get_hc(cluster=cluster), - post_upgrade_hc=post_upgrade_hc, + target = CoreObjectDescriptor(id=obj.pk, type=model_name_to_core_type(obj.__class__.__name__.lower())) + owner = target + if target.type == ADCMCoreType.HOST and action.host_action: + match action.prototype_type: + case "cluster": + owner = CoreObjectDescriptor(id=cluster.pk, type=ADCMCoreType.CLUSTER) + case "service": + owner = CoreObjectDescriptor( + id=ClusterObject.objects.values_list("id", flat=True) + .filter(cluster=cluster, prototype_id=action.prototype_id) + .get(), + type=ADCMCoreType.SERVICE, + ) + case "component": + owner = CoreObjectDescriptor( + id=ServiceComponent.objects.values_list("id", flat=True) + .filter(cluster=cluster, prototype_id=action.prototype_id) + .get(), + type=ADCMCoreType.COMPONENT, + ) + + task = prepare_task_for_action( + target=target, + owner=owner, + action=action.pk, + payload=TaskPayloadDTO( + conf=payload.conf, + attr=payload.attr, + verbose=payload.verbose, + hostcomponent=get_hc(cluster=cluster), + post_upgrade_hostcomponent=post_upgrade_hc, + ), ) + task_ = TaskLog.objects.get(id=task.id) if host_map or (is_upgrade_action and host_map is not None): save_hc(cluster=cluster, host_comp_list=host_map) @@ -173,17 +197,17 @@ def run_action( id=obj.pk, type=model_name_to_core_type(model_name=obj._meta.model_name) ), ) - process_file_type(obj=task, spec=spec, conf=payload.conf) - task.config = new_conf - task.save() + process_file_type(obj=task_, spec=spec, conf=payload.conf) + task_.config = new_conf + task_.save() - on_commit(func=partial(send_task_status_update_event, object_=task, status=JobStatus.CREATED.value)) + on_commit(func=partial(send_task_status_update_event, task_id=task_.pk, status=JobStatus.CREATED.value)) - re_apply_policy_for_jobs(action_object=obj, task=task) + re_apply_policy_for_jobs(action_object=obj, task=task_) - run_task(task) + run_task(task_) - return task + return task_ def check_action_hosts(action: Action, obj: ADCMEntity, cluster: Cluster | None, hosts: list[int]) -> None: @@ -493,60 +517,10 @@ def prepare_job(job_scope: JobScope, delta: dict): ) as file_descriptor: json.dump(obj=inventory, fp=file_descriptor, separators=(",", ":")) - prepare_ansible_config(job_id=job_scope.job_id, action=job_scope.action, sub_action=job_scope.sub_action) - - -def create_task( - action: Action, - obj: ADCM | Cluster | ClusterObject | ServiceComponent | HostProvider | Host, - conf: dict, - attr: dict, - hostcomponent: list[dict], - hosts: list[int], - verbose: bool, - post_upgrade_hc: list[dict], -) -> TaskLog: - selector = get_selector(obj=obj, action=action) - task = TaskLog.objects.create( - action=action, - task_object=obj, - config=conf, - attr=attr, - hostcomponentmap=hostcomponent, - hosts=hosts, - post_upgrade_hc_map=post_upgrade_hc, - verbose=verbose, - status=JobStatus.CREATED, - selector=selector, - ) - - if action.type == ActionType.JOB.value: - sub_actions = [None] - else: - sub_actions = SubAction.objects.filter(action=action).order_by("id") - - for sub_action in sub_actions: - job = JobLog.obj.create( - task=task, - action=action, - sub_action=sub_action, - log_files=action.log_files, - status=JobStatus.CREATED, - selector=selector, - ) - log_type = sub_action.script_type if sub_action else action.script_type - LogStorage.objects.create(job=job, name=log_type, type="stdout", format="txt") - LogStorage.objects.create(job=job, name=log_type, type="stderr", format="txt") - Path(settings.RUN_DIR, f"{job.pk}", "tmp").mkdir(parents=True, exist_ok=True) - - return task + prepare_ansible_config(job_id=job_scope.job_id, action=job_scope.action) def get_state(action: Action, job: JobLog, status: str) -> tuple[str | None, list[str], list[str]]: - sub_action = None - if job and job.sub_action: - sub_action = job.sub_action - if status == JobStatus.SUCCESS: multi_state_set = action.multi_state_on_success_set multi_state_unset = action.multi_state_on_success_unset @@ -554,9 +528,9 @@ def get_state(action: Action, job: JobLog, status: str) -> tuple[str | None, lis if not state: logger.warning('action "%s" success state is not set', action.name) elif status == JobStatus.FAILED: - state = getattr_first("state_on_fail", sub_action, action) - multi_state_set = getattr_first("multi_state_on_fail_set", sub_action, action) - multi_state_unset = getattr_first("multi_state_on_fail_unset", sub_action, action) + state = getattr_first("state_on_fail", job, action) + multi_state_set = getattr_first("multi_state_on_fail_set", job, action) + multi_state_unset = getattr_first("multi_state_on_fail_unset", job, action) if not state: logger.warning('action "%s" fail state is not set', action.name) else: @@ -700,7 +674,7 @@ def finish_task(task: TaskLog, job: JobLog | None, status: str) -> None: set_task_final_status(task=task, status=status) - send_task_status_update_event(object_=task, status=status) + send_task_status_update_event(task_id=task.pk, status=status) try: reset_objects_in_mm() @@ -715,10 +689,11 @@ def run_task(task: TaskLog, args: str = ""): "a+", encoding=settings.ENCODING_UTF_8, ) + cmd = [ - str(Path(settings.CODE_DIR, "task_runner.py")), + str(Path(settings.CODE_DIR, "runner.py")), + "restart" if args == "restart" else "start", str(task.pk), - args, ] logger.info("task run cmd: %s", " ".join(cmd)) proc = subprocess.Popen( # noqa: SIM115 @@ -731,7 +706,7 @@ def run_task(task: TaskLog, args: str = ""): lock_affected_objects(task=task, objects=affected_objs) -def prepare_ansible_config(job_id: int, action: Action, sub_action: SubAction): +def prepare_ansible_config(job_id: int, action: Action): config_parser = ConfigParser() config_parser["defaults"] = { "stdout_callback": "yaml", @@ -743,10 +718,8 @@ def prepare_ansible_config(job_id: int, action: Action, sub_action: SubAction): forks = adcm_conf["ansible_settings"]["forks"] config_parser["defaults"]["forks"] = str(forks) - params = action.params - if sub_action: - params = sub_action.params + params = JobLog.objects.values_list("params").get(pk=job_id) or action.params if "jinja2_native" in params: config_parser["defaults"]["jinja2_native"] = str(params["jinja2_native"]) diff --git a/python/cm/migrations/0116_autonomous_joblogs.py b/python/cm/migrations/0116_autonomous_joblogs.py new file mode 100644 index 0000000000..f93d44f3ed --- /dev/null +++ b/python/cm/migrations/0116_autonomous_joblogs.py @@ -0,0 +1,215 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated by Django 3.2.23 on 2024-02-22 10:06 +from operator import attrgetter, itemgetter + +from django.db import migrations, models + + +def extract_sub_action_data_to_joblogs(apps, schema_editor): + JobLog = apps.get_model("cm", "JobLog") + SubAction = apps.get_model("cm", "SubAction") + Action = apps.get_model("cm", "Action") + + for action in Action.objects.filter(type="task"): + SubAction.objects.filter(action=action, allow_to_terminate__isnull=True).update( + allow_to_terminate=action.allow_to_terminate + ) + + requested_for_update = JobLog.objects.values("id", "sub_action_id").filter(sub_action__isnull=False) + fields_to_move = ( + "name", + "display_name", + "allow_to_terminate", + "script", + "script_type", + "state_on_fail", + "multi_state_on_fail_set", + "multi_state_on_fail_unset", + ) + for sub_action_update_dict in SubAction.objects.values("id", *fields_to_move).filter( + id__in=set(map(itemgetter("sub_action_id"), requested_for_update)) + ): + sub_action_id = sub_action_update_dict.pop("id") + JobLog.objects.filter(sub_action_id=sub_action_id).update(**sub_action_update_dict) + + default_to_fill = {"name": "unknown", "script": "unknown", "script_type": "ansible"} + JobLog.objects.filter(sub_action__isnull=True).update(**default_to_fill) + + +class Migration(migrations.Migration): + dependencies = [ + ("cm", "0115_auto_20231025_1823"), + ] + + operations = [ + # create nullable fields + migrations.AlterField( + model_name='joblog', + name='status', + field=models.CharField( + choices=[('created', 'created'), ('success', 'success'), ('failed', 'failed'), ('running', 'running'), + ('locked', 'locked'), ('aborted', 'aborted'), ('broken', 'broken')], max_length=1000), + ), + migrations.AlterField( + model_name='tasklog', + name='status', + field=models.CharField( + choices=[('created', 'created'), ('success', 'success'), ('failed', 'failed'), ('running', 'running'), + ('locked', 'locked'), ('aborted', 'aborted'), ('broken', 'broken')], max_length=1000), + ), + migrations.AddField( + model_name="joblog", + name="multi_state_on_fail_set", + field=models.JSONField(default=list), + ), + migrations.AddField( + model_name="joblog", + name="multi_state_on_fail_unset", + field=models.JSONField(default=list), + ), + migrations.AddField( + model_name="joblog", + name="params", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="joblog", + name="script", + field=models.CharField(max_length=1000, null=True), + ), + migrations.AddField( + model_name="joblog", + name="script_type", + field=models.CharField( + choices=[("ansible", "ansible"), ("python", "python"), ("internal", "internal")], + max_length=1000, + null=True, + ), + ), + migrations.AddField( + model_name="joblog", + name="state_on_fail", + field=models.CharField(blank=True, max_length=1000), + ), + migrations.AddField( + model_name="joblog", + name="allow_to_terminate", + field=models.BooleanField(default=None, null=True), + ), + migrations.AddField( + model_name="joblog", + name="display_name", + field=models.CharField(blank=True, max_length=1000), + ), + migrations.AddField( + model_name="joblog", + name="name", + field=models.CharField(null=True, max_length=1000), + preserve_default=False, + ), + migrations.AddField( + model_name='tasklog', + name='owner_id', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='tasklog', + name='owner_type', + field=models.CharField( + choices=[('adcm', 'adcm'), ('cluster', 'cluster'), ('service', 'service'), ('component', 'component'), + ('hostprovider', 'hostprovider'), ('host', 'host')], max_length=100, null=True), + ), + # move what data can be saved + # todo add reverse code + migrations.RunPython(extract_sub_action_data_to_joblogs), + # make those non-nullable + migrations.AlterField( + model_name="joblog", + name="script", + field=models.CharField(max_length=1000), + ), + migrations.AlterField( + model_name="joblog", + name="script_type", + field=models.CharField( + choices=[("ansible", "ansible"), ("python", "python"), ("internal", "internal")], max_length=1000 + ), + ), + migrations.AlterField( + model_name="joblog", + name="name", + field=models.CharField(null=False, max_length=1000), + ), + migrations.AlterField( + model_name="joblog", + name="allow_to_terminate", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="stagesubaction", + name="allow_to_terminate", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="subaction", + name="allow_to_terminate", + field=models.BooleanField(default=False), + ), + # because script types weren't correctly defined + migrations.AlterField( + model_name="action", + name="script_type", + field=models.CharField( + choices=[("ansible", "ansible"), ("python", "python"), ("internal", "internal")], max_length=1000 + ), + ), + migrations.AlterField( + model_name="stageaction", + name="script_type", + field=models.CharField( + choices=[("ansible", "ansible"), ("python", "python"), ("internal", "internal")], max_length=1000 + ), + ), + migrations.AlterField( + model_name="stagesubaction", + name="script_type", + field=models.CharField( + choices=[("ansible", "ansible"), ("python", "python"), ("internal", "internal")], max_length=1000 + ), + ), + migrations.AlterField( + model_name="subaction", + name="script_type", + field=models.CharField( + choices=[("ansible", "ansible"), ("python", "python"), ("internal", "internal")], max_length=1000 + ), + ), + # remove redundant links + migrations.RemoveField( + model_name="joblog", + name="action", + ), + migrations.RemoveField( + model_name="joblog", + name="log_files", + ), + migrations.RemoveField( + model_name="joblog", + name="selector", + ), + migrations.RemoveField( + model_name="joblog", + name="sub_action", + ), + ] diff --git a/python/cm/models.py b/python/cm/models.py index 8c760e22ca..514b0c4b7c 100644 --- a/python/cm/models.py +++ b/python/cm/models.py @@ -20,6 +20,8 @@ import signal import os.path +from core.job.types import ScriptType +from core.types import ADCMCoreType from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType @@ -327,7 +329,7 @@ def __str__(self): def set_state(self, state: str) -> None: self.state = state or self.state - self.save() + self.save(update_fields=["state"]) logger.info('set %s state to "%s"', self, state) def get_id_chain(self) -> dict: @@ -378,6 +380,11 @@ def content_type(self): model_name = self.__class__.__name__.lower() return ContentType.objects.get(app_label="cm", model=model_name) + @classmethod + @property + def class_content_type(cls): + return ContentType.objects.get(app_label="cm", model=cls.__name__.lower()) + def delete(self, using=None, keep_parents=False): for concern in self.concerns.filter(owner_type=self.content_type, owner_id=self.id): logger.debug("Delete %s", str(concern)) @@ -1003,10 +1010,7 @@ class ActionType(models.TextChoices): JOB = "job", "job" -SCRIPT_TYPE = ( - ("ansible", "ansible"), - ("task_generator", "task_generator"), -) +SCRIPT_TYPE = tuple((entry.value, entry.value) for entry in ScriptType) def get_any(): @@ -1214,7 +1218,7 @@ class AbstractSubAction(ADCMModel): multi_state_on_fail_set = models.JSONField(default=list) multi_state_on_fail_unset = models.JSONField(default=list) params = models.JSONField(default=dict) - allow_to_terminate = models.BooleanField(null=True, default=None) + allow_to_terminate = models.BooleanField(default=False) class Meta: abstract = True @@ -1223,12 +1227,6 @@ class Meta: class SubAction(AbstractSubAction): action = models.ForeignKey(Action, on_delete=models.CASCADE) - @property - def allowed_to_terminate(self) -> bool: - if self.allow_to_terminate is None: - return self.action.allow_to_terminate - return self.allow_to_terminate - class HostComponent(ADCMModel): cluster = models.ForeignKey(Cluster, on_delete=models.CASCADE) @@ -1329,6 +1327,7 @@ class JobStatus(models.TextChoices): RUNNING = "running", "running" LOCKED = "locked", "locked" ABORTED = "aborted", "aborted" + BROKEN = "broken", "broken" class UserProfile(ADCMModel): @@ -1340,6 +1339,12 @@ class TaskLog(ADCMModel): object_id = models.PositiveIntegerField() object_type = models.ForeignKey(ContentType, null=True, on_delete=models.CASCADE) task_object = GenericForeignKey("object_type", "object_id") + + owner_id = models.PositiveIntegerField(default=0) + owner_type = models.CharField( + max_length=100, choices=((type_.value, type_.value) for type_ in ADCMCoreType), null=True + ) + action = models.ForeignKey(Action, on_delete=models.SET_NULL, null=True, default=None) pid = models.PositiveIntegerField(blank=True, default=0) selector = models.JSONField(default=dict) @@ -1400,13 +1405,9 @@ def duration(self) -> float | None: return (self.finish_date - self.start_date).total_seconds() -class JobLog(ADCMModel): +class JobLog(AbstractSubAction): task = models.ForeignKey(TaskLog, on_delete=models.SET_NULL, null=True, default=None) - action = models.ForeignKey(Action, on_delete=models.SET_NULL, null=True, default=None) - sub_action = models.ForeignKey(SubAction, on_delete=models.SET_NULL, null=True, default=None) pid = models.PositiveIntegerField(blank=True, default=0) - selector = models.JSONField(default=dict) - log_files = models.JSONField(default=list) status = models.CharField(max_length=1000, choices=JobStatus.choices) start_date = models.DateTimeField(null=True, default=None) finish_date = models.DateTimeField(db_index=True, null=True, default=None) @@ -1416,6 +1417,13 @@ class JobLog(ADCMModel): class Meta: ordering = ["id"] + @property + def action(self) -> Action | None: + try: + return self.task.action + except (ObjectDoesNotExist, AttributeError): + return None + def cook_reason(self): return MessageTemplate.get_message_from_template( KnownNames.LOCKED_BY_JOB.value, @@ -1424,7 +1432,7 @@ def cook_reason(self): ) def cancel(self): - if self.sub_action and not self.sub_action.allowed_to_terminate: + if not self.allow_to_terminate: raise AdcmEx("JOB_TERMINATION_ERROR", f"Job #{self.pk} can not be terminated") if self.status != JobStatus.RUNNING or self.pid == 0: @@ -1718,14 +1726,13 @@ def _adcm_entity_placeholder(cls, ph_name, **kwargs) -> dict: @classmethod def _job_placeholder(cls, _, **kwargs) -> dict: job = kwargs.get("job") - action = job.sub_action or job.action if not job: return {} return { "type": PlaceHolderType.JOB.value, - "name": action.display_name or action.name, + "name": job.display_name or job.name or job.action.display_name, "params": {"job_id": job.task.id}, } diff --git a/python/cm/services/job/config.py b/python/cm/services/job/config.py index cababc283e..5a60f902cd 100644 --- a/python/cm/services/job/config.py +++ b/python/cm/services/job/config.py @@ -93,7 +93,7 @@ def _get_job_data(job_scope: JobScope) -> JobData: command=job_scope.action.name, script=job_scope.action.script, verbose=job_scope.task.verbose, - playbook=get_script_path(action=job_scope.action, sub_action=job_scope.sub_action), + playbook=get_script_path(action=job_scope.action, job=job_scope.job), action_type_specification=_get_action_type_specific_data( cluster=cluster, obj=job_scope.object, action=job_scope.action ), @@ -102,12 +102,11 @@ def _get_job_data(job_scope: JobScope) -> JobData: if job_scope.action.params: job_data.params = job_scope.action.params - if job_scope.sub_action: - job_data.script = job_scope.sub_action.script - job_data.job_name = job_scope.sub_action.name - job_data.command = job_scope.sub_action.name - if job_scope.sub_action.params: - job_data.params = job_scope.sub_action.params + job_data.script = job_scope.job.script + job_data.job_name = job_scope.job.name + job_data.command = job_scope.job.name + if job_scope.job.params: + job_data.params = job_scope.job.params if cluster is not None: job_data.cluster_id = cluster.pk diff --git a/python/cm/services/job/prepare.py b/python/cm/services/job/prepare.py new file mode 100644 index 0000000000..766c8a2a81 --- /dev/null +++ b/python/cm/services/job/prepare.py @@ -0,0 +1,29 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from core.job.dto import TaskPayloadDTO +from core.job.task import compose_task +from core.job.types import Task +from core.types import ActionID, CoreObjectDescriptor + +from cm.services.job.run.repo import ActionRepoImpl, JobRepoImpl + + +def prepare_task_for_action( + target: CoreObjectDescriptor, + owner: CoreObjectDescriptor, + action: ActionID, + payload: TaskPayloadDTO, +) -> Task: + return compose_task( + target=target, owner=owner, action=action, payload=payload, job_repo=JobRepoImpl, action_repo=ActionRepoImpl + ) diff --git a/python/cm/services/job/run/__init__.py b/python/cm/services/job/run/__init__.py new file mode 100644 index 0000000000..dd5b7c57bd --- /dev/null +++ b/python/cm/services/job/run/__init__.py @@ -0,0 +1,15 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from cm.services.job.run._impl import get_default_runner, get_restart_runner + +__all__ = ["get_default_runner", "get_restart_runner"] diff --git a/python/cm/services/job/run/_impl.py b/python/cm/services/job/run/_impl.py new file mode 100644 index 0000000000..a89936412d --- /dev/null +++ b/python/cm/services/job/run/_impl.py @@ -0,0 +1,71 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime +import os +import logging + +from core.job.runners import ADCMSettings, AnsibleSettings, ExternalSettings, JobProcessor +from core.job.types import ExecutionStatus +from django.conf import settings +from django.utils import timezone + +from cm import status_api +from cm.services.job.run._target_factories import ExecutionTargetFactory +from cm.services.job.run.repo import JobRepoImpl +from cm.services.job.run.runners import JobSequenceRunner +from cm.services.status import notify + +logger = logging.getLogger("task_runner_err") + + +class SubprocessRunnerEnvironment: + @property + def pid(self) -> int: + return os.getpid() + + def now(self) -> datetime: + return timezone.now() + + +def get_default_runner(): + return JobSequenceRunner( + job_processor=JobProcessor(convert=ExecutionTargetFactory()), + settings=_prepare_settings(), + repo=JobRepoImpl, + environment=SubprocessRunnerEnvironment(), + notifier=status_api, + status_server=notify, + logger=logger, + ) + + +def get_restart_runner(): + return JobSequenceRunner( + job_processor=JobProcessor( + convert=ExecutionTargetFactory(), + filter_predicate=lambda job: job.status != ExecutionStatus.SUCCESS, + ), + settings=_prepare_settings(), + repo=JobRepoImpl, + environment=SubprocessRunnerEnvironment(), + notifier=status_api, + status_server=notify, + logger=logger, + ) + + +def _prepare_settings() -> ExternalSettings: + return ExternalSettings( + adcm=ADCMSettings(code_root_dir=settings.CODE_DIR, run_dir=settings.RUN_DIR), + ansible=AnsibleSettings(ansible_secret_script=settings.CODE_DIR / "ansible_secret.py"), + ) diff --git a/python/cm/services/job/run/_target_factories.py b/python/cm/services/job/run/_target_factories.py new file mode 100644 index 0000000000..63f4689767 --- /dev/null +++ b/python/cm/services/job/run/_target_factories.py @@ -0,0 +1,181 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from functools import partial +from pathlib import Path +from typing import Generator, Iterable, Literal + +from core.job.executors import BundleExecutorConfig, ExecutorConfig +from core.job.runners import ExecutionTarget, ExternalSettings +from core.job.types import Job, ScriptType, Task + +from cm.ansible_plugin import finish_check +from cm.api import get_hc, save_hc +from cm.job import check_hostcomponentmap, re_prepare_job +from cm.models import JobLog, LogStorage, Prototype, ServiceComponent, TaskLog +from cm.services.job.run.executors import ( + AnsibleExecutorConfig, + AnsibleProcessExecutor, + InternalExecutor, + PythonProcessExecutor, +) +from cm.services.job.utils import JobScope +from cm.status_api import send_prototype_and_state_update_event +from cm.upgrade import bundle_revert, bundle_switch + + +def _prepare_ansible_environment(job: Job) -> None: + # todo rework re-prepare, so it won't request what is shouldn't + # probably will require something else but job_info here + + # fixme with null object will fail at + # `cm.services.job.config.get_job_config` | line: object_type=job_scope.object.prototype.type, + re_prepare_job(job_scope=JobScope(job_id=job.id, object=JobLog.objects.get(id=job.id).task.task_object)) + + +def _finish_check_logs(job: Job) -> None: + finish_check(job.id) + + +def _save_fs_logs_to_db(job: Job, work_dir: Path, log_type: Literal["stdout", "stderr"]) -> None: + # todo maybe format can be unified with one that's used by `WithErrOutLogsMixin` + log_path = work_dir / f"{job.type.value}-{log_type}.txt" + if not log_path.is_file(): + # todo raise exception? - only if each step "is catchable" + return + + corresponding_log = LogStorage.objects.filter(job_id=job.id, name=job.type.value, type=log_type).first() + if not corresponding_log: + return + + corresponding_log.body = log_path.read_text(encoding="utf-8") + corresponding_log.save(update_fields=["body"]) + + +def _switch_hc_if_required(task: TaskLog): + """ + Should be performed during upgrade of cluster, if not cluster, no need in HC update. + Because it's upgrade, it will be called either on cluster or hostprovider, + so task object will be one of those too. + """ + if task.task_object.prototype.type != "cluster": + return + + cluster = task.task_object + old_hc = get_hc(cluster) + new_hc = [] + for hostcomponent in [*task.post_upgrade_hc_map, *old_hc]: + if hostcomponent not in new_hc: + new_hc.append(hostcomponent) + + task.hostcomponentmap = old_hc + task.post_upgrade_hc_map = None + task.save() + + for hostcomponent in new_hc: + if "component_prototype_id" in hostcomponent: + proto = Prototype.objects.get(type="component", id=hostcomponent.pop("component_prototype_id")) + comp = ServiceComponent.objects.get(cluster=cluster, prototype=proto) + hostcomponent["component_id"] = comp.id + hostcomponent["service_id"] = comp.service.id + + host_map, _ = check_hostcomponentmap(cluster, task.action, new_hc) + if host_map is not None: + save_hc(cluster, host_map) + + +def _internal_script_bundle_switch(task: Task) -> int: + task_ = TaskLog.objects.get(id=task.id) + + bundle_switch(obj=task_.task_object, upgrade=task_.action.upgrade) + _switch_hc_if_required(task=task_) + + return 0 + + +def _internal_script_bundle_revert(task: Task) -> int: + task = TaskLog.objects.get(id=task.id) + + try: + bundle_revert(obj=task.task_object) + finally: + send_prototype_and_state_update_event(object_=task.task_object) + + _switch_hc_if_required(task=task) + + return 0 + + +def _internal_script_hc_apply(task: Task) -> int: + TaskLog.objects.filter(id=task.id).update(restore_hc_on_fail=False) + + return 0 + + +class ExecutionTargetFactory: + def __init__(self): + self._default_ansible_finalizers = (_finish_check_logs,) + self._supported_internal_scripts = { + "bundle_switch": _internal_script_bundle_switch, + "bundle_revert": _internal_script_bundle_revert, + "hc_apply": _internal_script_hc_apply, + } + + def __call__( + self, task: Task, jobs: Iterable[Job], configuration: ExternalSettings + ) -> Generator[ExecutionTarget, None, None]: + for job_info in jobs: + work_dir = configuration.adcm.run_dir / str(job_info.id) + finalizers = ( + partial(_save_fs_logs_to_db, work_dir=work_dir, log_type="stderr"), + partial(_save_fs_logs_to_db, work_dir=work_dir, log_type="stdout"), + ) + match job_info.type: + case ScriptType.ANSIBLE: + executor = AnsibleProcessExecutor( + config=AnsibleExecutorConfig( + script_file=Path(job_info.script), + work_dir=work_dir, + bundle_root=task.bundle_root, + tags=job_info.params.ansible_tags, + verbose=task.verbose, + venv=task.venv, + ansible_secret_script=configuration.ansible.ansible_secret_script, + ) + ) + finalizers = (*self._default_ansible_finalizers, *finalizers) + environment_builders = (_prepare_ansible_environment,) + case ScriptType.PYTHON: + executor = PythonProcessExecutor( + config=BundleExecutorConfig( + script_file=Path(job_info.script), + work_dir=work_dir, + bundle_root=task.bundle_root, + ) + ) + environment_builders = () + case ScriptType.INTERNAL: + internal_script_func = self._supported_internal_scripts.get(job_info.script) + if not internal_script_func: + message = f"Unknown internal script {job_info.type}, can't build runner for it" + raise NotImplementedError(message) + + script = partial(internal_script_func, task=task) + executor = InternalExecutor(config=ExecutorConfig(work_dir=work_dir), script=script) + environment_builders = () + case _: + message = f"Can't convert job of type {job_info.type}" + raise NotImplementedError(message) + + yield ExecutionTarget( + job=job_info, executor=executor, environment_builders=environment_builders, finalizers=finalizers + ) diff --git a/python/cm/services/job/run/executors.py b/python/cm/services/job/run/executors.py new file mode 100644 index 0000000000..6785db5998 --- /dev/null +++ b/python/cm/services/job/run/executors.py @@ -0,0 +1,107 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path +from typing import Callable + +from core.job.executors import ( + BundleExecutorConfig, + ExecutionResult, + Executor, + ExecutorConfig, + ProcessExecutor, + WithErrOutLogsMixin, +) +from typing_extensions import Self + +from cm.errors import AdcmEx +from cm.utils import get_env_with_venv_path + + +class AnsibleExecutorConfig(BundleExecutorConfig): + ansible_secret_script: Path + tags: str + verbose: bool + venv: str + + +class AnsibleProcessExecutor(ProcessExecutor): + script_type = "ansible" + + _config: AnsibleExecutorConfig + + def __init__(self, config: AnsibleExecutorConfig): + super().__init__(config=config) + + def _prepare_command(self) -> list[str]: + playbook = self._config.script_file + cmd = [ + "ansible-playbook", + "--vault-password-file", + str(self._config.ansible_secret_script), + "-e", + f"@{self._config.work_dir}/config.json", + "-i", + f"{self._config.work_dir}/inventory.json", + playbook, + ] + + if self._config.tags: + cmd.append(f"--tags={self._config.tags}") + + if self._config.verbose: + cmd.append("-vvvv") + + return cmd + + def _get_environment_variables(self) -> dict: + env = super()._get_environment_variables() + + env = get_env_with_venv_path(venv=self._config.venv, existing_env=env) + + # This condition is intended to support compatibility. + # Since older bundle versions may contain their own ansible.cfg + if not Path(self._config.bundle_root, "ansible.cfg").is_file(): + env["ANSIBLE_CONFIG"] = str(self._config.work_dir / "ansible.cfg") + + return env + + +class PythonProcessExecutor(ProcessExecutor): + script_type = "python" + + def _prepare_command(self) -> list[str]: + return ["python", self._config.script_file] + + +class InternalExecutor(Executor, WithErrOutLogsMixin): + script_type = "internal" + + def __init__(self, config: ExecutorConfig, script: Callable[[], int]): + super().__init__(config=config) + self._script = script + + def execute(self) -> Self: + self._open_logs(log_dir=self._config.work_dir, log_prefix=self.script_type) + + try: + return_code = self._script() + except AdcmEx as err: + self._err_log.write(err.msg) + return_code = 1 + + self._result = ExecutionResult(code=return_code) + + return self + + def wait_finished(self) -> Self: + return self diff --git a/python/cm/services/job/run/repo.py b/python/cm/services/job/run/repo.py new file mode 100644 index 0000000000..f54a6674c4 --- /dev/null +++ b/python/cm/services/job/run/repo.py @@ -0,0 +1,349 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from contextlib import suppress +from typing import Collection, Iterable + +from core.errors import NotFoundError +from core.job.dto import JobUpdateDTO, LogCreateDTO, TaskPayloadDTO, TaskUpdateDTO +from core.job.types import ( + ActionInfo, + ExecutionStatus, + HostComponentChanges, + Job, + JobParams, + JobSpec, + ScriptType, + StateChanges, + Task, +) +from core.types import ( + ActionID, + ADCMCoreType, + ADCMDescriptor, + CoreObjectDescriptor, + HostID, + NamedCoreObject, + PrototypeDescriptor, +) +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist +from django.db.models import F, QuerySet, Value + +from cm.converters import core_type_to_model, db_record_type_to_core_type +from cm.models import ( + ADCM, + Action, + ActionType, + Cluster, + ClusterObject, + Host, + HostProvider, + JobLog, + LogStorage, + ServiceComponent, + SubAction, + TaskLog, + Upgrade, +) + + +class JobRepoImpl: + # need to filter out "unsupported" values, because no guarantee DB have correct ones + _supported_statuses = tuple(entry.value for entry in ExecutionStatus) + _supported_script_types = tuple(entry.value for entry in ScriptType) + _selector_fields_map = { + Cluster: {"object_id": F("id"), "object_name": F("name"), "type_name": Value(ADCMCoreType.CLUSTER.value)}, + ClusterObject: { + "object_id": F("id"), + "object_name": F("prototype__display_name"), + "type_name": Value(ADCMCoreType.SERVICE.value), + }, + ServiceComponent: { + "object_id": F("id"), + "object_name": F("prototype__display_name"), + "type_name": Value(ADCMCoreType.COMPONENT.value), + }, + Host: {"object_id": F("id"), "object_name": F("fqdn"), "type_name": Value(ADCMCoreType.HOST.value)}, + HostProvider: {"object_id": F("id"), "object_name": F("name"), "type_name": Value("provider")}, + } + + @staticmethod + def get_task(id: int) -> Task: # noqa: A002 + try: + task_record: TaskLog = ( + TaskLog.objects.select_related("action__prototype") + .prefetch_related("task_object__prototype__bundle") + .get(id=id) + ) + except ObjectDoesNotExist: + message = f"Can't find task identified by {id}" + raise NotFoundError(message) from None + + if not task_record.action: + message = f"Task identified by {id} doesn't have linked action" + raise RuntimeError(message) + + action_prototype = task_record.action.prototype + target_ = bundle_root = None + if target := task_record.task_object: + target_ = NamedCoreObject( + id=target.pk, type=db_record_type_to_core_type(db_record_type=target.prototype.type), name=target.name + ) + if action_prototype.type == "adcm": + bundle_root = settings.BASE_DIR / "conf" / "adcm" + else: + bundle_root = settings.BUNDLE_DIR / action_prototype.bundle.hash / action_prototype.path + + owner = None + if task_record.owner_type and task_record.owner_id: + owner_type = ADCMCoreType(task_record.owner_type) + # object can be deleted at any point, so if it doesn't exist anymore, owner should be None + if core_type_to_model(core_type=owner_type).objects.filter(id=task_record.owner_id).exists(): + owner = CoreObjectDescriptor(id=task_record.owner_id, type=owner_type) + + return Task( + id=id, + target=target_, + owner=owner, + is_upgrade=Upgrade.objects.filter(action=task_record.action).exists(), + name=task_record.action.name, + display_name=task_record.action.display_name, + bundle_root=bundle_root, + venv=task_record.action.venv, + verbose=task_record.verbose, + hostcomponent=HostComponentChanges( + to_set=task_record.hostcomponentmap, + post_upgrade=task_record.post_upgrade_hc_map, + restore_on_fail=task_record.restore_hc_on_fail, + ), + on_success=StateChanges( + state=task_record.action.state_on_success, + multi_state_set=tuple(task_record.action.multi_state_on_success_set or ()), + multi_state_unset=tuple(task_record.action.multi_state_on_success_unset or ()), + ), + on_fail=StateChanges( + state=task_record.action.state_on_fail, + multi_state_set=tuple(task_record.action.multi_state_on_fail_set or ()), + multi_state_unset=tuple(task_record.action.multi_state_on_fail_unset or ()), + ), + ) + + @classmethod + def create_task( + cls, target: CoreObjectDescriptor, owner: CoreObjectDescriptor, action: ActionInfo, payload: TaskPayloadDTO + ) -> Task: + if action.owner_prototype.type == ADCMCoreType.ADCM: + if target.type != ADCMCoreType.ADCM: + message = f"ADCM actions can be launched only on ADCM: {target=} ; {action.owner_prototype=}" + raise TypeError(message) + + selector = {"adcm": {"id": target.id, "name": "adcm"}} + object_type = ADCM.class_content_type + elif target.type == ADCMDescriptor: + message = f"ADCM actions can be launched only on ADCM: {target=} ; {action.owner_prototype=}" + raise TypeError(message) + else: + selector = cls._get_selector_for_core_object(target=target, owner=action.owner_prototype) + object_type = core_type_to_model(core_type=target.type).class_content_type + + task = TaskLog.objects.create( + action_id=action.id, + object_id=target.id, + object_type=object_type, + owner_id=owner.id, + owner_type=owner.type.value, + config=payload.conf, + attr=payload.attr or {}, + hostcomponentmap=payload.hostcomponent, + post_upgrade_hc_map=payload.post_upgrade_hostcomponent, + verbose=payload.verbose, + status=ExecutionStatus.CREATED.value, + selector=selector, + ) + + return cls.get_task(id=task.pk) + + @classmethod + def get_task_jobs(cls, task_id: int) -> Iterable[Job]: + return map(cls._job_from_job_log, cls._job_log_qs().filter(task_id=task_id)) + + @classmethod + def get_job(cls, id: int) -> Job: # noqa: A002 + with suppress(ObjectDoesNotExist): + return cls._job_from_job_log(cls._job_log_qs().filter(id=id).get()) + + message = f"Can't find job with id {id}" + raise NotFoundError(message) + + @staticmethod + def update_task(id: int, data: TaskUpdateDTO) -> None: # noqa: A002 + # todo probably better to do `exclude_unset` + fields_to_change: dict = data.dict(exclude_none=True) + if "status" in fields_to_change: + fields_to_change["status"] = fields_to_change["status"].value + + TaskLog.objects.filter(id=id).update(**fields_to_change) + + @staticmethod + def update_job(id: int, data: JobUpdateDTO) -> None: # noqa: A002 + # todo probably better to do `exclude_unset` + fields_to_change: dict = data.dict(exclude_none=True) + if "status" in fields_to_change: + fields_to_change["status"] = fields_to_change["status"].value + + JobLog.objects.filter(id=id).update(**fields_to_change) + + @staticmethod + def create_jobs(task_id: int, jobs: Iterable[JobSpec]) -> None: + JobLog.objects.bulk_create( + JobLog( + task_id=task_id, + status=ExecutionStatus.CREATED.value, + **job.dict(), + ) + for job in jobs + ) + + @staticmethod + def create_logs(logs: Iterable[LogCreateDTO]) -> None: + LogStorage.objects.bulk_create( + LogStorage(job_id=log.job_id, name=log.name, type=log.type, format=log.format) for log in logs + ) + + @classmethod + def update_owner_state(cls, owner: CoreObjectDescriptor, state: str) -> None: + core_type_to_model(core_type=owner.type).objects.filter(id=owner.id).update(state=state) + + @classmethod + def update_owner_multi_states( + cls, owner: CoreObjectDescriptor, add_multi_states: Collection[str], remove_multi_states: Collection[str] + ) -> None: + current_multi_state: dict = ( + core_type_to_model(core_type=owner.type).objects.values_list("_multi_state", flat=True).get(id=owner.id) + ) + + current_multi_state |= {state: 1 for state in add_multi_states} + for remove_key in remove_multi_states: + current_multi_state.pop(remove_key, None) + + core_type_to_model(core_type=owner.type).objects.filter(id=owner.id).update(_multi_state=current_multi_state) + + @staticmethod + def _job_from_job_log(job: JobLog) -> Job: + ansible_tags = job.params.get("ansible_tags") or "" + if not isinstance(ansible_tags, str): + # todo I don't like to fix it here, + # but not sure we can validate it now on config.yaml load + ansible_tags = "" + if isinstance(ansible_tags, (list, tuple)): + ansible_tags = ",".join(map(str, ansible_tags)) + + return Job( + id=job.id, + pid=job.pid, + type=ScriptType(job.script_type), + status=ExecutionStatus(job.status), + script=job.script, + params=JobParams(ansible_tags=ansible_tags), + on_fail=StateChanges( + state=job.state_on_fail, + multi_state_set=tuple(job.multi_state_on_fail_set or ()), + multi_state_unset=tuple(job.multi_state_on_fail_unset or ()), + ), + ) + + @classmethod + def _job_log_qs(cls) -> QuerySet: + return JobLog.objects.order_by("id").filter( + script_type__in=cls._supported_script_types, status__in=cls._supported_statuses + ) + + @classmethod + def _get_selector_for_core_object(cls, target: CoreObjectDescriptor, owner: PrototypeDescriptor) -> dict: + model_ = core_type_to_model(core_type=target.type) + query = model_.objects.values(**cls._selector_fields_map[model_]).filter(id=target.id) + + match target.type, owner.type: + case (ADCMCoreType.HOST, ADCMCoreType.HOST): + hostprovider_id = Host.objects.values_list("provider_id", flat=True).get(id=target.id) + query = query.union( + HostProvider.objects.values(**cls._selector_fields_map[HostProvider]).filter(id=hostprovider_id) + ) + case (ADCMCoreType.HOST, ADCMCoreType.CLUSTER | ADCMCoreType.SERVICE | ADCMCoreType.COMPONENT): + query = query.union(cls._get_host_related_selector(host_id=target.id, action_owner=owner)) + case (ADCMCoreType.SERVICE, _): + cluster_id = ClusterObject.objects.values_list("cluster_id", flat=True).get(id=target.id) + query = query.union(Cluster.objects.values(**cls._selector_fields_map[Cluster]).filter(id=cluster_id)) + case (ADCMCoreType.COMPONENT, _): + cluster_id, service_id = ServiceComponent.objects.values_list("cluster_id", "service_id").get( + id=target.id + ) + cluster_qs = Cluster.objects.values(**cls._selector_fields_map[Cluster]).filter(id=cluster_id) + service_qs = ClusterObject.objects.values(**cls._selector_fields_map[ClusterObject]).filter( + id=service_id + ) + query = query.union(cluster_qs).union(service_qs) + + return {entry["type_name"]: {"id": entry["object_id"], "name": entry["object_name"]} for entry in query.all()} + + @classmethod + def _get_host_related_selector(cls, host_id: HostID, action_owner: PrototypeDescriptor) -> QuerySet: + cluster_id = Host.objects.values_list("cluster_id", flat=True).get(id=host_id) + if not cluster_id: + message = "Can't detect selector for host without cluster for other targets than host itself" + raise RuntimeError(message) + + query = Cluster.objects.values("id", object_name=F("name"), type_name=Value(ADCMCoreType.CLUSTER.value)).filter( + id=cluster_id + ) + + if action_owner.type == ADCMCoreType.SERVICE: + query = query.union( + ClusterObject.objects.values(**cls._selector_fields_map[ClusterObject]).filter( + prototype_id=action_owner.id, cluster_id=cluster_id + ) + ) + elif action_owner.type == ADCMCoreType.COMPONENT: + service_id, component_id = ServiceComponent.objects.values_list("service_id", "id").get( + cluster_id=cluster_id, prototype_id=action_owner.id + ) + query = query.union( + ClusterObject.objects.values(**cls._selector_fields_map[ClusterObject]).filter(id=service_id) + ) + query = query.union( + ServiceComponent.objects.values(**cls._selector_fields_map[ServiceComponent]).filter(id=service_id) + ) + + return query + + +class ActionRepoImpl: + @staticmethod + def get_action(id: ActionID) -> ActionInfo: # noqa: A002 + action = Action.objects.values("id", "name", "prototype_id", "prototype__type").get(id=id) + return ActionInfo( + id=action["id"], + name=action["name"], + owner_prototype=PrototypeDescriptor( + id=action["prototype_id"], type=db_record_type_to_core_type(db_record_type=action["prototype__type"]) + ), + ) + + @staticmethod + def get_job_specs(id: ActionID) -> Iterable[JobSpec]: # noqa: A002 + try: + if Action.objects.values_list("type", flat=True).get(id=id) == ActionType.JOB: + return [JobSpec.from_orm(Action.objects.get(id=id))] + except Action.DoesNotExist: + return [] + + return [JobSpec.from_orm(sub_action) for sub_action in SubAction.objects.filter(action_id=id).order_by("id")] diff --git a/python/cm/services/job/run/runners.py b/python/cm/services/job/run/runners.py new file mode 100644 index 0000000000..dbc6678682 --- /dev/null +++ b/python/cm/services/job/run/runners.py @@ -0,0 +1,394 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from operator import itemgetter +from typing import Any, Protocol +import os +import signal +import logging + +from core.job.dto import JobUpdateDTO, TaskUpdateDTO +from core.job.runners import ExecutionTarget, RunnerRuntime, TaskRunner +from core.job.types import ExecutionStatus, Job, Task +from core.types import ADCMCoreType, CoreObjectDescriptor, NamedCoreObject + +NO_PROCESS_PID = 0 + + +class EventNotifier(Protocol): + def send_update_event(self, object_: CoreObjectDescriptor, changes: dict) -> Any: + ... + + def send_task_status_update_event(self, task_id: int, status: str) -> Any: + ... + + def send_prototype_update_event(self, object_: CoreObjectDescriptor) -> Any: + ... + + +class StatusServerInteractor(Protocol): + def reset_objects_in_mm(self) -> Any: + ... + + +def set_job_lock(job_id: int) -> None: + # todo move it to `cm.services.job` somewhere + from cm.models import JobLog + + job = JobLog.objects.select_related("task").get(pk=job_id) + if job.task.lock and job.task.task_object: + job.task.lock.reason = job.cook_reason() + job.task.lock.save(update_fields=["reason"]) + + +def set_hostcomponent(task: Task, logger: logging.Logger): + # todo move it to `cm.services.job` somewhere + from cm.api import save_hc # fixme no way it can be in `cm.api` + from cm.models import ClusterObject, Host, ServiceComponent, TaskLog, get_object_cluster + + # todo no need in task here, just take owner from task + task_ = TaskLog.objects.prefetch_related("task_object").get(id=task.id) + + cluster = get_object_cluster(task_.task_object) + if cluster is None: + logger.error("no cluster in task #%s", task_.pk) + + return + + new_hostcomponent = task.hostcomponent.to_set + hosts = { + entry.pk: entry for entry in Host.objects.filter(id__in=set(map(itemgetter("host_id"), new_hostcomponent))) + } + services = { + entry.pk: entry + for entry in ClusterObject.objects.filter(id__in=set(map(itemgetter("service_id"), new_hostcomponent))) + } + components = { + entry.pk: entry + for entry in ServiceComponent.objects.filter(id__in=set(map(itemgetter("component_id"), new_hostcomponent))) + } + + host_comp_list = [ + (services[entry["service_id"]], hosts[entry["host_id"]], components[entry["component_id"]]) + for entry in new_hostcomponent + ] + + logger.warning("task #%s is failed, restore old hc", task_.pk) + + save_hc(cluster, host_comp_list) + + +def remove_task_lock(task_id: int) -> None: + from cm.issue import unlock_affected_objects + from cm.models import TaskLog + + unlock_affected_objects(TaskLog.objects.get(pk=task_id)) + + +def update_issues(object_: CoreObjectDescriptor): + # todo move it to `cm.services.job` somewhere + from cm.converters import core_type_to_model + from cm.issue import update_hierarchy_issues + + update_hierarchy_issues(obj=core_type_to_model(core_type=object_.type).objects.get(id=object_.id)) + + +def update_object_maintenance_mode(action_name: str, object_: CoreObjectDescriptor): + # todo move it to `cm.services.job` somewhere + from django.conf import settings + + from cm.converters import core_type_to_model + from cm.models import MaintenanceMode + + obj = core_type_to_model(core_type=object_.type).objects.get(id=object_.id) + + if ( + action_name in {settings.ADCM_TURN_ON_MM_ACTION_NAME, settings.ADCM_HOST_TURN_ON_MM_ACTION_NAME} + and obj.maintenance_mode == MaintenanceMode.CHANGING + ): + obj.maintenance_mode = MaintenanceMode.OFF + obj.save() + + if ( + action_name in {settings.ADCM_TURN_OFF_MM_ACTION_NAME, settings.ADCM_HOST_TURN_OFF_MM_ACTION_NAME} + and obj.maintenance_mode == MaintenanceMode.CHANGING + ): + obj.maintenance_mode = MaintenanceMode.ON + obj.save() + + +def audit_job_finish( + owner: NamedCoreObject, display_name: str, is_upgrade: bool, job_result: ExecutionStatus +) -> None: # todo probably shouldn't be here at all + from audit.cases.common import get_or_create_audit_obj + from audit.cef_logger import cef_logger + from audit.models import AuditLog, AuditLogOperationResult, AuditLogOperationType, AuditObjectType + + operation_name = f"{display_name} {'upgrade' if is_upgrade else 'action'} completed" + + if owner.type == ADCMCoreType.HOSTPROVIDER: + obj_type = AuditObjectType.PROVIDER + else: + obj_type = AuditObjectType(owner.type.value) + + audit_object = get_or_create_audit_obj( + object_id=str(owner.id), + object_name=owner.name, + object_type=obj_type, + ) + operation_result = ( + AuditLogOperationResult.SUCCESS if job_result == ExecutionStatus.SUCCESS else AuditLogOperationResult.FAIL + ) + + audit_log = AuditLog.objects.create( + audit_object=audit_object, + operation_name=operation_name, + operation_type=AuditLogOperationType.UPDATE, + operation_result=operation_result, + object_changes={}, + ) + + cef_logger(audit_instance=audit_log, signature_id="Action completion") + + +class JobSequenceRunner(TaskRunner): + _notifier: EventNotifier + _status_server = StatusServerInteractor + + def __init__( + self, *, notifier: EventNotifier, status_server: StatusServerInteractor, logger: logging.Logger, **kwargs: Any + ): + super().__init__(**kwargs) + + self._notifier = notifier + self._status_server = status_server + self._logger = logger + + def terminate(self) -> None: + self._runtime.termination.is_requested = True + for job_to_terminate in filter( + lambda job_: job_.status == ExecutionStatus.RUNNING and job_.pid != NO_PROCESS_PID, + self._repo.get_task_jobs(task_id=self._runtime.task_id), + ): + self._logger.info(f"Terminating job #{job_to_terminate.id} with pid {job_to_terminate.pid}") + try: + os.kill(job_to_terminate.pid, signal.SIGTERM) + except OSError: + self._logger.exception(f"Failed to abort job #{job_to_terminate.id} at pid {job_to_terminate.pid}") + + def consider_broken(self) -> None: + # special value is used to avoid handling NPEs + if self._runtime.task_id < 0: + return + + self._runtime.status = ExecutionStatus.BROKEN + try: + self._finish(task=self._repo.get_task(id=self._runtime.task_id), last_job=None) + except: # noqa: E722 + # force set task finish date if something goes wrong + self._repo.update_task( + id=self._runtime.task_id, + data=TaskUpdateDTO(status=self._runtime.status, finish_date=self._environment.now()), + ) + + def run(self, task_id: int): + task, configured_jobs = self._configure(task_id=task_id) + self._start(task_id=task_id) + + last_processed_job = None + last_job_result = None + for current_job in configured_jobs: + self._prepare_job_environment(target=current_job) + + last_processed_job = current_job.job + last_job_result = self._execute_job(target=current_job) + + if self._runtime.status != ExecutionStatus.ABORTED and last_job_result not in ( + ExecutionStatus.SUCCESS, + ExecutionStatus.ABORTED, + ): + self._runtime.status = ExecutionStatus.FAILED + + if not self._should_proceed(last_job_result=last_job_result): + break + + if self._runtime.termination.is_requested or ( + last_job_result == ExecutionStatus.ABORTED and last_processed_job.id == configured_jobs[-1].job.id + ): + self._runtime.status = ExecutionStatus.ABORTED + elif self._runtime.status == ExecutionStatus.RUNNING: + if last_job_result in (ExecutionStatus.ABORTED, None): + self._runtime.status = ExecutionStatus.SUCCESS + else: + self._runtime.status = last_job_result + + self._finish( + task=task, + last_job=last_processed_job, + ) + + def _configure(self, task_id: int) -> tuple[Task, tuple[ExecutionTarget, ...]]: + self._runtime: RunnerRuntime = RunnerRuntime(task_id=task_id) + + task = self._repo.get_task(id=task_id) + + if not (task.target and task.bundle_root): + message = "Can't run task with no owner and/or bundle info" + raise RuntimeError(message) + + configured_jobs = tuple( + self._job_processor.convert( + task=task, + jobs=filter(self._job_processor.filter_predicate, self._repo.get_task_jobs(task_id=task_id)), + configuration=self._settings, + ) + ) + if not configured_jobs: + raise RuntimeError() + + return task, configured_jobs + + def _start(self, task_id: int) -> None: + self._repo.update_task( + id=task_id, + data=TaskUpdateDTO( + pid=self._environment.pid, start_date=self._environment.now(), status=ExecutionStatus.RUNNING + ), + ) + self._runtime.status = ExecutionStatus.RUNNING + self._notifier.send_task_status_update_event(task_id=self._runtime.task_id, status=self._runtime.status.value) + + def _prepare_job_environment(self, target: ExecutionTarget) -> None: + (self._settings.adcm.run_dir / str(target.job.id) / "tmp").mkdir(parents=True, exist_ok=True) + + for prepare_environment in target.environment_builders: + prepare_environment(job=target.job) + + def _execute_job(self, target: ExecutionTarget) -> ExecutionStatus: + target.executor.execute() + + self._repo.update_job( + id=target.job.id, + data=JobUpdateDTO( + pid=getattr(target.executor.process, "pid", NO_PROCESS_PID), + status=ExecutionStatus.RUNNING, + start_date=self._environment.now(), + ), + ) + + # todo add object update based on job state/multi_state update rules + + set_job_lock(job_id=target.job.id) + + result = target.executor.wait_finished().result + + if result.code == -15: + job_status = ExecutionStatus.ABORTED + elif result.code == 0: + job_status = ExecutionStatus.SUCCESS + else: + job_status = ExecutionStatus.FAILED + + self._repo.update_job( + id=target.job.id, data=JobUpdateDTO(status=job_status, finish_date=self._environment.now()) + ) + + for finalizer in target.finalizers: + # todo should we catch any exception here and just log it? + finalizer(job=target.job) + + return job_status + + def _should_proceed(self, last_job_result: ExecutionStatus) -> bool: + if self._runtime.termination.is_requested: + return False + + if last_job_result == ExecutionStatus.SUCCESS: + return True + + # ABORTED means "skipped" here, so if it's skipped, we just continue + return last_job_result == ExecutionStatus.ABORTED + + def _finish(self, task: Task, last_job: Job | None): + task_result = self._runtime.status + + remove_task_lock(task_id=task.id) + + audit_job_finish( + owner=task.target, + display_name=task.display_name, + is_upgrade=task.is_upgrade, + job_result=task_result, + ) + + finished_task = self._repo.get_task(id=task.id) + if finished_task.owner: + self._update_owner_object(owner=finished_task.owner, finished_task=finished_task, last_job=last_job) + + if finished_task.target: + update_object_maintenance_mode(action_name=finished_task.name, object_=finished_task.target) + + self._repo.update_task(id=task.id, data=TaskUpdateDTO(finish_date=self._environment.now(), status=task_result)) + self._notifier.send_task_status_update_event(task_id=self._runtime.task_id, status=task_result) + + try: + self._status_server.reset_objects_in_mm() + except: # noqa: E722 + self._logger.exception("Error loading mm objects on task finish") + + def _update_owner_object(self, owner: CoreObjectDescriptor, finished_task: Task, last_job: Job | None): + """Task should be re-read before calling this method, because some flags need to be updated""" + if last_job: + self._update_owner_state(task=finished_task, job=last_job, owner=owner) + + if ( + self._runtime.status in {ExecutionStatus.FAILED, ExecutionStatus.ABORTED, ExecutionStatus.BROKEN} + and finished_task.hostcomponent.to_set is not None + and finished_task.hostcomponent.restore_on_fail + ): + set_hostcomponent(task=finished_task, logger=self._logger) + + update_issues(object_=owner) + + def _update_owner_state(self, task: Task, job: Job, owner: CoreObjectDescriptor) -> None: + if self._runtime.status == ExecutionStatus.SUCCESS: + multi_state_set = task.on_success.multi_state_set + multi_state_unset = task.on_success.multi_state_unset + state = task.on_success.state + if not state: + self._logger.warning('task for "%s" success state is not set', task.display_name) + + elif self._runtime.status == ExecutionStatus.FAILED: + job_on_fail = job.on_fail + task_on_fail = task.on_fail + state = job_on_fail.state or task_on_fail.state + multi_state_set = job_on_fail.multi_state_set or task_on_fail.multi_state_set + multi_state_unset = job_on_fail.multi_state_unset or task_on_fail.multi_state_unset + if not state: + self._logger.warning('task for "%s" fail state is not set', task.display_name) + + else: + if self._runtime.status != ExecutionStatus.ABORTED: + self._logger.error("unknown task status: %s", self._runtime.status) + + return + + if state: + self._repo.update_owner_state(owner=owner, state=state) + + self._repo.update_owner_multi_states( + owner=owner, add_multi_states=multi_state_set, remove_multi_states=multi_state_unset + ) + + if task.is_upgrade: + self._notifier.send_prototype_update_event(object_=owner) + else: + self._notifier.send_update_event(object_=owner, changes={"state": state}) diff --git a/python/cm/services/job/utils.py b/python/cm/services/job/utils.py index b11470e5b9..3815fcfd26 100644 --- a/python/cm/services/job/utils.py +++ b/python/cm/services/job/utils.py @@ -20,14 +20,9 @@ from cm.models import ( Action, - ClusterObject, JobLog, - ObjectType, - ServiceComponent, - SubAction, TaskLog, ) -from cm.services.job.types import Selector from cm.services.types import ADCMEntityType @@ -58,52 +53,12 @@ def config(self) -> Json: def action(self) -> Action | None: return self.task.action - @cached_property - def sub_action(self) -> SubAction | None: - return self.job.sub_action - - -def get_selector(obj: ADCMEntityType, action: Action) -> Selector: - selector: Selector = {obj.prototype.type: {"id": obj.pk, "name": obj.display_name}} - - match obj.prototype.type: - case ObjectType.SERVICE: - selector[ObjectType.CLUSTER.value] = {"id": obj.cluster.pk, "name": obj.cluster.display_name} - - case ObjectType.COMPONENT: - selector[ObjectType.SERVICE.value] = {"id": obj.service.pk, "name": obj.service.display_name} - selector[ObjectType.CLUSTER.value] = {"id": obj.cluster.pk, "name": obj.cluster.display_name} - - case ObjectType.HOST: - if action.host_action: - if obj.cluster_id is None: - raise ValueError(f'Host "{obj.fqdn}" is not bound to any cluster') - - cluster = obj.cluster - selector[ObjectType.CLUSTER.value] = {"id": cluster.pk, "name": cluster.display_name} - - if action.prototype.type == ObjectType.SERVICE: - service = ClusterObject.objects.get(prototype=action.prototype, cluster=cluster) - selector[ObjectType.SERVICE.value] = {"id": service.pk, "name": service.display_name} - - elif action.prototype.type == ObjectType.COMPONENT: - service = ClusterObject.objects.get(prototype=action.prototype.parent, cluster=cluster) - selector[ObjectType.SERVICE.value] = {"id": service.pk, "name": service.display_name} - component = ServiceComponent.objects.get( - prototype=action.prototype, cluster=cluster, service=service - ) - selector[ObjectType.COMPONENT.value] = {"id": component.pk, "name": component.display_name} - - else: - selector[ObjectType.PROVIDER.value] = {"id": obj.provider.pk, "name": obj.provider.display_name} - - return selector - -def get_script_path(action: Action, sub_action: SubAction | None) -> str: - script = action.script - if sub_action: - script = sub_action.script +def get_script_path(action: Action, job: JobLog | None) -> str: + # fixme remove `if` here. + # currently left for "backward compatibility", but actually script should always be set for job + # and job should always be passed in here + script = job.script if job else action.script relative_path_part = "./" if script.startswith(relative_path_part): diff --git a/python/cm/stack.py b/python/cm/stack.py index 56440d0b48..a1194ee553 100644 --- a/python/cm/stack.py +++ b/python/cm/stack.py @@ -595,6 +595,7 @@ def save_sub_actions(conf, action): script=sub["script"], script_type=sub["script_type"], name=sub["name"], + allow_to_terminate=sub.get("allow_to_terminate", action.allow_to_terminate), ) sub_action.display_name = sub["name"] @@ -602,7 +603,6 @@ def save_sub_actions(conf, action): sub_action.display_name = sub["display_name"] dict_to_obj(sub, "params", sub_action) - dict_to_obj(sub, "allow_to_terminate", sub_action) on_fail = sub.get(ON_FAIL, "") if isinstance(on_fail, str): sub_action.state_on_fail = on_fail diff --git a/python/cm/status_api.py b/python/cm/status_api.py index 723940170a..5e54ebb493 100644 --- a/python/cm/status_api.py +++ b/python/cm/status_api.py @@ -15,11 +15,13 @@ from urllib.parse import urljoin import json +from core.types import CoreObjectDescriptor from django.conf import settings from requests import Response from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED import requests +from cm.converters import core_type_to_model from cm.logger import logger from cm.models import ( ADCMEntity, @@ -28,7 +30,6 @@ Host, HostComponent, ServiceComponent, - TaskLog, ) @@ -119,12 +120,23 @@ def send_config_creation_event(object_: ADCMEntity) -> None: ) +def send_update_event(object_: CoreObjectDescriptor, changes: dict) -> None: + post_event(event=EventTypes.UPDATE.format(object_.type.value), object_id=object_.id, changes=changes) + + def send_object_update_event(object_: ADCMEntity, changes: dict) -> None: post_event(event=EventTypes.UPDATE.format(object_.prototype.type), object_id=object_.pk, changes=changes) -def send_task_status_update_event(object_: TaskLog, status: str) -> None: - post_event(event=EventTypes.UPDATE.format("task"), object_id=object_.pk, changes={"status": status}) +def send_task_status_update_event(task_id: int, status: str) -> None: + post_event(event=EventTypes.UPDATE.format("task"), object_id=task_id, changes={"status": status}) + + +def send_prototype_update_event(object_: CoreObjectDescriptor) -> None: + # todo inplace request, no need in the whole object + send_prototype_and_state_update_event( + core_type_to_model(core_type=object_.type).objects.select_related("prototype").get(id=object_.id) + ) def send_prototype_and_state_update_event(object_: ADCMEntity) -> None: diff --git a/python/cm/tests/test_inventory/test_action_config.py b/python/cm/tests/test_inventory/test_action_config.py index 202a0c9cb0..cb4952eb20 100644 --- a/python/cm/tests/test_inventory/test_action_config.py +++ b/python/cm/tests/test_inventory/test_action_config.py @@ -12,14 +12,17 @@ from copy import deepcopy from unittest.mock import patch +from core.job.dto import TaskPayloadDTO +from core.types import ADCMCoreType, CoreObjectDescriptor from django.conf import settings -from django.utils import timezone from cm.adcm_config.ansible import ansible_decrypt +from cm.converters import model_name_to_core_type from cm.job import ActionRunPayload, run_action from cm.models import Action, JobLog, ServiceComponent, SubAction, TaskLog from cm.services.job.config import get_job_config -from cm.services.job.utils import JobScope, get_selector +from cm.services.job.prepare import prepare_task_for_action +from cm.services.job.utils import JobScope from cm.tests.test_inventory.base import BaseInventoryTestCase @@ -106,19 +109,20 @@ def test_action_config(self) -> None: (self.host_1, self.CONFIG_WITH_NONES, "host"), ): action = Action.objects.filter(prototype=object_.prototype, name="with_config").first() - selector = get_selector(obj=object_, action=action) - task = TaskLog.objects.create( - task_object=object_, - action=action, - config=config, - start_date=timezone.now(), - finish_date=timezone.now(), - selector=selector, + obj_ = CoreObjectDescriptor( + id=object_.pk, type=model_name_to_core_type(model_name=object_.__class__.__name__.lower()) ) - job = JobLog.objects.create( - task=task, action=action, start_date=timezone.now(), finish_date=timezone.now(), selector=selector + task = TaskLog.objects.get( + id=prepare_task_for_action( + target=obj_, + owner=obj_, + action=action.pk, + payload=TaskPayloadDTO(conf=config), + ).id ) + job = JobLog.objects.filter(task=task).first() + with self.subTest(f"Own Action for {object_.__class__.__name__}"): expected_data = self.render_json_template( file=self.templates_dir / "action_configs" / f"{type_name}.json.j2", @@ -134,19 +138,18 @@ def test_action_config(self) -> None: (self.component, None, "component"), ): action = Action.objects.filter(prototype=object_.prototype, name="with_config_on_host").first() - selector = get_selector(obj=self.host_1, action=action) - task = TaskLog.objects.create( - task_object=self.host_1, - action=action, - config=config, - start_date=timezone.now(), - finish_date=timezone.now(), - verbose=True, - selector=selector, - ) - job = JobLog.objects.create( - task=task, action=action, start_date=timezone.now(), finish_date=timezone.now(), selector=selector + target = CoreObjectDescriptor(id=self.host_1.pk, type=ADCMCoreType.HOST) + task = TaskLog.objects.get( + id=prepare_task_for_action( + target=target, + owner=CoreObjectDescriptor( + id=object_.pk, type=model_name_to_core_type(object_.__class__.__name__.lower()) + ), + action=action.pk, + payload=TaskPayloadDTO(verbose=True, conf=config), + ).id ) + job = JobLog.objects.filter(task=task).first() with self.subTest(f"Host Action for {object_.__class__.__name__}"): expected_data = self.render_json_template( diff --git a/python/cm/tests/test_inventory/test_inventory.py b/python/cm/tests/test_inventory/test_inventory.py index a098e11370..14b6589223 100644 --- a/python/cm/tests/test_inventory/test_inventory.py +++ b/python/cm/tests/test_inventory/test_inventory.py @@ -284,6 +284,7 @@ def get_inventory_data(self, data: dict, kwargs: dict) -> dict: job = JobLog.objects.last() + (settings.RUN_DIR / str(job.pk)).mkdir(exist_ok=True, parents=True) re_prepare_job(job_scope=JobScope(job_id=job.pk, object=job.task.task_object)) inventory_file = settings.RUN_DIR / str(job.pk) / "inventory.json" diff --git a/python/cm/tests/test_job.py b/python/cm/tests/test_job.py index 6e9274a68c..1bb166d494 100644 --- a/python/cm/tests/test_job.py +++ b/python/cm/tests/test_job.py @@ -16,7 +16,7 @@ from urllib.parse import urljoin from adcm.tests.base import APPLICATION_JSON, BaseTestCase -from core.types import ADCMCoreType +from core.types import ADCMCoreType, CoreObjectDescriptor, PrototypeDescriptor from django.conf import settings from django.urls import reverse from django.utils import timezone @@ -55,7 +55,8 @@ get_context, get_job_config, ) -from cm.services.job.utils import JobScope, get_bundle_root, get_script_path, get_selector +from cm.services.job.run.repo import JobRepoImpl +from cm.services.job.utils import JobScope, get_bundle_root, get_script_path from cm.tests.utils import ( gen_action, gen_bundle, @@ -142,7 +143,7 @@ def test_set_job_status(self): start_date=timezone.now(), finish_date=timezone.now(), ) - job = JobLog.objects.create(task=task, action=action, start_date=timezone.now(), finish_date=timezone.now()) + job = JobLog.objects.create(task=task, start_date=timezone.now(), finish_date=timezone.now()) lock_affected_objects(task=task, objects=[cluster]) status = JobStatus.RUNNING pid = 10 @@ -197,7 +198,8 @@ def test_get_state_multi_job(self): action.save() task = gen_task_log(cluster, action) job = gen_job_log(task) - job.sub_action = SubAction.objects.create(action=action, state_on_fail="sub_action fail") + job.state_on_fail = "sub_action fail" + job.save() # status: expected state, expected multi_state set, expected multi_state unset test_data = [ @@ -319,7 +321,7 @@ def test_prepare_job(self, mock_get_inventory_data, mock_get_job_config, mock_pr start_date=timezone.now(), finish_date=timezone.now(), ) - job = JobLog.objects.create(action=action, start_date=timezone.now(), finish_date=timezone.now(), task=task) + job = JobLog.objects.create(name=action.name, start_date=timezone.now(), finish_date=timezone.now(), task=task) job_scope = JobScope(job_id=job.pk, object=cluster) mocked_open = mock_open() @@ -328,7 +330,7 @@ def test_prepare_job(self, mock_get_inventory_data, mock_get_job_config, mock_pr mock_get_inventory_data.assert_called_once_with(obj=cluster, action=action, delta={}) mock_get_job_config.assert_called_once_with(job_scope=job_scope) - mock_prepare_ansible_config.assert_called_once_with(job_id=job.id, action=action, sub_action=None) + mock_prepare_ansible_config.assert_called_once_with(job_id=job.id, action=action) def test_prepare_context(self): bundle = Bundle.objects.create() @@ -336,18 +338,22 @@ def test_prepare_context(self): action1 = Action.objects.create(prototype=proto1) add_cluster(proto1, "Garbage") cluster = add_cluster(proto1, "Ontario") - context = get_context( - action=action1, object_type=cluster.prototype.type, selector=get_selector(obj=cluster, action=action1) + selector = JobRepoImpl._get_selector_for_core_object( + target=CoreObjectDescriptor(id=cluster.pk, type=ADCMCoreType.CLUSTER), + owner=PrototypeDescriptor(id=action1.prototype_id, type=ADCMCoreType(action1.prototype_type)), ) + context = get_context(action=action1, object_type=cluster.prototype.type, selector=selector) self.assertDictEqual(context, {"type": "cluster", "cluster_id": cluster.id}) proto2 = Prototype.objects.create(bundle=bundle, type="service") action2 = Action.objects.create(prototype=proto2) service = add_service_to_cluster(cluster, proto2) - context = get_context( - action=action2, object_type=service.prototype.type, selector=get_selector(obj=service, action=action2) + selector = JobRepoImpl._get_selector_for_core_object( + target=CoreObjectDescriptor(id=service.pk, type=ADCMCoreType.SERVICE), + owner=PrototypeDescriptor(id=action2.prototype_id, type=ADCMCoreType(action2.prototype_type)), ) + context = get_context(action=action2, object_type=service.prototype.type, selector=selector) self.assertDictEqual(context, {"type": "service", "service_id": service.id, "cluster_id": cluster.id}) @@ -453,21 +459,16 @@ def test_prepare_job_config( ] for prototype_type, obj, action in data: - selector = get_selector(obj=obj, action=action) task = TaskLog.objects.create( task_object=obj, action=action, start_date=timezone.now(), finish_date=timezone.now(), config="test", - selector=selector, + selector={}, ) job = JobLog.objects.create( - action=action, - start_date=timezone.now(), - finish_date=timezone.now(), - task=task, - selector=selector, + name=action.name, start_date=timezone.now(), finish_date=timezone.now(), task=task ) with self.subTest(prototype_type=prototype_type, obj=obj): @@ -525,10 +526,10 @@ def test_prepare_job_config( self.assertDictEqual(job_config, actual_job_config) mock_get_adcm_configuration.assert_called() mock_get_context.assert_called_with( - action=action, object_type=obj.prototype.type, selector=job.selector + action=action, object_type=obj.prototype.type, selector=job.task.selector ) mock_get_bundle_root.assert_called_with(action=action) - mock_get_script_path.assert_called_with(action=action, sub_action=None) + mock_get_script_path.assert_called_with(action=action, job=job) @patch("cm.job.cook_delta") @patch("cm.job.get_old_hc") @@ -557,7 +558,7 @@ def test_re_prepare_job(self, mock_prepare_job, mock_get_actual_hc, mock_get_old prototype=prototype, hostcomponentmap=[{"service": "", "component": "", "action": ""}], ) - sub_action = SubAction.objects.create(action=action) + SubAction.objects.create(action=action) hostcomponentmap = [ { "host_id": host.id, @@ -575,8 +576,6 @@ def test_re_prepare_job(self, mock_prepare_job, mock_get_actual_hc, mock_get_old ) job = JobLog.objects.create( task=task, - action=action, - sub_action=sub_action, start_date=timezone.now(), finish_date=timezone.now(), ) diff --git a/python/cm/tests/test_message_template.py b/python/cm/tests/test_message_template.py index 619537a258..2831428354 100644 --- a/python/cm/tests/test_message_template.py +++ b/python/cm/tests/test_message_template.py @@ -15,7 +15,7 @@ from adcm.tests.base import BaseTestCase from cm.errors import AdcmEx -from cm.models import KnownNames, MessageTemplate +from cm.models import MessageTemplate from cm.tests.utils import gen_adcm @@ -79,10 +79,3 @@ def test_bad_template__bad_placeholder(self): self.assertIn("AttributeError", e.exception.msg) self.assertIn("list", e.exception.msg) - - def test_bad_template__bad_args(self): - name = KnownNames.LOCKED_BY_JOB.value - with self.assertRaises(AdcmEx) as e: - MessageTemplate.get_message_from_template(name) - - self.assertIn("AttributeError", e.exception.msg) diff --git a/python/cm/tests/test_task_log.py b/python/cm/tests/test_task_log.py index d6e9944f9e..02eeb73a36 100644 --- a/python/cm/tests/test_task_log.py +++ b/python/cm/tests/test_task_log.py @@ -16,6 +16,8 @@ get_task_download_archive_file_handler, get_task_download_archive_name, ) +from core.job.dto import TaskPayloadDTO +from core.types import ADCMCoreType, CoreObjectDescriptor from django.conf import settings from django.test import override_settings from django.urls import reverse @@ -35,6 +37,7 @@ SubAction, TaskLog, ) +from cm.services.job.prepare import prepare_task_for_action from cm.tests.utils import ( gen_adcm, gen_cluster, @@ -82,6 +85,7 @@ def test_unlock_affected(self): self.assertFalse(cluster.locked) self.assertIsNone(task.lock) + # todo looks like useless test @override_settings(RUN_DIR=settings.BASE_DIR / "python" / "cm" / "tests" / "files" / "task_log_download") def test_download(self): bundle = Bundle.objects.create() @@ -105,6 +109,7 @@ def test_download(self): start_date=timezone.now(), finish_date=timezone.now(), ) + cluster_2 = Cluster.objects.create( prototype=Prototype.objects.create( bundle=bundle, @@ -138,56 +143,64 @@ def test_download(self): name="test_cluster_5", ) JobLog.objects.create( - task=task, - start_date=timezone.now(), - finish_date=timezone.now(), - sub_action=SubAction.objects.create( + task=TaskLog.objects.create( + task_object=cluster, action=Action.objects.create( display_name="test_subaction_job_1", prototype=cluster_2.prototype, type="job", state_available="any", ), + start_date=timezone.now(), + finish_date=timezone.now(), ), - ) - JobLog.objects.create( - task=task, start_date=timezone.now(), finish_date=timezone.now(), - sub_action=SubAction.objects.create( + ) + JobLog.objects.create( + task=TaskLog.objects.create( + task_object=cluster, action=Action.objects.create( display_name="test_subaction_job_2", prototype=cluster_3.prototype, type="job", state_available="any", ), + start_date=timezone.now(), + finish_date=timezone.now(), ), - ) - JobLog.objects.create( - task=task, start_date=timezone.now(), finish_date=timezone.now(), - sub_action=SubAction.objects.create( + ) + JobLog.objects.create( + task=TaskLog.objects.create( + task_object=cluster, action=Action.objects.create( display_name="test_subaction_job_3", prototype=cluster_4.prototype, type="job", state_available="any", ), + start_date=timezone.now(), + finish_date=timezone.now(), ), - ) - job_no_files = JobLog.objects.create( - task=task, start_date=timezone.now(), finish_date=timezone.now(), - sub_action=SubAction.objects.create( + ) + job_no_files = JobLog.objects.create( + task=TaskLog.objects.create( + task_object=cluster, action=Action.objects.create( display_name="test_subaction_job_4", prototype=cluster_5.prototype, type="job", state_available="any", ), + start_date=timezone.now(), + finish_date=timezone.now(), ), + start_date=timezone.now(), + finish_date=timezone.now(), ) LogStorage.objects.create(job=job_no_files, body="stdout db", type="stdout", format="txt") LogStorage.objects.create(job=job_no_files, body="stderr db", type="stderr", format="txt") @@ -213,30 +226,25 @@ def test_download_negative(self): display_name="Test cluster action", prototype=cluster.prototype, type="task", + script_type="ansible", state_available="any", name="test_cluster_action", ) - task = TaskLog.objects.create( - task_object=cluster, + SubAction.objects.create( + name="test_subaction_1", action=action, - start_date=timezone.now(), - finish_date=timezone.now(), - ) - JobLog.objects.create( - task=task, - start_date=timezone.now(), - finish_date=timezone.now(), - sub_action=SubAction.objects.create( - name="test_subaction_1", - action=action, - display_name="Test Dis%#play NAME!", - ), + script_type="ansible", + display_name="Test Dis%#play NAME!", ) - JobLog.objects.create( - task=task, - start_date=timezone.now(), - finish_date=timezone.now(), - sub_action=SubAction.objects.create(name="test_subaction_2", action=action), + SubAction.objects.create(name="test_subaction_2", action=action, script_type="ansible") + object_ = CoreObjectDescriptor(id=cluster.pk, type=ADCMCoreType.CLUSTER) + task = TaskLog.objects.get( + id=prepare_task_for_action( + target=object_, + owner=object_, + action=action.pk, + payload=TaskPayloadDTO(), + ).id ) file_handler = get_task_download_archive_file_handler(task) file_handler.seek(0) diff --git a/python/cm/tests/utils.py b/python/cm/tests/utils.py index 268f1e7fa3..f84100bd87 100644 --- a/python/cm/tests/utils.py +++ b/python/cm/tests/utils.py @@ -200,7 +200,6 @@ def gen_task_log(obj: ADCMEntity, action: Action = None) -> TaskLog: def gen_job_log(task: TaskLog) -> JobLog: return JobLog.objects.create( task=task, - action=task.action, status="CREATED", start_date=timezone.now(), finish_date=timezone.now(), diff --git a/python/core/job/__init__.py b/python/core/job/__init__.py new file mode 100644 index 0000000000..f3e59d81d8 --- /dev/null +++ b/python/core/job/__init__.py @@ -0,0 +1,12 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/python/core/job/dto.py b/python/core/job/dto.py new file mode 100644 index 0000000000..0a6f9678da --- /dev/null +++ b/python/core/job/dto.py @@ -0,0 +1,47 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from datetime import datetime + +from pydantic import BaseModel + +from core.job.types import ExecutionStatus + + +class TaskUpdateDTO(BaseModel): + pid: int | None = None + start_date: datetime | None = None + finish_date: datetime | None = None + status: ExecutionStatus | None = None + + +class JobUpdateDTO(BaseModel): + pid: int | None = None + start_date: datetime | None = None + finish_date: datetime | None = None + status: ExecutionStatus | None = None + + +class LogCreateDTO(BaseModel): + job_id: int + name: str + type: str + format: str + + +class TaskPayloadDTO(BaseModel): + verbose: bool = False + + conf: dict | None = None + attr: dict | None = None + + hostcomponent: list[dict] | None = None + post_upgrade_hostcomponent: list[dict] | None = None diff --git a/python/core/job/executors.py b/python/core/job/executors.py new file mode 100644 index 0000000000..f1485e7dd2 --- /dev/null +++ b/python/core/job/executors.py @@ -0,0 +1,125 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, NamedTuple, TextIO +import os +import subprocess + +from pydantic import BaseModel +from typing_extensions import Self + + +class ExecutionResult(NamedTuple): + code: int + + +class WithErrOutLogsMixin: + _out_log: TextIO | None = None + _err_log: TextIO | None = None + + def _open_logs(self, log_dir: Path, log_prefix: str) -> None: + self._out_log = (log_dir / f"{log_prefix}-stdout.txt").open(mode="a+", encoding="utf-8") + self._err_log = (log_dir / f"{log_prefix}-stderr.txt").open(mode="a+", encoding="utf-8") + + def _close_logs(self) -> None: + if self._out_log: + self._out_log.close() + + if self._err_log: + self._err_log.close() + + +class ExecutorConfig(BaseModel): + work_dir: Path + + +class BundleExecutorConfig(ExecutorConfig): + script_file: Path + bundle_root: Path + + +class Executor(ABC): + _config: ExecutorConfig + _result: ExecutionResult | None + _process: Any | None + + @property + @abstractmethod + def script_type(self) -> str: + raise NotImplementedError() + + @property + def result(self) -> ExecutionResult | None: + return self._result + + @property + def process(self): + return self._process + + def __init__(self, config: ExecutorConfig): + self._config = config + self._result = None + self._process = None + + @abstractmethod + def execute(self) -> Self: + raise NotImplementedError() + + @abstractmethod + def wait_finished(self) -> Self: + raise NotImplementedError() + + +class ProcessExecutor(Executor, WithErrOutLogsMixin, ABC): + _config: BundleExecutorConfig + _process: subprocess.Popen | None + + def __init__(self, config: BundleExecutorConfig) -> None: + super().__init__(config=config) + + self._process = None + + def execute(self) -> Self: + command = self._prepare_command() + environment = self._get_environment_variables() + + self._open_logs(log_dir=self._config.work_dir, log_prefix=self.script_type) + + os.chdir(self._config.bundle_root) + self._process = subprocess.Popen( + command, # noqa S603 + env=environment, + stdout=self._out_log, + stderr=self._err_log, + ) + + return self + + def wait_finished(self) -> Self: + return_code = self._process.wait() + self._result = ExecutionResult(code=return_code) + + self._close_logs() + + return self + + @abstractmethod + def _prepare_command(self) -> list[str]: + raise NotImplementedError() + + def _get_environment_variables(self) -> dict: + env = os.environ.copy() + env["PYTHONPATH"] = f"./pmod:{self._config.bundle_root}/pmod:{env.get('PYTHONPATH', '')}".rstrip(":") + + return env diff --git a/python/core/job/repo.py b/python/core/job/repo.py new file mode 100644 index 0000000000..1deeae7819 --- /dev/null +++ b/python/core/job/repo.py @@ -0,0 +1,63 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Collection, Iterable, Protocol + +from core.job.dto import JobUpdateDTO, LogCreateDTO, TaskPayloadDTO, TaskUpdateDTO +from core.job.types import ActionInfo, Job, JobSpec, Task +from core.types import ActionID, CoreObjectDescriptor + + +class JobRepoInterface(Protocol): + def get_task(self, id: int) -> Task: # noqa: A002 + """Should raise `NotFoundError` on fail""" + + def create_task( + self, target: CoreObjectDescriptor, owner: CoreObjectDescriptor, action: ActionInfo, payload: TaskPayloadDTO + ) -> Task: + ... + + def update_task(self, id: int, data: TaskUpdateDTO) -> None: # noqa: A002 + ... + + def get_task_jobs(self, task_id: int) -> Iterable[Job]: + ... + + def create_jobs(self, task_id: int, jobs: Iterable[JobSpec]) -> None: + ... + + def get_job(self, id: int) -> Job: # noqa: A002 + """Should raise `NotFoundError` on fail""" + + def update_job(self, id: int, data: JobUpdateDTO) -> None: # noqa: A002 + ... + + def create_logs(self, logs: Iterable[LogCreateDTO]) -> None: + ... + + # todo quite strange to keep it here, + # on the other hand statuses are more about actions/task/jobs that anything else + def update_owner_state(self, owner: CoreObjectDescriptor, state: str) -> None: + ... + + def update_owner_multi_states( + self, owner: CoreObjectDescriptor, add_multi_states: Collection[str], remove_multi_states: Collection[str] + ) -> None: + ... + + +class ActionRepoInterface(Protocol): + def get_action(self, id: ActionID) -> ActionInfo: # noqa: A002 + ... + + def get_job_specs(self, id: ActionID) -> Iterable[JobSpec]: # noqa: A002 + ... diff --git a/python/core/job/runners.py b/python/core/job/runners.py new file mode 100644 index 0000000000..1b22fc4542 --- /dev/null +++ b/python/core/job/runners.py @@ -0,0 +1,120 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Callable, Iterable, NamedTuple, Protocol + +from core.job.executors import Executor +from core.job.repo import JobRepoInterface +from core.job.types import ExecutionStatus, Job, Task + + +class ADCMSettings(NamedTuple): + code_root_dir: Path + run_dir: Path + + +class AnsibleSettings(NamedTuple): + ansible_secret_script: Path + + +class ExternalSettings(NamedTuple): + adcm: ADCMSettings + ansible: AnsibleSettings + + +class JobFinalizer(Protocol): + def __call__(self, job: Job) -> None: + ... + + +class JobEnvironmentBuilder(Protocol): + def __call__(self, job: Job) -> None: + ... + + +class ExecutionTarget(NamedTuple): + job: Job + executor: Executor + environment_builders: Iterable[JobEnvironmentBuilder] + # stuff like `finish_check` should go to finalizers + finalizers: Iterable[JobFinalizer] + + +class ExecutionTargetFactoryProtocol(Protocol): + def __call__(self, task: Task, jobs: Iterable[Job], configuration: ExternalSettings) -> Iterable[ExecutionTarget]: + ... + + +class JobProcessor(NamedTuple): + convert: ExecutionTargetFactoryProtocol + # id will always return True in bool cast + filter_predicate: Callable[[Job], bool] = id + + +class RunnerEnvironment(Protocol): + pid: int + + def now(self) -> datetime: + ... + + +@dataclass(slots=True) +class Termination: + is_requested: bool = False + + +@dataclass(slots=True) +class RunnerRuntime: + task_id: int + status: ExecutionStatus = ExecutionStatus.CREATED + termination: Termination = field(default_factory=Termination) + + +class TaskRunner(ABC): + _job_processor: JobProcessor + _settings: ExternalSettings + + # external dependencies + _repo: JobRepoInterface + _environment: RunnerEnvironment + + _runtime: RunnerRuntime + + def __init__( + self, + *, + job_processor: JobProcessor, + settings: ExternalSettings, + repo: JobRepoInterface, + environment: RunnerEnvironment, + ): + self._job_processor = job_processor + self._settings = settings + self._repo = repo + self._environment = environment + self._runtime = RunnerRuntime(task_id=-1) + + @abstractmethod + def run(self, task_id: int) -> None: + raise NotImplementedError() + + @abstractmethod + def terminate(self) -> None: + raise NotImplementedError() + + @abstractmethod + def consider_broken(self) -> None: + raise NotImplementedError() diff --git a/python/core/job/task.py b/python/core/job/task.py new file mode 100644 index 0000000000..45f1f32ad3 --- /dev/null +++ b/python/core/job/task.py @@ -0,0 +1,67 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from core.job.dto import LogCreateDTO, TaskPayloadDTO +from core.job.repo import ActionRepoInterface, JobRepoInterface +from core.job.types import JobSpec +from core.types import ActionID, CoreObjectDescriptor + + +def compose_task( + target: CoreObjectDescriptor, + owner: CoreObjectDescriptor, + action: ActionID, + payload: TaskPayloadDTO, + job_repo: JobRepoInterface, + action_repo: ActionRepoInterface, +): + """ + Prepare task based on action, target object and task payload. + + Target object is an object on which action is going to be launched, not the on it's described on. + + `Task` is launched action, "task for ADCM to perform action" in other words. + `Job` is an actual piece of work required by task to be performed. + + ! WARNING ! + Currently, stdout/stderr logs are created alongside the jobs + for policies to be re-applied correctly after this method is called. + It must be changed. + """ + + job_specifications = get_specifications_for_jobs(action=action, repo=action_repo) + if not job_specifications: + # todo fix error type + message = f"Can't compose task for action #{action}, because no associated jobs found" + raise RuntimeError(message) + + action_info = action_repo.get_action(id=action) + task = job_repo.create_task(target=target, owner=owner, action=action_info, payload=payload) + + job_repo.create_jobs(task_id=task.id, jobs=job_specifications) + + # todo fix warning from docstring + logs = [] + for job in job_repo.get_task_jobs(task_id=task.id): + logs.append(LogCreateDTO(job_id=job.id, name=job.type.value, type="stdout", format="txt")) + logs.append(LogCreateDTO(job_id=job.id, name=job.type.value, type="stderr", format="txt")) + + if logs: + job_repo.create_logs(logs) + + return task + + +def get_specifications_for_jobs(action: ActionID, repo: ActionRepoInterface) -> tuple[JobSpec, ...]: + # jinja_scripts will be used here + return tuple(repo.get_job_specs(id=action)) diff --git a/python/core/job/types.py b/python/core/job/types.py new file mode 100644 index 0000000000..c89c238b7a --- /dev/null +++ b/python/core/job/types.py @@ -0,0 +1,113 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from enum import Enum +from pathlib import Path +from typing import NamedTuple + +from pydantic import BaseModel + +from core.types import ActionID, CoreObjectDescriptor, NamedCoreObject, PrototypeDescriptor + + +# str is required for pydantic to correctly cast enum to value when calling `.dict` +class ExecutionStatus(str, Enum): + CREATED = "created" + RUNNING = "running" + SUCCESS = "success" + FAILED = "failed" + ABORTED = "aborted" + BROKEN = "broken" + + +# str is required for pydantic to correctly cast enum to value when calling `.dict` +class ScriptType(str, Enum): + ANSIBLE = "ansible" + PYTHON = "python" + INTERNAL = "internal" + + +class ActionInfo(NamedTuple): + id: ActionID + name: str + owner_prototype: PrototypeDescriptor + + +class StateChanges(NamedTuple): + state: str | None + multi_state_set: tuple[str, ...] + multi_state_unset: tuple[str, ...] + + +class HostComponentChanges(NamedTuple): + to_set: list[dict] | None + post_upgrade: list[dict] | None + restore_on_fail: bool + + +class Task(BaseModel): + id: int + + # Owner is an object on which action is defined + owner: CoreObjectDescriptor | None + bundle_root: Path | None + + # Target is an object on which action should be performed + # it's the same as owner for all cases except `host_action: true` + target: NamedCoreObject | None + + name: str + display_name: str + is_upgrade: bool + verbose: bool + venv: str + hostcomponent: HostComponentChanges + on_success: StateChanges + on_fail: StateChanges + + +class JobSpec(BaseModel): + # basic info + name: str + display_name: str + script: str + script_type: ScriptType + allow_to_terminate: bool + + # states + state_on_fail: str + multi_state_on_fail_set: list + multi_state_on_fail_unset: list + + # extra + params: dict + + class Config: # simplify existing objects retrieval + orm_mode = True + + +# it is validated, because we want to fail here on incorrect data +# rather than when we will use it +class JobParams(BaseModel): + ansible_tags: str + + +class Job(BaseModel): + id: int + pid: int + type: ScriptType + status: ExecutionStatus + script: str + + params: JobParams + + on_fail: StateChanges diff --git a/python/core/types.py b/python/core/types.py index 6242f24140..bc84aea642 100644 --- a/python/core/types.py +++ b/python/core/types.py @@ -24,6 +24,8 @@ PrototypeID: TypeAlias = int ActionID: TypeAlias = int +ActionID: TypeAlias = int + ConfigID: TypeAlias = int HostName: TypeAlias = str @@ -41,6 +43,7 @@ def __init__(self, message: str): class ADCMCoreType(Enum): + ADCM = "adcm" CLUSTER = "cluster" SERVICE = "service" COMPONENT = "component" @@ -53,6 +56,15 @@ class ShortObjectInfo(NamedTuple): name: str +class ADCMDescriptor(NamedTuple): + id: int + + +class PrototypeDescriptor(NamedTuple): + id: PrototypeID + type: ADCMCoreType + + class GeneralEntityDescriptor(NamedTuple): id: ObjectID type: str @@ -61,3 +73,9 @@ class GeneralEntityDescriptor(NamedTuple): class CoreObjectDescriptor(NamedTuple): id: ObjectID type: ADCMCoreType + + +class NamedCoreObject(NamedTuple): + id: ObjectID + type: ADCMCoreType + name: str diff --git a/python/job_runner.py b/python/job_runner.py index b28f492591..5b6a9ae0c7 100755 --- a/python/job_runner.py +++ b/python/job_runner.py @@ -103,11 +103,13 @@ def start_subprocess(job_id, cmd, conf, out_file, err_file): stdout=out_file, stderr=err_file, ) - set_job_start_status(job_id=job_id, pid=process.pid) + + set_job_start_status(job_id=job_id, pid=process.pid) # todo not implemented in runners logger.info("run job #%s, pid %s", job_id, process.pid) return_code = process.wait() - finish_check(job_id) - return_code = set_job_status(job_id=job_id, return_code=return_code) + + finish_check(job_id) # todo not implemented in runners + return_code = set_job_status(job_id=job_id, return_code=return_code) # todo not implemented in runners out_file.close() err_file.close() @@ -144,7 +146,7 @@ def run_ansible(job_id: int) -> None: def run_internal(job: JobLog) -> None: set_job_start_status(job_id=job.id, pid=0) out_file, err_file = process_err_out_file(job_id=job.id, job_type="internal") - script = job.sub_action.script if job.sub_action else job.action.script + script = job.script return_code = 0 status = JobStatus.SUCCESS @@ -160,15 +162,18 @@ def run_internal(job: JobLog) -> None: job.task.save(update_fields=["restore_hc_on_fail"]) if script != "hc_apply": + # todo wut? switch_hc(task=job.task, action=job.action) + # todo shouldn't we reapply policies for every job? + # yes -- for `bundle_switch` and `bundle_revert` re_apply_policy_for_jobs(action_object=object_, task=job.task) except AdcmEx as e: err_file.write(e.msg) return_code = 1 status = JobStatus.FAILED finally: - if script == "bundle_revert": + if script == "bundle_revert": # todo not implemented in runner send_prototype_and_state_update_event(object_=object_) set_job_final_status(job_id=job.id, status=status) @@ -217,7 +222,7 @@ def switch_hc(task, action): def main(job_id): logger.debug("job_runner.py called as: %s", sys.argv) job = JobLog.objects.get(id=job_id) - job_type = job.sub_action.script_type if job.sub_action else job.action.script_type + job_type = job.script_type if job_type == "internal": run_internal(job=job) elif job_type == "python": diff --git a/python/runner.py b/python/runner.py new file mode 100755 index 0000000000..df2ebaabed --- /dev/null +++ b/python/runner.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import os +import sys +import signal +import logging +import argparse + +import adcm.init_django # noqa: F401, isort:skip +from cm.services.job.run import get_default_runner, get_restart_runner + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("command", choices=["start", "restart"]) + parser.add_argument("task_id", type=int) + args = parser.parse_args() + + runner = get_restart_runner() if args.command == "restart" else get_default_runner() + + logger = logging.getLogger("task_runner_err") + + exit_ = {"code": 0} + + def terminate(signum, frame): + _ = frame + + logger.info(f"Cancelling runner at {os.getpid()} with {signum}") + + exit_["code"] = signum + try: + runner.terminate() + except: # noqa: E722 + logger.exception("Unhandled error occurred during runner termination") + + runner.consider_broken() + + exit_["code"] = 1 + + signal.signal(signal.SIGTERM, terminate) + + try: + runner.run(task_id=args.task_id) + except: # noqa: E722 + logger.exception("Unhandled error occurred during runner execution") + + runner.consider_broken() + + exit_["code"] = 1 + + sys.exit(exit_["code"]) + + +if __name__ == "__main__": + main() diff --git a/python/task_runner.py b/python/task_runner.py index 760cc40fd7..ef2cab94ae 100755 --- a/python/task_runner.py +++ b/python/task_runner.py @@ -91,7 +91,7 @@ def run_job(task_id, job_id, err_file): def set_log_body(job): - name = job.sub_action.script_type if job.sub_action else job.action.script_type + name = job.script_type log_storages = LogStorage.objects.filter(job=job, name=name, type__in=["stdout", "stderr"]) for log_storage in log_storages: file_path = ( @@ -118,7 +118,7 @@ def run_task(task_id: int, args: str | None = None) -> None: task.status = JobStatus.RUNNING task.save(update_fields=["pid", "restore_hc_on_fail", "start_date", "status"]) - send_task_status_update_event(object_=task, status=JobStatus.RUNNING.value) + send_task_status_update_event(task_id=task.pk, status=JobStatus.RUNNING.value) jobs = JobLog.objects.filter(task_id=task.id).order_by("id") if not jobs: From 8603ad1f239230b5c75e52755100d9459b007e83 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Tue, 12 Mar 2024 05:07:02 +0000 Subject: [PATCH 002/208] ADCM-5316 Fix task runner related bugs revealed in integration tests Fixes: 1. Closed log file descriptors for internal scripts, wrapped internal scripts in `atomic` 2. Splat migration into two separate, because `PostgreSQL` doesn't allow changing table and data in it in one migration, `atomic=False` for migration doesn't help for some reason 3. Fix script/playbook path detection Changed: 1. `runner.py` replaced `task_runner.py`, `job_runner.py` removed 2. `cm.job` removed, functionality moved to `cm.services.job` 3. Steps for ansible running environment preparation reworked to use less direct ORM calls --- poetry.lock | 28 +- pyproject.toml | 1 + python/adcm/utils.py | 4 +- python/api/job/serializers.py | 3 +- python/api/job/views.py | 16 +- python/api/tests/test_component.py | 20 +- python/api/tests/test_host.py | 28 +- python/api/tests/test_job.py | 4 +- python/api/tests/test_service.py | 34 +- python/api/tests/test_task.py | 3 + python/api_v2/action/views.py | 3 +- python/api_v2/tests/test_actions.py | 12 +- python/api_v2/tests/test_cluster.py | 10 +- python/api_v2/tests/test_component.py | 2 +- python/api_v2/tests/test_host.py | 4 +- python/api_v2/tests/test_host_provider.py | 2 +- python/api_v2/tests/test_service.py | 6 +- python/audit/tests/test_action.py | 28 +- python/audit/tests/test_task.py | 9 +- python/audit/utils.py | 32 + python/cm/converters.py | 7 +- python/cm/job.py | 773 ------------------ .../cm/management/commands/run_ldap_sync.py | 6 +- .../cm/migrations/0067_tasklog_object_type.py | 12 +- .../cm/migrations/0116_autonomous_joblogs.py | 94 ++- .../0117_post_autonomous_joblogs.py | 57 ++ python/cm/models.py | 2 +- python/cm/services/bundle.py | 57 ++ python/cm/services/job/_utils.py | 105 +++ python/cm/services/job/action.py | 202 +++++ python/cm/services/job/checks.py | 119 +++ python/cm/services/job/config.py | 192 ----- python/cm/services/job/inventory/_base.py | 13 +- python/cm/services/job/inventory/_config.py | 3 - python/cm/services/job/run/__init__.py | 3 +- python/cm/services/job/run/_impl.py | 5 +- .../cm/services/job/run/_target_factories.py | 404 ++++++--- python/cm/services/job/run/_task.py | 56 ++ .../cm/services/job/run/_task_finalizers.py | 92 +++ python/cm/services/job/run/executors.py | 21 +- python/cm/services/job/run/repo.py | 210 ++++- python/cm/services/job/run/runners.py | 198 ++--- python/cm/services/job/utils.py | 74 -- python/cm/tests/test_hc.py | 2 +- python/cm/tests/test_inventory/base.py | 7 +- .../test_inventory/test_action_config.py | 64 +- .../tests/test_inventory/test_group_config.py | 5 +- .../cm/tests/test_inventory/test_imports.py | 24 +- .../cm/tests/test_inventory/test_inventory.py | 92 +-- python/cm/tests/test_job.py | 486 +---------- python/cm/tests/test_migrations/__init__.py | 12 + .../test_migrations/test_0116_and_0117.py | 103 +++ python/cm/tests/test_task_log.py | 132 --- python/cm/upgrade.py | 3 +- python/core/job/dto.py | 6 +- python/core/job/errors.py | 17 + python/core/job/executors.py | 11 +- python/core/job/repo.py | 7 +- python/core/job/runners.py | 12 +- python/core/job/task.py | 8 +- python/core/job/types.py | 71 +- python/core/types.py | 7 + python/init_db.py | 16 + python/job_runner.py | 243 ------ python/runner.py | 65 -- python/task_runner.py | 207 +---- 66 files changed, 1845 insertions(+), 2709 deletions(-) delete mode 100644 python/cm/job.py create mode 100644 python/cm/migrations/0117_post_autonomous_joblogs.py create mode 100644 python/cm/services/bundle.py create mode 100644 python/cm/services/job/_utils.py create mode 100644 python/cm/services/job/action.py create mode 100644 python/cm/services/job/checks.py delete mode 100644 python/cm/services/job/config.py create mode 100644 python/cm/services/job/run/_task.py create mode 100644 python/cm/services/job/run/_task_finalizers.py delete mode 100644 python/cm/services/job/utils.py create mode 100644 python/cm/tests/test_migrations/__init__.py create mode 100644 python/cm/tests/test_migrations/test_0116_and_0117.py create mode 100644 python/core/job/errors.py delete mode 100755 python/job_runner.py delete mode 100755 python/runner.py diff --git a/poetry.lock b/poetry.lock index 043cf3cc23..86ab6eb91b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "adcm-version" @@ -521,6 +521,20 @@ Django = ">=3.2" gprof2dot = ">=2017.09.19" sqlparse = "*" +[[package]] +name = "django-test-migrations" +version = "1.3.0" +description = "Test django schema and data migrations, including ordering" +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "django_test_migrations-1.3.0-py3-none-any.whl", hash = "sha256:b52b29475f9a1bcaa4512f2ec8fad08b5f470cf1cf522e86b7d950252fb6fbf1"}, + {file = "django_test_migrations-1.3.0.tar.gz", hash = "sha256:b42edb1af481e08c9d91c95aa9b373e76e905a931bc19c086ec00a6cb936876e"}, +] + +[package.dependencies] +typing_extensions = ">=3.6,<5" + [[package]] name = "djangorestframework" version = "3.14.0" @@ -1397,7 +1411,6 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1405,15 +1418,8 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1430,7 +1436,6 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1438,7 +1443,6 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -1742,4 +1746,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "fc1d113cdd9de022243712f1e04cbb2ec94de9bf6c587d366a5f373b2ee7f6d8" +content-hash = "19fa29a87862d0e60081abeae2f50c5e6a3380accea8401ddf2af0cfe503d882" diff --git a/pyproject.toml b/pyproject.toml index c699bd1b8e..27abf76fd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ django-cors-headers = "^4.1.0" djangorestframework-camel-case = "^1.4.2" adcm-version = "^1.0.0" drf-spectacular = {version = "^0.27.0", extras = ["sidecar"]} +django-test-migrations = {version = "^1.3.0", python = "3.10"} [tool.poetry.group.lint] optional = true diff --git a/python/adcm/utils.py b/python/adcm/utils.py index 5bd3e7090f..1a69db0df9 100644 --- a/python/adcm/utils.py +++ b/python/adcm/utils.py @@ -17,7 +17,6 @@ from cm.errors import AdcmEx from cm.flag import update_flags from cm.issue import update_hierarchy_issues, update_issue_after_deleting -from cm.job import ActionRunPayload, run_action from cm.models import ( ADCM, Action, @@ -35,6 +34,7 @@ ServiceComponent, TaskLog, ) +from cm.services.job.action import ActionRunPayload, run_action from cm.services.status.notify import reset_objects_in_mm from cm.status_api import send_object_update_event from django.conf import settings @@ -71,7 +71,6 @@ def _change_mm_via_action( action=action, obj=obj, payload=ActionRunPayload(conf={}, attr={}, hostcomponent=[], verbose=False), - hosts=[], ) serializer.validated_data["maintenance_mode"] = MaintenanceMode.CHANGING @@ -381,7 +380,6 @@ def delete_service_from_api(service: ClusterObject) -> Response: action=delete_action, obj=service, payload=ActionRunPayload(conf={}, attr={}, hostcomponent=[], verbose=False), - hosts=[], ) else: delete_service(service=service) diff --git a/python/api/job/serializers.py b/python/api/job/serializers.py index cb199a9c7b..1cb5aba81d 100644 --- a/python/api/job/serializers.py +++ b/python/api/job/serializers.py @@ -14,8 +14,8 @@ import json from cm.ansible_plugin import get_checklogs_data_by_job_id -from cm.job import ActionRunPayload, run_action from cm.models import JobLog, JobStatus, LogStorage, TaskLog +from cm.services.job.action import ActionRunPayload, run_action from django.conf import settings from rest_framework.reverse import reverse from rest_framework.serializers import ( @@ -140,7 +140,6 @@ def create(self, validated_data): hostcomponent=validated_data.get("hc", []), verbose=validated_data.get("verbose", False), ), - hosts=validated_data.get("hosts", []), ) obj.jobs = JobLog.objects.filter(task_id=obj.id) diff --git a/python/api/job/views.py b/python/api/job/views.py index b32c18ec47..46612dff4d 100644 --- a/python/api/job/views.py +++ b/python/api/job/views.py @@ -18,8 +18,9 @@ from adcm.permissions import check_custom_perm, get_object_for_user from adcm.utils import str_remove_non_alnum from audit.utils import audit -from cm.job import restart_task -from cm.models import ActionType, JobLog, LogStorage, TaskLog +from cm.errors import AdcmEx +from cm.models import ActionType, JobLog, JobStatus, LogStorage, TaskLog +from cm.services.job.run import restart_task, run_task from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.http import HttpResponse @@ -219,7 +220,16 @@ def get_serializer_class(self): def restart(self, request: Request, task_pk: int) -> Response: task = get_object_for_user(request.user, VIEW_TASKLOG_PERMISSION, TaskLog, id=task_pk) check_custom_perm(request.user, "change", TaskLog, task) - restart_task(task) + + if task.status in (JobStatus.CREATED, JobStatus.RUNNING): + raise AdcmEx(code="TASK_ERROR", msg=f"task #{task.pk} is running") + + if task.status == JobStatus.SUCCESS: + run_task(task) + elif task.status in (JobStatus.FAILED, JobStatus.ABORTED): + restart_task(task) + else: + raise AdcmEx(code="TASK_ERROR", msg=f"task #{task.pk} has unexpected status: {task.status}") return Response(status=HTTP_200_OK) diff --git a/python/api/tests/test_component.py b/python/api/tests/test_component.py index 93a602494f..4d955e6b5a 100644 --- a/python/api/tests/test_component.py +++ b/python/api/tests/test_component.py @@ -14,7 +14,6 @@ from unittest.mock import patch from adcm.tests.base import BaseTestCase -from cm.job import ActionRunPayload from cm.models import ( Action, Bundle, @@ -26,6 +25,7 @@ Prototype, ServiceComponent, ) +from cm.services.job.action import ActionRunPayload from django.conf import settings from django.urls import reverse from rest_framework.response import Response @@ -142,18 +142,13 @@ def test_change_maintenance_mode_on_with_action_success(self): self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.data["maintenance_mode"], "CHANGING") self.assertEqual(self.component.maintenance_mode, MaintenanceMode.CHANGING) - start_task_mock.assert_called_once_with( - action=action, - obj=self.component, - payload=ActionRunPayload(), - hosts=[], - ) + start_task_mock.assert_called_once_with(action=action, obj=self.component, payload=ActionRunPayload()) def test_change_maintenance_mode_on_from_on_with_action_fail(self): self.component.maintenance_mode = MaintenanceMode.ON self.component.save() - with patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.job.action.run_action") as start_task_mock: response: Response = self.client.post( path=reverse(viewname="v1:component-maintenance-mode", kwargs={"component_id": self.component.pk}), data={"maintenance_mode": "ON"}, @@ -202,18 +197,13 @@ def test_change_maintenance_mode_off_with_action_success(self): self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.data["maintenance_mode"], "CHANGING") self.assertEqual(self.component.maintenance_mode, MaintenanceMode.CHANGING) - start_task_mock.assert_called_once_with( - action=action, - obj=self.component, - payload=ActionRunPayload(), - hosts=[], - ) + start_task_mock.assert_called_once_with(action=action, obj=self.component, payload=ActionRunPayload()) def test_change_maintenance_mode_off_to_off_with_action_fail(self): self.component.maintenance_mode = MaintenanceMode.OFF self.component.save() - with patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.job.action.run_action") as start_task_mock: response: Response = self.client.post( path=reverse(viewname="v1:component-maintenance-mode", kwargs={"component_id": self.component.pk}), data={"maintenance_mode": "OFF"}, diff --git a/python/api/tests/test_host.py b/python/api/tests/test_host.py index 05f9b5ab35..ecd3611ff6 100644 --- a/python/api/tests/test_host.py +++ b/python/api/tests/test_host.py @@ -14,7 +14,6 @@ from unittest.mock import patch from adcm.tests.base import APPLICATION_JSON, BaseTestCase -from cm.job import ActionRunPayload from cm.models import ( Action, ActionType, @@ -27,6 +26,7 @@ Prototype, ServiceComponent, ) +from cm.services.job.action import ActionRunPayload from django.conf import settings from django.urls import reverse from rest_framework.response import Response @@ -91,6 +91,7 @@ def test_change_mm_on_with_action_success(self): name=settings.ADCM_HOST_TURN_ON_MM_ACTION_NAME, type=ActionType.JOB, state_available="any", + script_type="ansible", ) with patch("adcm.utils.run_action") as start_task_mock: @@ -104,18 +105,13 @@ def test_change_mm_on_with_action_success(self): self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.data["maintenance_mode"], "CHANGING") self.assertEqual(self.host.maintenance_mode, MaintenanceMode.CHANGING) - start_task_mock.assert_called_once_with( - action=action, - obj=self.host, - payload=ActionRunPayload(), - hosts=[], - ) + start_task_mock.assert_called_once_with(action=action, obj=self.host, payload=ActionRunPayload()) def test_change_mm_on_from_on_with_action_fail(self): self.host.maintenance_mode = MaintenanceMode.ON self.host.save(update_fields=["maintenance_mode"]) - with patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.job.action.run_action") as start_task_mock: response: Response = self.client.post( path=reverse(viewname="v1:host-maintenance-mode", kwargs={"host_id": self.host.pk}), data={"maintenance_mode": "ON"}, @@ -161,18 +157,13 @@ def test_change_mm_off_with_action_success(self): self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.data["maintenance_mode"], "CHANGING") self.assertEqual(self.host.maintenance_mode, MaintenanceMode.CHANGING) - start_task_mock.assert_called_once_with( - action=action, - obj=self.host, - payload=ActionRunPayload(), - hosts=[], - ) + start_task_mock.assert_called_once_with(action=action, obj=self.host, payload=ActionRunPayload()) def test_change_mm_off_to_off_with_action_fail(self): self.host.maintenance_mode = MaintenanceMode.OFF self.host.save(update_fields=["maintenance_mode"]) - with patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.job.action.run_action") as start_task_mock: response: Response = self.client.post( path=reverse(viewname="v1:host-maintenance-mode", kwargs={"host_id": self.host.pk}), data={"maintenance_mode": "OFF"}, @@ -334,9 +325,4 @@ def test_change_maintenance_mode_on_with_action_via_bundle_success(self): self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.data["maintenance_mode"], "CHANGING") self.assertEqual(host.maintenance_mode, MaintenanceMode.CHANGING) - start_task_mock.assert_called_once_with( - action=action, - obj=host, - payload=ActionRunPayload(), - hosts=[], - ) + start_task_mock.assert_called_once_with(action=action, obj=host, payload=ActionRunPayload()) diff --git a/python/api/tests/test_job.py b/python/api/tests/test_job.py index 3045072cf5..076ece9b6a 100644 --- a/python/api/tests/test_job.py +++ b/python/api/tests/test_job.py @@ -167,7 +167,7 @@ def test_log_files(self): cluster_prototype = Prototype.objects.get(bundle=bundle, type="cluster") cluster = Cluster.objects.create(name="test_cluster", prototype=cluster_prototype) - with patch("cm.job.run_task"): + with patch("cm.services.job.run.run_task"): response: Response = self.client.post( path=reverse(viewname="v1:run-task", kwargs={"cluster_id": cluster.pk, "action_id": action.pk}), ) @@ -201,7 +201,7 @@ def test_task_permissions(self): policy.apply() with self.no_rights_user_logged_in: - with patch("cm.job.run_task"): + with patch("cm.services.job.run.run_task"): self.client.post( path=reverse(viewname="v1:run-task", kwargs={"cluster_id": cluster.pk, "action_id": action.pk}), ) diff --git a/python/api/tests/test_service.py b/python/api/tests/test_service.py index 4f24c9f06a..3b2efbf29f 100644 --- a/python/api/tests/test_service.py +++ b/python/api/tests/test_service.py @@ -14,7 +14,6 @@ from unittest.mock import patch from adcm.tests.base import APPLICATION_JSON, BaseTestCase -from cm.job import ActionRunPayload from cm.models import ( Action, Bundle, @@ -28,6 +27,7 @@ Prototype, ServiceComponent, ) +from cm.services.job.action import ActionRunPayload from django.conf import settings from django.urls import reverse from rest_framework.response import Response @@ -131,18 +131,13 @@ def test_change_maintenance_mode_on_with_action_success(self): self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.data["maintenance_mode"], "CHANGING") self.assertEqual(self.service.maintenance_mode, MaintenanceMode.CHANGING) - start_task_mock.assert_called_once_with( - action=action, - obj=self.service, - payload=ActionRunPayload(), - hosts=[], - ) + start_task_mock.assert_called_once_with(action=action, obj=self.service, payload=ActionRunPayload()) def test_change_maintenance_mode_on_from_on_with_action_fail(self): self.service.maintenance_mode = MaintenanceMode.ON self.service.save() - with patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.job.action.run_action") as start_task_mock: response: Response = self.client.post( path=reverse(viewname="v1:service-maintenance-mode", kwargs={"service_id": self.service.pk}), data={"maintenance_mode": "ON"}, @@ -191,18 +186,13 @@ def test_change_maintenance_mode_off_with_action_success(self): self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.data["maintenance_mode"], "CHANGING") self.assertEqual(self.service.maintenance_mode, MaintenanceMode.CHANGING) - start_task_mock.assert_called_once_with( - action=action, - obj=self.service, - payload=ActionRunPayload(), - hosts=[], - ) + start_task_mock.assert_called_once_with(action=action, obj=self.service, payload=ActionRunPayload()) def test_change_maintenance_mode_off_to_off_with_action_fail(self): self.service.maintenance_mode = MaintenanceMode.OFF self.service.save() - with patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.job.action.run_action") as start_task_mock: response: Response = self.client.post( path=reverse(viewname="v1:service-maintenance-mode", kwargs={"service_id": self.service.pk}), data={"maintenance_mode": "OFF"}, @@ -279,12 +269,7 @@ def test_delete_with_action(self): ) self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) - start_task_mock.assert_called_once_with( - action=action, - obj=self.service, - payload=ActionRunPayload(), - hosts=[], - ) + start_task_mock.assert_called_once_with(action=action, obj=self.service, payload=ActionRunPayload()) def test_delete_with_action_not_created_state(self): action = Action.objects.create(prototype=self.service.prototype, name=settings.ADCM_DELETE_SERVICE_ACTION_NAME) @@ -297,12 +282,7 @@ def test_delete_with_action_not_created_state(self): ) self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) - start_task_mock.assert_called_once_with( - action=action, - obj=self.service, - payload=ActionRunPayload(), - hosts=[], - ) + start_task_mock.assert_called_once_with(action=action, obj=self.service, payload=ActionRunPayload()) def test_upload_with_cyclic_requires(self): self.upload_and_load_bundle(path=Path(self.base_dir, "python/api/tests/files/bundle_cluster_requires.tar")) diff --git a/python/api/tests/test_task.py b/python/api/tests/test_task.py index 6112c48f4c..159db5af07 100644 --- a/python/api/tests/test_task.py +++ b/python/api/tests/test_task.py @@ -173,6 +173,9 @@ def test_retrieve(self): ) def test_restart(self): + self.task_1.status = "failed" + self.task_1.save() + with patch("api.job.views.restart_task"): response: Response = self.client.put( path=reverse(viewname="v1:tasklog-restart", kwargs={"task_pk": self.task_1.pk}), diff --git a/python/api_v2/action/views.py b/python/api_v2/action/views.py index e2bd91a364..cd73c02182 100644 --- a/python/api_v2/action/views.py +++ b/python/api_v2/action/views.py @@ -15,8 +15,8 @@ from adcm.mixins import GetParentObjectMixin from audit.utils import audit from cm.errors import AdcmEx -from cm.job import ActionRunPayload, run_action from cm.models import ADCM, Action, ConcernType, Host, HostComponent, PrototypeConfig +from cm.services.job.action import ActionRunPayload, run_action from cm.stack import check_hostcomponents_objects_exist from django.conf import settings from django.db.models import Q @@ -194,7 +194,6 @@ def run(self, request: Request, *args, **kwargs) -> Response: # noqa: ARG001, A hostcomponent=insert_service_ids(hc_create_data=serializer.validated_data["host_component_map"]), verbose=serializer.validated_data["is_verbose"], ), - hosts=[], ) return Response(status=HTTP_200_OK, data=TaskListSerializer(instance=task).data) diff --git a/python/api_v2/tests/test_actions.py b/python/api_v2/tests/test_actions.py index 5e8f180a88..49c5f34ab2 100644 --- a/python/api_v2/tests/test_actions.py +++ b/python/api_v2/tests/test_actions.py @@ -252,7 +252,7 @@ def test_adcm_4516_disallowed_host_action_not_executable_success(self) -> None: self.host_1.maintenance_mode = MaintenanceMode.ON self.host_1.save(update_fields=["maintenance_mode"]) - with patch("cm.job.run_task", return_value=None): + with patch("cm.services.job.run.run_task", return_value=None): response = self.client.post( path=reverse( viewname="v2:host-action-run", @@ -275,7 +275,7 @@ def test_adcm_4535_job_cant_be_terminated_success(self) -> None: self.add_host_to_cluster(cluster=self.cluster, host=self.host_1) allowed_action = Action.objects.filter(display_name="cluster_host_action_allowed").first() - with patch("cm.job.run_task", return_value=None): + with patch("cm.services.job.run.run_task", return_value=None): response = self.client.post( path=reverse( viewname="v2:host-action-run", @@ -303,7 +303,7 @@ def test_adcm_4856_action_with_non_existing_component_fail(self) -> None: self.add_host_to_cluster(cluster=self.cluster, host=self.host_1) allowed_action = Action.objects.filter(display_name="cluster_host_action_allowed").first() - with patch("cm.job.run_task", return_value=None): + with patch("cm.services.job.run.run_task", return_value=None): response = self.client.post( path=reverse( viewname="v2:host-action-run", @@ -324,7 +324,7 @@ def test_adcm_4856_action_with_non_existing_host_fail(self) -> None: self.add_host_to_cluster(cluster=self.cluster, host=self.host_1) allowed_action = Action.objects.filter(display_name="cluster_host_action_allowed").first() - with patch("cm.job.run_task", return_value=None): + with patch("cm.services.job.run.run_task", return_value=None): response = self.client.post( path=reverse( viewname="v2:host-action-run", @@ -345,7 +345,7 @@ def test_adcm_4856_action_with_duplicated_hc_success(self) -> None: self.add_host_to_cluster(cluster=self.cluster, host=self.host_1) allowed_action = Action.objects.filter(display_name="cluster_host_action_allowed").first() - with patch("cm.job.run_task", return_value=None): + with patch("cm.services.job.run.run_task", return_value=None): response = self.client.post( path=reverse( viewname="v2:host-action-run", @@ -368,7 +368,7 @@ def test_adcm_4856_action_with_several_entries_hc_success(self) -> None: self.add_host_to_cluster(cluster=self.cluster, host=self.host_1) allowed_action = Action.objects.filter(display_name="cluster_host_action_allowed").first() - with patch("cm.job.run_task", return_value=None): + with patch("cm.services.job.run.run_task", return_value=None): response = self.client.post( path=reverse( viewname="v2:host-action-run", diff --git a/python/api_v2/tests/test_cluster.py b/python/api_v2/tests/test_cluster.py index cb0bd02f19..2c19ada7c0 100644 --- a/python/api_v2/tests/test_cluster.py +++ b/python/api_v2/tests/test_cluster.py @@ -372,7 +372,7 @@ def test_retrieve_cluster_action_success(self): self.assertEqual(response.status_code, HTTP_200_OK) def test_run_cluster_action_success(self): - with patch("cm.job.run_task", return_value=None): + with patch("cm.services.job.run.run_task", return_value=None): response = self.client.post( path=reverse( viewname="v2:cluster-action-run", @@ -392,7 +392,7 @@ def test_run_action_with_config_success(self): } adcm_meta = {"/activatable_group": {"isActive": True}} - with patch("cm.job.run_task", return_value=None): + with patch("cm.services.job.run.run_task", return_value=None): response = self.client.post( path=reverse( viewname="v2:cluster-action-run", @@ -404,7 +404,7 @@ def test_run_action_with_config_success(self): self.assertEqual(response.status_code, HTTP_200_OK) def test_run_action_with_config_wrong_configuration_fail(self): - with patch("cm.job.run_task", return_value=None): + with patch("cm.services.job.run.run_task", return_value=None): response = self.client.post( path=reverse( viewname="v2:cluster-action-run", @@ -426,7 +426,7 @@ def test_run_action_with_config_wrong_configuration_fail(self): def test_run_action_with_config_required_adcm_meta_fail(self): config = {"simple": "kuku", "grouped": {"simple": 5, "second": 4.3}, "after": ["something"]} - with patch("cm.job.run_task", return_value=None): + with patch("cm.services.job.run.run_task", return_value=None): response = self.client.post( path=reverse( viewname="v2:cluster-action-run", @@ -441,7 +441,7 @@ def test_run_action_with_config_required_adcm_meta_fail(self): ) def test_run_action_with_config_required_config_fail(self): - with patch("cm.job.run_task", return_value=None): + with patch("cm.services.job.run.run_task", return_value=None): response = self.client.post( path=reverse( viewname="v2:cluster-action-run", diff --git a/python/api_v2/tests/test_component.py b/python/api_v2/tests/test_component.py index e9119762dc..0e2b414057 100644 --- a/python/api_v2/tests/test_component.py +++ b/python/api_v2/tests/test_component.py @@ -127,7 +127,7 @@ def test_action_retrieve_success(self): self.assertTrue(response.json()) def test_action_run_success(self): - with patch("cm.job.run_task", return_value=None): + with patch("cm.services.job.run.run_task", return_value=None): response = self.client.post( path=reverse( "v2:component-action-run", diff --git a/python/api_v2/tests/test_host.py b/python/api_v2/tests/test_host.py index 3ed01170bd..474305ca2f 100644 --- a/python/api_v2/tests/test_host.py +++ b/python/api_v2/tests/test_host.py @@ -580,7 +580,7 @@ def test_host_cluster_retrieve_success(self): self.assertTrue(response.json()) def test_host_cluster_run_success(self): - with patch("cm.job.run_task", return_value=None): + with patch("cm.services.job.run.run_task", return_value=None): response = self.client.post( path=reverse( "v2:host-cluster-action-run", @@ -612,7 +612,7 @@ def test_host_retrieve_success(self): self.assertTrue(response.json()) def test_host_run_success(self): - with patch("cm.job.run_task", return_value=None): + with patch("cm.services.job.run.run_task", return_value=None): response = self.client.post( path=reverse("v2:host-action-run", kwargs={"host_pk": self.host.pk, "pk": self.action.pk}), data={"hostComponentMap": [], "config": {}, "adcmMeta": {}, "isVerbose": False}, diff --git a/python/api_v2/tests/test_host_provider.py b/python/api_v2/tests/test_host_provider.py index 50d5c68252..d2b5b7b4e4 100644 --- a/python/api_v2/tests/test_host_provider.py +++ b/python/api_v2/tests/test_host_provider.py @@ -136,7 +136,7 @@ def test_action_retrieve_success(self): self.assertTrue(response.json()) def test_action_run_success(self): - with patch("cm.job.run_task", return_value=None): + with patch("cm.services.job.run.run_task", return_value=None): response = self.client.post( path=reverse( viewname="v2:provider-action-run", diff --git a/python/api_v2/tests/test_service.py b/python/api_v2/tests/test_service.py index 83ba81efda..7cac30d2fa 100644 --- a/python/api_v2/tests/test_service.py +++ b/python/api_v2/tests/test_service.py @@ -13,7 +13,6 @@ from typing import NamedTuple from unittest.mock import patch -from cm.job import ActionRunPayload, run_action from cm.models import ( Action, ADCMEntityStatus, @@ -28,6 +27,7 @@ ServiceComponent, TaskLog, ) +from cm.services.job.action import ActionRunPayload, run_action from cm.services.status.client import FullStatusMap from django.urls import reverse from rest_framework.status import ( @@ -252,7 +252,7 @@ def test_action_retrieve_success(self): self.assertTrue(response.json()) def test_action_run_success(self): - with patch("cm.job.run_task", return_value=None): + with patch("cm.services.job.run.run_task", return_value=None): response = self.client.post( path=reverse( viewname="v2:service-action-run", @@ -318,7 +318,7 @@ def test_delete_service_abort_own_actions_success(self) -> None: @staticmethod def imitate_task_running(action: Action, object_: Cluster | ClusterObject) -> TaskLog: with patch("subprocess.Popen", return_value=FakePopenResponse(4)): - task = run_action(action=action, obj=object_, payload=ActionRunPayload(), hosts=[]) + task = run_action(action=action, obj=object_, payload=ActionRunPayload()) job = JobLog.objects.filter(task=task).first() job.status = "running" diff --git a/python/audit/tests/test_action.py b/python/audit/tests/test_action.py index 09663ec8a6..b2d0470c98 100644 --- a/python/audit/tests/test_action.py +++ b/python/audit/tests/test_action.py @@ -15,7 +15,6 @@ from unittest.mock import patch from adcm.tests.base import BaseTestCase -from cm.job import finish_task from cm.models import ( ADCM, Action, @@ -155,19 +154,20 @@ def test_adcm_launch(self): user=self.test_user, ) - def test_adcm_finish_fail(self): - finish_task(task=self.task, job=None, status="fail") - - log: AuditLog = AuditLog.objects.order_by("operation_time").last() - - self.check_obj_updated( - log=log, - obj_pk=self.adcm.pk, - obj_name=self.adcm.name, - obj_type=AuditObjectType.ADCM, - operation_name=f"{self.action.display_name} action completed", - operation_result=AuditLogOperationResult.FAIL, - ) + # todo test finalizing action launch with audit + # def test_adcm_finish_fail(self): + # finish_task(task=self.task, job=None, status="fail") + # + # log: AuditLog = AuditLog.objects.order_by("operation_time").last() + # + # self.check_obj_updated( + # log=log, + # obj_pk=self.adcm.pk, + # obj_name=self.adcm.name, + # obj_type=AuditObjectType.ADCM, + # operation_name=f"{self.action.display_name} action completed", + # operation_result=AuditLogOperationResult.FAIL, + # ) def test_component_launch(self): cluster, service, component = self.get_cluster_service_component() diff --git a/python/audit/tests/test_task.py b/python/audit/tests/test_task.py index 222069f7f3..73a143bce9 100644 --- a/python/audit/tests/test_task.py +++ b/python/audit/tests/test_task.py @@ -20,7 +20,7 @@ from django.utils import timezone from rbac.models import User from rest_framework.response import Response -from rest_framework.status import HTTP_404_NOT_FOUND +from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND from audit.models import ( AuditLog, @@ -98,8 +98,13 @@ def test_cancel_denied(self): ) def test_restart(self): + self.task.status = "failed" + self.task.save() + with patch("api.job.views.restart_task"): - self.client.put(path=reverse(viewname="v1:tasklog-restart", kwargs={"task_pk": self.task.pk})) + response = self.client.put(path=reverse(viewname="v1:tasklog-restart", kwargs={"task_pk": self.task.pk})) + + self.assertEqual(response.status_code, HTTP_200_OK) log: AuditLog = AuditLog.objects.order_by("operation_time").last() diff --git a/python/audit/utils.py b/python/audit/utils.py index d7c1c318d2..c7ff4a350b 100644 --- a/python/audit/utils.py +++ b/python/audit/utils.py @@ -48,6 +48,8 @@ get_cm_model_by_type, get_model_by_type, ) +from core.job.types import ExecutionStatus +from core.types import ADCMCoreType, NamedCoreObject from django.contrib.auth.models import User as DjangoUser from django.core.handlers.wsgi import WSGIRequest from django.db.models import Model, ObjectDoesNotExist @@ -65,12 +67,14 @@ from rest_framework.viewsets import ModelViewSet from audit.cases.cases import get_audit_operation_and_object +from audit.cases.common import get_or_create_audit_obj from audit.cef_logger import cef_logger from audit.models import ( AuditLog, AuditLogOperationResult, AuditLogOperationType, AuditObject, + AuditObjectType, AuditOperation, AuditUser, ) @@ -612,3 +616,31 @@ def get_client_ip(request: WSGIRequest) -> str | None: break return host + + +def audit_job_finish(owner: NamedCoreObject, display_name: str, is_upgrade: bool, job_result: ExecutionStatus) -> None: + operation_name = f"{display_name} {'upgrade' if is_upgrade else 'action'} completed" + + if owner.type == ADCMCoreType.HOSTPROVIDER: + obj_type = AuditObjectType.PROVIDER + else: + obj_type = AuditObjectType(owner.type.value) + + audit_object = get_or_create_audit_obj( + object_id=str(owner.id), + object_name=owner.name, + object_type=obj_type, + ) + operation_result = ( + AuditLogOperationResult.SUCCESS if job_result == ExecutionStatus.SUCCESS else AuditLogOperationResult.FAIL + ) + + audit_log = AuditLog.objects.create( + audit_object=audit_object, + operation_name=operation_name, + operation_type=AuditLogOperationType.UPDATE, + operation_result=operation_result, + object_changes={}, + ) + + cef_logger(audit_instance=audit_log, signature_id="Action completion") diff --git a/python/cm/converters.py b/python/cm/converters.py index db0e5a6ebd..aa6e0da533 100644 --- a/python/cm/converters.py +++ b/python/cm/converters.py @@ -64,13 +64,14 @@ def db_record_type_to_core_type(db_record_type: str) -> ADCMCoreType: def model_name_to_core_type(model_name: str) -> ADCMCoreType: + name_ = model_name.lower() try: - return ADCMCoreType(model_name) + return ADCMCoreType(name_) except ValueError: - if model_name == "clusterobject": + if name_ == "clusterobject": return ADCMCoreType.SERVICE - if model_name == "servicecomponent": + if name_ == "servicecomponent": return ADCMCoreType.COMPONENT raise diff --git a/python/cm/job.py b/python/cm/job.py deleted file mode 100644 index b1d86fed50..0000000000 --- a/python/cm/job.py +++ /dev/null @@ -1,773 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -from collections.abc import Hashable -from configparser import ConfigParser -from dataclasses import dataclass, field -from functools import partial -from pathlib import Path -from typing import Any -import copy -import json -import subprocess - -from audit.cases.common import get_or_create_audit_obj -from audit.cef_logger import cef_logger -from audit.models import ( - MODEL_TO_AUDIT_OBJECT_TYPE_MAP, - AuditLog, - AuditLogOperationResult, - AuditLogOperationType, -) -from core.job.dto import TaskPayloadDTO -from core.types import ADCMCoreType, CoreObjectDescriptor -from django.conf import settings -from django.db.transaction import atomic, on_commit -from django.utils import timezone -from rbac.roles import re_apply_policy_for_jobs - -from cm.adcm_config.checks import check_attr -from cm.adcm_config.config import ( - check_config_spec, - get_prototype_config, - process_config_spec, - process_file_type, -) -from cm.api import ( - check_hc, - check_maintenance_mode, - check_sub_key, - get_hc, - make_host_comp_list, - save_hc, -) -from cm.converters import model_name_to_core_type -from cm.errors import AdcmEx, raise_adcm_ex -from cm.hierarchy import Tree -from cm.issue import ( - check_bound_components, - check_component_constraint, - check_hc_requires, - check_service_requires, - lock_affected_objects, - unlock_affected_objects, - update_hierarchy_issues, -) -from cm.logger import logger -from cm.models import ( - ADCM, - Action, - ADCMEntity, - Cluster, - ClusterObject, - ConcernType, - ConfigLog, - Host, - HostComponent, - HostProvider, - JobLog, - JobStatus, - MaintenanceMode, - Prototype, - ServiceComponent, - TaskLog, - Upgrade, - get_object_cluster, -) -from cm.services.config.spec import convert_to_flat_spec_from_proto_flat_spec -from cm.services.job.config import get_job_config -from cm.services.job.inventory import get_inventory_data -from cm.services.job.inventory._config import update_configuration_for_inventory_inplace -from cm.services.job.types import HcAclAction -from cm.services.job.utils import JobScope -from cm.services.status.notify import reset_objects_in_mm -from cm.status_api import ( - send_object_update_event, - send_prototype_and_state_update_event, - send_task_status_update_event, -) -from cm.utils import get_env_with_venv_path -from cm.variant import process_variant - - -@dataclass -class ActionRunPayload: - conf: dict = field(default_factory=dict) - attr: dict = field(default_factory=dict) - hostcomponent: list[dict] = field(default_factory=list) - verbose: bool = False - - -def run_action( - action: Action, - obj: ADCM | Cluster | ClusterObject | ServiceComponent | HostProvider | Host, - payload: ActionRunPayload, - hosts: list[int], -) -> TaskLog: - # todo resolve circular dependency - from cm.services.job.prepare import prepare_task_for_action - - cluster: Cluster | None = get_object_cluster(obj=obj) - - if hosts: - check_action_hosts(action=action, obj=obj, cluster=cluster, hosts=hosts) - - action_target = get_host_object(action=action, cluster=cluster) if action.host_action else obj - - object_locks = action_target.concerns.filter(type=ConcernType.LOCK) - - if action.name == settings.ADCM_DELETE_SERVICE_ACTION_NAME: - object_locks = object_locks.exclude(owner_id=obj.id, owner_type=obj.content_type) - - if object_locks.exists(): - raise AdcmEx(code="LOCK_ERROR", msg=f"object {action_target} is locked") - - if ( - action.name not in settings.ADCM_SERVICE_ACTION_NAMES_SET - and action_target.concerns.filter(type=ConcernType.ISSUE).exists() - ): - raise AdcmEx(code="ISSUE_INTEGRITY_ERROR", msg=f"object {action_target} has issues") - - if not action.allowed(obj=action_target): - raise AdcmEx(code="TASK_ERROR", msg="action is disabled") - - spec, flat_spec = check_action_config(action=action, obj=obj, conf=payload.conf, attr=payload.attr) - - is_upgrade_action = hasattr(action, "upgrade") - - if is_upgrade_action and not action.hostcomponentmap: - check_constraints_for_upgrade( - cluster=cluster, upgrade=action.upgrade, host_comp_list=get_actual_hc(cluster=cluster) - ) - - host_map, post_upgrade_hc = check_hostcomponentmap(cluster=cluster, action=action, new_hc=payload.hostcomponent) - - with atomic(): - target = CoreObjectDescriptor(id=obj.pk, type=model_name_to_core_type(obj.__class__.__name__.lower())) - owner = target - if target.type == ADCMCoreType.HOST and action.host_action: - match action.prototype_type: - case "cluster": - owner = CoreObjectDescriptor(id=cluster.pk, type=ADCMCoreType.CLUSTER) - case "service": - owner = CoreObjectDescriptor( - id=ClusterObject.objects.values_list("id", flat=True) - .filter(cluster=cluster, prototype_id=action.prototype_id) - .get(), - type=ADCMCoreType.SERVICE, - ) - case "component": - owner = CoreObjectDescriptor( - id=ServiceComponent.objects.values_list("id", flat=True) - .filter(cluster=cluster, prototype_id=action.prototype_id) - .get(), - type=ADCMCoreType.COMPONENT, - ) - - task = prepare_task_for_action( - target=target, - owner=owner, - action=action.pk, - payload=TaskPayloadDTO( - conf=payload.conf, - attr=payload.attr, - verbose=payload.verbose, - hostcomponent=get_hc(cluster=cluster), - post_upgrade_hostcomponent=post_upgrade_hc, - ), - ) - task_ = TaskLog.objects.get(id=task.id) - if host_map or (is_upgrade_action and host_map is not None): - save_hc(cluster=cluster, host_comp_list=host_map) - - if payload.conf: - new_conf = update_configuration_for_inventory_inplace( - configuration=payload.conf, - attributes=payload.attr, - specification=convert_to_flat_spec_from_proto_flat_spec(prototypes_flat_spec=flat_spec), - config_owner=CoreObjectDescriptor( - id=obj.pk, type=model_name_to_core_type(model_name=obj._meta.model_name) - ), - ) - process_file_type(obj=task_, spec=spec, conf=payload.conf) - task_.config = new_conf - task_.save() - - on_commit(func=partial(send_task_status_update_event, task_id=task_.pk, status=JobStatus.CREATED.value)) - - re_apply_policy_for_jobs(action_object=obj, task=task_) - - run_task(task_) - - return task_ - - -def check_action_hosts(action: Action, obj: ADCMEntity, cluster: Cluster | None, hosts: list[int]) -> None: - if not action.partial_execution: - raise AdcmEx(code="TASK_ERROR", msg="Only action with partial_execution permission can receive host list") - - provider = obj if obj.prototype.type == "provider" else None - - hosts = Host.objects.filter(id__in=hosts) - - if cluster and hosts.exclude(cluster=cluster).exists(): - raise AdcmEx(code="TASK_ERROR", msg=f"One of hosts does not belong to cluster #{cluster.pk}") - - if provider and hosts.exclude(provider=provider).exists(): - raise AdcmEx(code="TASK_ERROR", msg=f"One of hosts does not belong to host provider #{provider.pk}") - - -def restart_task(task: TaskLog): - if task.status in (JobStatus.CREATED, JobStatus.RUNNING): - raise_adcm_ex("TASK_ERROR", f"task #{task.pk} is running") - elif task.status == JobStatus.SUCCESS: - run_task(task) - elif task.status in (JobStatus.FAILED, JobStatus.ABORTED): - run_task(task, "restart") - else: - raise_adcm_ex("TASK_ERROR", f"task #{task.pk} has unexpected status: {task.status}") - - -def get_host_object(action: Action, cluster: Cluster | None) -> ADCMEntity | None: - obj = None - if action.prototype.type == "service": - obj = ClusterObject.obj.get(cluster=cluster, prototype=action.prototype) - elif action.prototype.type == "component": - obj = ServiceComponent.obj.get(cluster=cluster, prototype=action.prototype) - elif action.prototype.type == "cluster": - obj = cluster - - return obj - - -def check_action_config(action: Action, obj: ADCMEntity, conf: dict, attr: dict) -> tuple[dict, dict]: - proto = action.prototype - spec, flat_spec, _, _ = get_prototype_config(prototype=proto, action=action, obj=obj) - if not spec: - if conf: - raise AdcmEx(code="CONFIG_VALUE_ERROR", msg="Absent config in action prototype") - - return {}, {} - - if not conf: - raise AdcmEx("TASK_ERROR", "action config is required") - - check_attr(proto, action, attr, flat_spec) - - object_config = {} - if obj.config is not None: - object_config = ConfigLog.objects.get(id=obj.config.current).config - - process_variant(obj=obj, spec=spec, conf=object_config) - check_config_spec(proto=proto, obj=action, spec=spec, flat_spec=flat_spec, conf=conf, attr=attr) - - process_config_spec(obj=obj, spec=spec, new_config=conf) - - return spec, flat_spec - - -def add_to_dict(my_dict: dict, key: Hashable, subkey: Hashable, value: Any): - if key not in my_dict: - my_dict[key] = {} - - my_dict[key][subkey] = value - - -def check_action_hc( - action_hc: list[dict], - service: ClusterObject, - component: ServiceComponent, - action: Action, -) -> bool: - for item in action_hc: - if item["service"] == service and item["component"] == component and item["action"] == action: - return True - - return False - - -def cook_comp_key(name, subname): - return f"{name}.{subname}" - - -def cook_delta( - cluster: Cluster, - new_hc: list[tuple[ClusterObject, Host, ServiceComponent]], - action_hc: list[dict], - old: dict = None, -) -> dict: - def add_delta(_delta, action, _key, fqdn, _host): - _service, _comp = _key.split(".") - if not check_action_hc(action_hc, _service, _comp, action): - msg = ( - f'no permission to "{action}" component "{_comp}" of ' f'service "{_service}" to/from hostcomponentmap' - ) - raise_adcm_ex("WRONG_ACTION_HC", msg) - - add_to_dict(_delta[action], _key, fqdn, _host) - - new = {} - for service, host, comp in new_hc: - key = cook_comp_key(service.prototype.name, comp.prototype.name) - add_to_dict(new, key, host.fqdn, host) - - if old is None: - old = {} - for hostcomponent in HostComponent.objects.filter(cluster=cluster): - key = cook_comp_key(hostcomponent.service.prototype.name, hostcomponent.component.prototype.name) - add_to_dict(old, key, hostcomponent.host.fqdn, hostcomponent.host) - - delta = {HcAclAction.ADD.value: {}, HcAclAction.REMOVE.value: {}} - for key, value in new.items(): - if key in old: - for host in value: - if host not in old[key]: - add_delta(_delta=delta, action=HcAclAction.ADD.value, _key=key, fqdn=host, _host=value[host]) - - for host in old[key]: - if host not in value: - add_delta(_delta=delta, action=HcAclAction.REMOVE.value, _key=key, fqdn=host, _host=old[key][host]) - else: - for host in value: - add_delta(_delta=delta, action=HcAclAction.ADD.value, _key=key, fqdn=host, _host=value[host]) - - for key, value in old.items(): - if key not in new: - for host in value: - add_delta(_delta=delta, action=HcAclAction.REMOVE.value, _key=key, fqdn=host, _host=value[host]) - - logger.debug("OLD: %s", old) - logger.debug("NEW: %s", new) - logger.debug("DELTA: %s", delta) - - return delta - - -def check_hostcomponentmap( - cluster: Cluster | None, action: Action, new_hc: list[dict] -) -> tuple[list[tuple[ClusterObject, Host, ServiceComponent]] | None, list]: - if not action.hostcomponentmap: - return None, [] - - if not new_hc: - raise_adcm_ex(code="TASK_ERROR", msg="hc is required") - - if not cluster: - raise_adcm_ex(code="TASK_ERROR", msg="Only cluster objects can have action with hostcomponentmap") - - if not hasattr(action, "upgrade"): - for host_comp in new_hc: - host = Host.obj.get(id=host_comp.get("host_id", 0)) - if host.concerns.filter(type=ConcernType.LOCK).exists(): - raise_adcm_ex(code="LOCK_ERROR", msg=f"object {host} is locked") - - if host.concerns.filter(type=ConcernType.ISSUE).exists(): - raise_adcm_ex(code="ISSUE_INTEGRITY_ERROR", msg=f"object {host} has issues") - - post_upgrade_hc, clear_hc = check_upgrade_hc(action=action, new_hc=new_hc) - - old_hc = get_old_hc(saved_hostcomponent=get_hc(cluster=cluster)) - if not hasattr(action, "upgrade"): - prepared_hc_list = check_hc(cluster=cluster, hc_in=clear_hc) - else: - check_sub_key(hc_in=clear_hc) - prepared_hc_list = make_host_comp_list(cluster=cluster, hc_in=clear_hc) - check_constraints_for_upgrade(cluster=cluster, upgrade=action.upgrade, host_comp_list=prepared_hc_list) - - cook_delta(cluster=cluster, new_hc=prepared_hc_list, action_hc=action.hostcomponentmap, old=old_hc) - - return prepared_hc_list, post_upgrade_hc - - -def check_constraints_for_upgrade(cluster, upgrade, host_comp_list): - try: - for service in ClusterObject.objects.filter(cluster=cluster): - try: - prototype = Prototype.objects.get(name=service.name, type="service", bundle=upgrade.bundle) - check_component_constraint( - cluster=cluster, - service_prototype=prototype, - hc_in=[i for i in host_comp_list if i[0] == service], - old_bundle=cluster.prototype.bundle, - ) - check_service_requires(cluster=cluster, proto=prototype) - except Prototype.DoesNotExist: - pass - - check_hc_requires(shc_list=host_comp_list) - check_bound_components(shc_list=host_comp_list) - check_maintenance_mode(cluster=cluster, host_comp_list=host_comp_list) - except AdcmEx as e: - if e.code == "COMPONENT_CONSTRAINT_ERROR": - e.msg = ( - f"Host-component map of upgraded cluster should satisfy " - f"constraints of new bundle. Now error is: {e.msg}" - ) - - raise_adcm_ex(e.code, e.msg) - - -def check_upgrade_hc(action, new_hc): - post_upgrade_hc = [] - clear_hc = copy.deepcopy(new_hc) - buff = 0 - for host_comp in new_hc: - if "component_prototype_id" in host_comp: - if not hasattr(action, "upgrade"): - raise_adcm_ex( - "WRONG_ACTION_HC", - "Hc map with components prototype available only in upgrade action", - ) - - proto = Prototype.obj.get( - type="component", - id=host_comp["component_prototype_id"], - bundle=action.upgrade.bundle, - ) - for hc_acl in action.hostcomponentmap: - if proto.name == hc_acl["component"]: - buff += 1 - if hc_acl["action"] != HcAclAction.ADD.value: - raise_adcm_ex( - "WRONG_ACTION_HC", - "New components from bundle with upgrade you can only add, not remove", - ) - - if buff == 0: - raise_adcm_ex("INVALID_INPUT", "hc_acl doesn't allow actions with this component") - - post_upgrade_hc.append(host_comp) - clear_hc.remove(host_comp) - - return post_upgrade_hc, clear_hc - - -def check_service_task(cluster_id: int, action: Action) -> ClusterObject | None: - cluster = Cluster.obj.get(id=cluster_id) - try: - return ClusterObject.objects.get(cluster=cluster, prototype=action.prototype) # noqa: TRY300 - except ClusterObject.DoesNotExist: - msg = f"service #{action.prototype.pk} for action " f'"{action.name}" is not installed in cluster #{cluster.pk}' - raise_adcm_ex("CLUSTER_SERVICE_NOT_FOUND", msg) - - return None - - -def check_cluster(cluster_id: int) -> Cluster: - return Cluster.obj.get(id=cluster_id) - - -def get_actual_hc(cluster: Cluster): - new_hc = [] - for hostcomponent in HostComponent.objects.filter(cluster=cluster): - new_hc.append((hostcomponent.service, hostcomponent.host, hostcomponent.component)) - return new_hc - - -def get_old_hc(saved_hostcomponent: list[dict]): - if not saved_hostcomponent: - return {} - - old_hostcomponent = {} - for hostcomponent in saved_hostcomponent: - service = ClusterObject.objects.get(id=hostcomponent["service_id"]) - comp = ServiceComponent.objects.get(id=hostcomponent["component_id"]) - host = Host.objects.get(id=hostcomponent["host_id"]) - key = cook_comp_key(service.prototype.name, comp.prototype.name) - add_to_dict(old_hostcomponent, key, host.fqdn, host) - - return old_hostcomponent - - -def re_prepare_job(job_scope: JobScope) -> None: - cluster = get_object_cluster(obj=job_scope.object) - - delta = {} - if job_scope.action.hostcomponentmap: - delta = cook_delta( - cluster=cluster, - new_hc=get_actual_hc(cluster=cluster), - action_hc=job_scope.action.hostcomponentmap, - old=get_old_hc(saved_hostcomponent=job_scope.task.hostcomponentmap), - ) - - prepare_job(job_scope=job_scope, delta=delta) - - -def write_job_config(job_id: int, config: dict[str, Any]) -> None: - config_path = Path(settings.RUN_DIR, str(job_id), "config.json") - with config_path.open(mode="w", encoding=settings.ENCODING_UTF_8) as config_file: - json.dump(obj=config, fp=config_file, sort_keys=True, separators=(",", ":")) - - -def prepare_job(job_scope: JobScope, delta: dict): - write_job_config(job_id=job_scope.job_id, config=get_job_config(job_scope=job_scope)) - - inventory = get_inventory_data(obj=job_scope.object, action=job_scope.action, delta=delta) - with (settings.RUN_DIR / f"{job_scope.job_id}" / "inventory.json").open( - mode="w", encoding=settings.ENCODING_UTF_8 - ) as file_descriptor: - json.dump(obj=inventory, fp=file_descriptor, separators=(",", ":")) - - prepare_ansible_config(job_id=job_scope.job_id, action=job_scope.action) - - -def get_state(action: Action, job: JobLog, status: str) -> tuple[str | None, list[str], list[str]]: - if status == JobStatus.SUCCESS: - multi_state_set = action.multi_state_on_success_set - multi_state_unset = action.multi_state_on_success_unset - state = action.state_on_success - if not state: - logger.warning('action "%s" success state is not set', action.name) - elif status == JobStatus.FAILED: - state = getattr_first("state_on_fail", job, action) - multi_state_set = getattr_first("multi_state_on_fail_set", job, action) - multi_state_unset = getattr_first("multi_state_on_fail_unset", job, action) - if not state: - logger.warning('action "%s" fail state is not set', action.name) - else: - if status != JobStatus.ABORTED: - logger.error("unknown task status: %s", status) - state = None - multi_state_set = [] - multi_state_unset = [] - - return state, multi_state_set, multi_state_unset - - -def set_action_state( - action: Action, - task: TaskLog, - obj: ADCMEntity, - state: str = None, - multi_state_set: list[str] = None, - multi_state_unset: list[str] = None, -): - if not obj: - logger.warning("empty object for action %s of task #%s", action.name, task.pk) - - return - - logger.info( - 'action "%s" of task #%s will set %s state to "%s" ' - 'add to multi_states "%s" and remove from multi_states "%s"', - action.name, - task.pk, - obj, - state, - multi_state_set, - multi_state_unset, - ) - - if state: - obj.set_state(state) - if hasattr(action, "upgrade"): - send_prototype_and_state_update_event(object_=obj) - else: - send_object_update_event(object_=obj, changes={"state": state}) - - for m_state in multi_state_set or []: - obj.set_multi_state(m_state) - - for m_state in multi_state_unset or []: - obj.unset_multi_state(m_state) - - -def restore_hc(task: TaskLog, action: Action, status: str): - if any( - (status not in {JobStatus.FAILED, JobStatus.ABORTED}, not action.hostcomponentmap, not task.restore_hc_on_fail) - ): - return - - cluster = get_object_cluster(task.task_object) - if cluster is None: - logger.error("no cluster in task #%s", task.pk) - - return - - host_comp_list = [] - for hostcomponent in task.hostcomponentmap: - host = Host.objects.get(id=hostcomponent["host_id"]) - service = ClusterObject.objects.get(id=hostcomponent["service_id"], cluster=cluster) - comp = ServiceComponent.objects.get(id=hostcomponent["component_id"], cluster=cluster, service=service) - host_comp_list.append((service, host, comp)) - - logger.warning("task #%s is failed, restore old hc", task.pk) - save_hc(cluster, host_comp_list) - - -def audit_task( - action: Action, object_: Cluster | ClusterObject | ServiceComponent | HostProvider | Host, status: str -) -> None: - upgrade = Upgrade.objects.filter(action=action).first() - - if upgrade: - operation_name = f"{action.display_name} upgrade completed" - else: - operation_name = f"{action.display_name} action completed" - - obj_type = MODEL_TO_AUDIT_OBJECT_TYPE_MAP.get(object_.__class__) - - if not obj_type: - return - - audit_object = get_or_create_audit_obj( - object_id=object_.pk, - object_name=object_.name, - object_type=obj_type, - ) - operation_result = AuditLogOperationResult.SUCCESS if status == "success" else AuditLogOperationResult.FAIL - - audit_log = AuditLog.objects.create( - audit_object=audit_object, - operation_name=operation_name, - operation_type=AuditLogOperationType.UPDATE, - operation_result=operation_result, - object_changes={}, - ) - cef_logger(audit_instance=audit_log, signature_id="Action completion") - - -def finish_task(task: TaskLog, job: JobLog | None, status: str) -> None: - action = task.action - obj = task.task_object - - state, multi_state_set, multi_state_unset = get_state(action=action, job=job, status=status) - - set_action_state( - action=action, - task=task, - obj=obj, - state=state, - multi_state_set=multi_state_set, - multi_state_unset=multi_state_unset, - ) - restore_hc(task=task, action=action, status=status) - unlock_affected_objects(task=task) - - if obj is not None: - update_hierarchy_issues(obj=obj) - - if ( - action.name in {settings.ADCM_TURN_ON_MM_ACTION_NAME, settings.ADCM_HOST_TURN_ON_MM_ACTION_NAME} - and obj.maintenance_mode == MaintenanceMode.CHANGING - ): - obj.maintenance_mode = MaintenanceMode.OFF - obj.save() - - if ( - action.name in {settings.ADCM_TURN_OFF_MM_ACTION_NAME, settings.ADCM_HOST_TURN_OFF_MM_ACTION_NAME} - and obj.maintenance_mode == MaintenanceMode.CHANGING - ): - obj.maintenance_mode = MaintenanceMode.ON - obj.save() - - audit_task(action=action, object_=obj, status=status) - - set_task_final_status(task=task, status=status) - - send_task_status_update_event(task_id=task.pk, status=status) - - try: - reset_objects_in_mm() - except Exception as error: # noqa: BLE001 - logger.warning("Error loading mm objects on task finish") - logger.exception(error) - - -def run_task(task: TaskLog, args: str = ""): - err_file = open( # noqa: SIM115 - Path(settings.LOG_DIR, "task_runner.err"), - "a+", - encoding=settings.ENCODING_UTF_8, - ) - - cmd = [ - str(Path(settings.CODE_DIR, "runner.py")), - "restart" if args == "restart" else "start", - str(task.pk), - ] - logger.info("task run cmd: %s", " ".join(cmd)) - proc = subprocess.Popen( # noqa: SIM115 - args=cmd, stderr=err_file, env=get_env_with_venv_path(venv=task.action.venv) - ) - logger.info("task run #%s, python process %s", task.pk, proc.pid) - - tree = Tree(obj=task.task_object) - affected_objs = (node.value for node in tree.get_all_affected(node=tree.built_from)) - lock_affected_objects(task=task, objects=affected_objs) - - -def prepare_ansible_config(job_id: int, action: Action): - config_parser = ConfigParser() - config_parser["defaults"] = { - "stdout_callback": "yaml", - "callback_whitelist": "profile_tasks", - } - adcm_object = ADCM.objects.first() - config_log = ConfigLog.objects.get(obj_ref=adcm_object.config, id=adcm_object.config.current) - adcm_conf = config_log.config - - forks = adcm_conf["ansible_settings"]["forks"] - config_parser["defaults"]["forks"] = str(forks) - - params = JobLog.objects.values_list("params").get(pk=job_id) or action.params - - if "jinja2_native" in params: - config_parser["defaults"]["jinja2_native"] = str(params["jinja2_native"]) - - with Path(settings.RUN_DIR, f"{job_id}", "ansible.cfg").open( - mode="w", encoding=settings.ENCODING_UTF_8 - ) as config_file: - config_parser.write(config_file) - - -def set_task_final_status(task: TaskLog, status: str): - task.status = status - task.finish_date = timezone.now() - task.save(update_fields=["status", "finish_date"]) - - -def set_job_start_status(job_id: int, pid: int) -> None: - job = JobLog.objects.get(id=job_id) - job.status = JobStatus.RUNNING - job.start_date = timezone.now() - job.pid = pid - job.save(update_fields=["status", "start_date", "pid"]) - - if job.task.lock and job.task.task_object: - job.task.lock.reason = job.cook_reason() - job.task.lock.save(update_fields=["reason"]) - - -def set_job_final_status(job_id: int, status: str) -> None: - JobLog.objects.filter(id=job_id).update(status=status, finish_date=timezone.now()) - - -def abort_all(): - for task in TaskLog.objects.filter(status=JobStatus.RUNNING): - set_task_final_status(task, JobStatus.ABORTED) - unlock_affected_objects(task=task) - - for job in JobLog.objects.filter(status=JobStatus.RUNNING): - set_job_final_status(job_id=job.pk, status=JobStatus.ABORTED) - - -def getattr_first(attr: str, *objects: Any, default: Any = None) -> Any: - """Get first truthy attr from list of object or use last one or default if set""" - result = None - for obj in objects: - result = getattr(obj, attr, None) - if result: - return result - if default is not None: - return default - return result # it could any falsy value from objects diff --git a/python/cm/management/commands/run_ldap_sync.py b/python/cm/management/commands/run_ldap_sync.py index 3b1b18fa5d..c4b5082964 100644 --- a/python/cm/management/commands/run_ldap_sync.py +++ b/python/cm/management/commands/run_ldap_sync.py @@ -18,8 +18,8 @@ from django.core.management.base import BaseCommand from django.utils import timezone -from cm.job import ActionRunPayload, run_action from cm.models import ADCM, Action, ConfigLog, JobStatus, TaskLog +from cm.services.job.action import ActionRunPayload, run_action logger = logging.getLogger("background_tasks") @@ -51,7 +51,7 @@ def handle(self, *args, **options): # noqa: ARG002 if last_sync is None: logger.debug("First ldap sync launched in %s", timezone.now()) make_audit_log("sync", AuditLogOperationResult.SUCCESS, "launched") - task = run_action(action=action, obj=adcm_object, payload=ActionRunPayload(), hosts=[]) + task = run_action(action=action, obj=adcm_object, payload=ActionRunPayload()) if task: make_audit_log("sync", AuditLogOperationResult.SUCCESS, "completed") else: @@ -61,7 +61,7 @@ def handle(self, *args, **options): # noqa: ARG002 if new_rotate_time <= timezone.now(): logger.debug("Ldap sync launched in %s", timezone.now()) make_audit_log("sync", AuditLogOperationResult.SUCCESS, "launched") - task = run_action(action=action, obj=adcm_object, payload=ActionRunPayload(), hosts=[]) + task = run_action(action=action, obj=adcm_object, payload=ActionRunPayload()) if task: make_audit_log("sync", AuditLogOperationResult.SUCCESS, "completed") else: diff --git a/python/cm/migrations/0067_tasklog_object_type.py b/python/cm/migrations/0067_tasklog_object_type.py index 4d4fc25b28..13a1a11d59 100644 --- a/python/cm/migrations/0067_tasklog_object_type.py +++ b/python/cm/migrations/0067_tasklog_object_type.py @@ -15,8 +15,14 @@ import django.db.models.deletion from django.db import migrations, models -from adcm.utils import OBJECT_TYPES_DICT - +content = { + "component": "servicecomponent", + "service": "clusterobject", + "host": "host", + "provider": "hostprovider", + "cluster": "cluster", + "adcm": "adcm", +} def fix_tasklog(apps, schema_editor): TaskLog = apps.get_model("cm", "TaskLog") @@ -32,7 +38,7 @@ def fix_tasklog(apps, schema_editor): def get_content(context): if context not in cash: - cash[context] = ContentType.objects.get(app_label="cm", model=OBJECT_TYPES_DICT[context]) + cash[context] = ContentType.objects.get(app_label="cm", model=content[context]) return cash[context] def get_task_obj(action, obj_id): diff --git a/python/cm/migrations/0116_autonomous_joblogs.py b/python/cm/migrations/0116_autonomous_joblogs.py index f93d44f3ed..ea5815b191 100644 --- a/python/cm/migrations/0116_autonomous_joblogs.py +++ b/python/cm/migrations/0116_autonomous_joblogs.py @@ -47,6 +47,10 @@ def extract_sub_action_data_to_joblogs(apps, schema_editor): JobLog.objects.filter(sub_action__isnull=True).update(**default_to_fill) +def do_nothing(apps, schema_editor): + return + + class Migration(migrations.Migration): dependencies = [ ("cm", "0115_auto_20231025_1823"), @@ -55,18 +59,36 @@ class Migration(migrations.Migration): operations = [ # create nullable fields migrations.AlterField( - model_name='joblog', - name='status', + model_name="joblog", + name="status", field=models.CharField( - choices=[('created', 'created'), ('success', 'success'), ('failed', 'failed'), ('running', 'running'), - ('locked', 'locked'), ('aborted', 'aborted'), ('broken', 'broken')], max_length=1000), + choices=[ + ("created", "created"), + ("success", "success"), + ("failed", "failed"), + ("running", "running"), + ("locked", "locked"), + ("aborted", "aborted"), + ("broken", "broken"), + ], + max_length=1000, + ), ), migrations.AlterField( - model_name='tasklog', - name='status', + model_name="tasklog", + name="status", field=models.CharField( - choices=[('created', 'created'), ('success', 'success'), ('failed', 'failed'), ('running', 'running'), - ('locked', 'locked'), ('aborted', 'aborted'), ('broken', 'broken')], max_length=1000), + choices=[ + ("created", "created"), + ("success", "success"), + ("failed", "failed"), + ("running", "running"), + ("locked", "locked"), + ("aborted", "aborted"), + ("broken", "broken"), + ], + max_length=1000, + ), ), migrations.AddField( model_name="joblog", @@ -86,7 +108,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name="joblog", name="script", - field=models.CharField(max_length=1000, null=True), + field=models.CharField(max_length=1000, default="unknown"), ), migrations.AddField( model_name="joblog", @@ -94,7 +116,7 @@ class Migration(migrations.Migration): field=models.CharField( choices=[("ansible", "ansible"), ("python", "python"), ("internal", "internal")], max_length=1000, - null=True, + default="ansible", ), ), migrations.AddField( @@ -105,7 +127,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name="joblog", name="allow_to_terminate", - field=models.BooleanField(default=None, null=True), + field=models.BooleanField(default=False), ), migrations.AddField( model_name="joblog", @@ -115,47 +137,33 @@ class Migration(migrations.Migration): migrations.AddField( model_name="joblog", name="name", - field=models.CharField(null=True, max_length=1000), + field=models.CharField(max_length=1000, default="unknown"), preserve_default=False, ), migrations.AddField( - model_name='tasklog', - name='owner_id', + model_name="tasklog", + name="owner_id", field=models.PositiveIntegerField(default=0), ), migrations.AddField( - model_name='tasklog', - name='owner_type', + model_name="tasklog", + name="owner_type", field=models.CharField( - choices=[('adcm', 'adcm'), ('cluster', 'cluster'), ('service', 'service'), ('component', 'component'), - ('hostprovider', 'hostprovider'), ('host', 'host')], max_length=100, null=True), - ), - # move what data can be saved - # todo add reverse code - migrations.RunPython(extract_sub_action_data_to_joblogs), - # make those non-nullable - migrations.AlterField( - model_name="joblog", - name="script", - field=models.CharField(max_length=1000), - ), - migrations.AlterField( - model_name="joblog", - name="script_type", - field=models.CharField( - choices=[("ansible", "ansible"), ("python", "python"), ("internal", "internal")], max_length=1000 + choices=[ + ("adcm", "adcm"), + ("cluster", "cluster"), + ("service", "service"), + ("component", "component"), + ("hostprovider", "hostprovider"), + ("host", "host"), + ], + max_length=100, + null=True, ), ), - migrations.AlterField( - model_name="joblog", - name="name", - field=models.CharField(null=False, max_length=1000), - ), - migrations.AlterField( - model_name="joblog", - name="allow_to_terminate", - field=models.BooleanField(default=False), - ), + # move data that can be saved + migrations.RunPython(code=extract_sub_action_data_to_joblogs, reverse_code=do_nothing), + # make those non-nullable migrations.AlterField( model_name="stagesubaction", name="allow_to_terminate", diff --git a/python/cm/migrations/0117_post_autonomous_joblogs.py b/python/cm/migrations/0117_post_autonomous_joblogs.py new file mode 100644 index 0000000000..8a2e2bd7ec --- /dev/null +++ b/python/cm/migrations/0117_post_autonomous_joblogs.py @@ -0,0 +1,57 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated by Django 3.2.19 on 2024-03-04 12:18 + +# Expected to be applied right after 0116, +# split was made, because of PostgreSQL behavior when populating and altering table in one transaction. +# For some reason, atomic=False failed to work + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("cm", "0116_autonomous_joblogs"), + ] + + operations = [ + migrations.AlterField( + model_name="joblog", + name="script", + field=models.CharField(max_length=1000), + ), + migrations.AlterField( + model_name="joblog", + name="script_type", + field=models.CharField( + choices=[("ansible", "ansible"), ("python", "python"), ("internal", "internal")], max_length=1000 + ), + ), + migrations.AlterField( + model_name="joblog", + name="status", + field=models.CharField( + choices=[ + ("created", "created"), + ("success", "success"), + ("failed", "failed"), + ("running", "running"), + ("locked", "locked"), + ("aborted", "aborted"), + ("broken", "broken"), + ], + default="created", + max_length=1000, + ), + ), + ] diff --git a/python/cm/models.py b/python/cm/models.py index 514b0c4b7c..e5e444e8ff 100644 --- a/python/cm/models.py +++ b/python/cm/models.py @@ -1408,7 +1408,7 @@ def duration(self) -> float | None: class JobLog(AbstractSubAction): task = models.ForeignKey(TaskLog, on_delete=models.SET_NULL, null=True, default=None) pid = models.PositiveIntegerField(blank=True, default=0) - status = models.CharField(max_length=1000, choices=JobStatus.choices) + status = models.CharField(max_length=1000, choices=JobStatus.choices, default="created") start_date = models.DateTimeField(null=True, default=None) finish_date = models.DateTimeField(db_index=True, null=True, default=None) diff --git a/python/cm/services/bundle.py b/python/cm/services/bundle.py new file mode 100644 index 0000000000..5350f00d1b --- /dev/null +++ b/python/cm/services/bundle.py @@ -0,0 +1,57 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path + + +def detect_path_for_file_in_bundle(bundle_root: Path, config_yaml_dir: str | Path, file: str) -> Path: + """ + Detect path to file within bundle directory + + :param bundle_root: Path to bundle root directory (like */adcm/data/bundle/somebundlehash/*) + :param config_yaml_dir: Directory containing *config.yaml* file with definition + of the object that file belongs to. + It is used when filename is specified in a "relative to config.yaml way": + staring with *"./"*. + May also be `""`, `"."`, `Path(".")`, `Path("")` + (which will be considered the same as for `Path` rules). + Those "empty" paths will make result equal for `file="./some.file"` and `file="some.file"`. + :param file: Filename string as it's specified in object definition in bundle. + + >>> from pathlib import Path + >>> bundle_root_dir = Path("/adcm/data/bundle") / "bundle-hash" + >>> this = detect_path_for_file_in_bundle + >>> str(this(bundle_root_dir, "", "./script.yaml")) == str((bundle_root_dir / "script.yaml").resolve()) + True + >>> res = str(this(bundle_root_dir, ".", "./some/script.yaml")) + >>> exp = str((bundle_root_dir / "some" /"script.yaml").resolve()) + >>> res == exp + True + >>> str(this(bundle_root_dir, Path(""), "script.yaml")) == str((bundle_root_dir / "script.yaml").resolve()) + True + >>> res = str(this(bundle_root_dir, Path("inner"), "atroot/script.yaml")) + >>> exp = str((bundle_root_dir / "atroot" / "script.yaml").resolve()) + >>> res == exp + True + >>> res = str(this(bundle_root_dir, Path("inner"), "./script.yaml")) + >>> exp = str((bundle_root_dir / "inner" / "script.yaml").resolve()) + >>> res == exp + True + >>> res = str(this(bundle_root_dir, Path("inner"), "./alongside/script.yaml")) + >>> exp = str((bundle_root_dir / "inner" / "alongside" / "script.yaml").resolve()) + >>> res == exp + True + """ + if file.startswith("./"): + return (bundle_root / config_yaml_dir / file).resolve() + + return (bundle_root / file).resolve() diff --git a/python/cm/services/job/_utils.py b/python/cm/services/job/_utils.py new file mode 100644 index 0000000000..8e1106812c --- /dev/null +++ b/python/cm/services/job/_utils.py @@ -0,0 +1,105 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any, Hashable + +from cm.errors import AdcmEx +from cm.models import Action, Cluster, ClusterObject, Host, HostComponent, ServiceComponent +from cm.services.job.types import HcAclAction + + +def get_old_hc(saved_hostcomponent: list[dict]): + if not saved_hostcomponent: + return {} + + old_hostcomponent = {} + for hostcomponent in saved_hostcomponent: + service = ClusterObject.objects.get(id=hostcomponent["service_id"]) + comp = ServiceComponent.objects.get(id=hostcomponent["component_id"]) + host = Host.objects.get(id=hostcomponent["host_id"]) + key = _cook_comp_key(service.prototype.name, comp.prototype.name) + _add_to_dict(old_hostcomponent, key, host.fqdn, host) + + return old_hostcomponent + + +def cook_delta( + cluster: Cluster, + new_hc: list[tuple[ClusterObject, Host, ServiceComponent]], + action_hc: list[dict], + old: dict = None, +) -> dict: + def add_delta(_delta, action, _key, fqdn, _host): + _service, _comp = _key.split(".") + if not _check_action_hc(action_hc, _service, _comp, action): + msg = ( + f'no permission to "{action}" component "{_comp}" of ' f'service "{_service}" to/from hostcomponentmap' + ) + raise AdcmEx(code="WRONG_ACTION_HC", msg=msg) + + _add_to_dict(_delta[action], _key, fqdn, _host) + + new = {} + for service, host, comp in new_hc: + key = _cook_comp_key(service.prototype.name, comp.prototype.name) + _add_to_dict(new, key, host.fqdn, host) + + if old is None: + old = {} + for hostcomponent in HostComponent.objects.filter(cluster=cluster): + key = _cook_comp_key(hostcomponent.service.prototype.name, hostcomponent.component.prototype.name) + _add_to_dict(old, key, hostcomponent.host.fqdn, hostcomponent.host) + + delta = {HcAclAction.ADD.value: {}, HcAclAction.REMOVE.value: {}} + for key, value in new.items(): + if key in old: + for host in value: + if host not in old[key]: + add_delta(_delta=delta, action=HcAclAction.ADD.value, _key=key, fqdn=host, _host=value[host]) + + for host in old[key]: + if host not in value: + add_delta(_delta=delta, action=HcAclAction.REMOVE.value, _key=key, fqdn=host, _host=old[key][host]) + else: + for host in value: + add_delta(_delta=delta, action=HcAclAction.ADD.value, _key=key, fqdn=host, _host=value[host]) + + for key, value in old.items(): + if key not in new: + for host in value: + add_delta(_delta=delta, action=HcAclAction.REMOVE.value, _key=key, fqdn=host, _host=value[host]) + + return delta + + +def _add_to_dict(my_dict: dict, key: Hashable, subkey: Hashable, value: Any) -> None: + if key not in my_dict: + my_dict[key] = {} + + my_dict[key][subkey] = value + + +def _cook_comp_key(name, subname): + return f"{name}.{subname}" + + +def _check_action_hc( + action_hc: list[dict], + service: ClusterObject, + component: ServiceComponent, + action: Action, +) -> bool: + for item in action_hc: + if item["service"] == service and item["component"] == component and item["action"] == action: + return True + + return False diff --git a/python/cm/services/job/action.py b/python/cm/services/job/action.py new file mode 100644 index 0000000000..8fd374abdb --- /dev/null +++ b/python/cm/services/job/action.py @@ -0,0 +1,202 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import dataclass, field +from functools import partial +from typing import TypeAlias + +from core.job.dto import TaskPayloadDTO +from core.types import ADCMCoreType, CoreObjectDescriptor +from django.conf import settings +from django.db.transaction import atomic, on_commit +from rbac.roles import re_apply_policy_for_jobs + +from cm.adcm_config.checks import check_attr +from cm.adcm_config.config import check_config_spec, get_prototype_config, process_config_spec, process_file_type +from cm.api import get_hc, save_hc +from cm.converters import model_name_to_core_type +from cm.errors import AdcmEx +from cm.models import ( + ADCM, + Action, + ADCMEntity, + Cluster, + ClusterObject, + ConcernType, + ConfigLog, + Host, + HostComponent, + HostProvider, + JobStatus, + ServiceComponent, + TaskLog, + get_object_cluster, +) +from cm.services.config.spec import retrieve_flat_spec_for_action +from cm.services.job.checks import check_constraints_for_upgrade, check_hostcomponentmap +from cm.services.job.inventory._config import update_configuration_for_inventory_inplace +from cm.services.job.prepare import prepare_task_for_action +from cm.services.job.run import run_task +from cm.status_api import send_task_status_update_event +from cm.variant import process_variant + +ObjectWithAction: TypeAlias = ADCM | Cluster | ClusterObject | ServiceComponent | HostProvider | Host + + +@dataclass +class ActionRunPayload: + conf: dict = field(default_factory=dict) + attr: dict = field(default_factory=dict) + hostcomponent: list[dict] = field(default_factory=list) + verbose: bool = False + + +def run_action( + action: Action, + obj: ObjectWithAction, + payload: ActionRunPayload, +) -> TaskLog: + cluster: Cluster | None = get_object_cluster(obj=obj) + + action_target = _get_host_object(action=action, cluster=cluster) if action.host_action else obj + + object_locks = action_target.concerns.filter(type=ConcernType.LOCK) + + if action.name == settings.ADCM_DELETE_SERVICE_ACTION_NAME: + object_locks = object_locks.exclude(owner_id=obj.id, owner_type=obj.content_type) + + if object_locks.exists(): + raise AdcmEx(code="LOCK_ERROR", msg=f"object {action_target} is locked") + + if ( + action.name not in settings.ADCM_SERVICE_ACTION_NAMES_SET + and action_target.concerns.filter(type=ConcernType.ISSUE).exists() + ): + raise AdcmEx(code="ISSUE_INTEGRITY_ERROR", msg=f"object {action_target} has issues") + + if not action.allowed(obj=action_target): + raise AdcmEx(code="TASK_ERROR", msg="action is disabled") + + spec, flat_spec = _check_action_config(action=action, obj=obj, conf=payload.conf, attr=payload.attr) + + is_upgrade_action = hasattr(action, "upgrade") + + if is_upgrade_action and not action.hostcomponentmap: + check_constraints_for_upgrade( + cluster=cluster, upgrade=action.upgrade, host_comp_list=_get_actual_hc(cluster=cluster) + ) + + host_map, post_upgrade_hc = check_hostcomponentmap(cluster=cluster, action=action, new_hc=payload.hostcomponent) + + with atomic(): + target = CoreObjectDescriptor(id=obj.pk, type=model_name_to_core_type(obj.__class__.__name__.lower())) + owner = target + if target.type == ADCMCoreType.HOST and action.host_action: + match action.prototype_type: + case "cluster": + owner = CoreObjectDescriptor(id=cluster.pk, type=ADCMCoreType.CLUSTER) + case "service": + owner = CoreObjectDescriptor( + id=ClusterObject.objects.values_list("id", flat=True) + .filter(cluster=cluster, prototype_id=action.prototype_id) + .get(), + type=ADCMCoreType.SERVICE, + ) + case "component": + owner = CoreObjectDescriptor( + id=ServiceComponent.objects.values_list("id", flat=True) + .filter(cluster=cluster, prototype_id=action.prototype_id) + .get(), + type=ADCMCoreType.COMPONENT, + ) + + task = prepare_task_for_action( + target=target, + owner=owner, + action=action.pk, + payload=TaskPayloadDTO( + conf=payload.conf, + attr=payload.attr, + verbose=payload.verbose, + hostcomponent=get_hc(cluster=cluster), + post_upgrade_hostcomponent=post_upgrade_hc, + ), + ) + task_ = TaskLog.objects.get(id=task.id) + if host_map or (is_upgrade_action and host_map is not None): + save_hc(cluster=cluster, host_comp_list=host_map) + + if payload.conf: + new_conf = update_configuration_for_inventory_inplace( + configuration=payload.conf, + attributes=payload.attr, + specification=retrieve_flat_spec_for_action(owner_prototype=obj.prototype.pk, action=action.pk), + config_owner=CoreObjectDescriptor( + id=obj.pk, type=model_name_to_core_type(model_name=obj._meta.model_name) + ), + ) + process_file_type(obj=task_, spec=spec, conf=payload.conf) + task_.config = new_conf + task_.save() + + on_commit(func=partial(send_task_status_update_event, task_id=task_.pk, status=JobStatus.CREATED.value)) + + re_apply_policy_for_jobs(action_object=obj, task=task_) + + run_task(task_) + + return task_ + + +def _get_host_object(action: Action, cluster: Cluster | None) -> ADCMEntity | None: + obj = None + if action.prototype.type == "service": + obj = ClusterObject.obj.get(cluster=cluster, prototype=action.prototype) + elif action.prototype.type == "component": + obj = ServiceComponent.obj.get(cluster=cluster, prototype=action.prototype) + elif action.prototype.type == "cluster": + obj = cluster + + return obj + + +def _check_action_config(action: Action, obj: ADCMEntity, conf: dict, attr: dict) -> tuple[dict, dict]: + proto = action.prototype + spec, flat_spec, _, _ = get_prototype_config(prototype=proto, action=action, obj=obj) + if not spec: + if conf: + raise AdcmEx(code="CONFIG_VALUE_ERROR", msg="Absent config in action prototype") + + return {}, {} + + if not conf: + raise AdcmEx("TASK_ERROR", "action config is required") + + check_attr(proto, action, attr, flat_spec) + + object_config = {} + if obj.config is not None: + object_config = ConfigLog.objects.get(id=obj.config.current).config + + process_variant(obj=obj, spec=spec, conf=object_config) + check_config_spec(proto=proto, obj=action, spec=spec, flat_spec=flat_spec, conf=conf, attr=attr) + + process_config_spec(obj=obj, spec=spec, new_config=conf) + + return spec, flat_spec + + +def _get_actual_hc(cluster: Cluster): + new_hc = [] + for hostcomponent in HostComponent.objects.filter(cluster=cluster): + new_hc.append((hostcomponent.service, hostcomponent.host, hostcomponent.component)) + return new_hc diff --git a/python/cm/services/job/checks.py b/python/cm/services/job/checks.py new file mode 100644 index 0000000000..d3bbcb2e6f --- /dev/null +++ b/python/cm/services/job/checks.py @@ -0,0 +1,119 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import copy + +from cm.api import check_hc, check_maintenance_mode, check_sub_key, get_hc, make_host_comp_list +from cm.errors import AdcmEx +from cm.issue import check_bound_components, check_component_constraint, check_hc_requires, check_service_requires +from cm.models import Action, Cluster, ClusterObject, ConcernType, Host, Prototype, ServiceComponent +from cm.services.job._utils import cook_delta, get_old_hc +from cm.services.job.types import HcAclAction + + +def check_hostcomponentmap( + cluster: Cluster | None, action: Action, new_hc: list[dict] +) -> tuple[list[tuple[ClusterObject, Host, ServiceComponent]] | None, list]: + if not action.hostcomponentmap: + return None, [] + + if not new_hc: + raise AdcmEx(code="TASK_ERROR", msg="hc is required") + + if not cluster: + raise AdcmEx(code="TASK_ERROR", msg="Only cluster objects can have action with hostcomponentmap") + + if not hasattr(action, "upgrade"): + for host_comp in new_hc: + host = Host.obj.get(id=host_comp.get("host_id", 0)) + if host.concerns.filter(type=ConcernType.LOCK).exists(): + raise AdcmEx(code="LOCK_ERROR", msg=f"object {host} is locked") + + if host.concerns.filter(type=ConcernType.ISSUE).exists(): + raise AdcmEx(code="ISSUE_INTEGRITY_ERROR", msg=f"object {host} has issues") + + post_upgrade_hc, clear_hc = _check_upgrade_hc(action=action, new_hc=new_hc) + + old_hc = get_old_hc(saved_hostcomponent=get_hc(cluster=cluster)) + if not hasattr(action, "upgrade"): + prepared_hc_list = check_hc(cluster=cluster, hc_in=clear_hc) + else: + check_sub_key(hc_in=clear_hc) + prepared_hc_list = make_host_comp_list(cluster=cluster, hc_in=clear_hc) + check_constraints_for_upgrade(cluster=cluster, upgrade=action.upgrade, host_comp_list=prepared_hc_list) + + cook_delta(cluster=cluster, new_hc=prepared_hc_list, action_hc=action.hostcomponentmap, old=old_hc) + + return prepared_hc_list, post_upgrade_hc + + +def check_constraints_for_upgrade(cluster, upgrade, host_comp_list): + try: + for service in ClusterObject.objects.filter(cluster=cluster): + try: + prototype = Prototype.objects.get(name=service.name, type="service", bundle=upgrade.bundle) + check_component_constraint( + cluster=cluster, + service_prototype=prototype, + hc_in=[i for i in host_comp_list if i[0] == service], + old_bundle=cluster.prototype.bundle, + ) + check_service_requires(cluster=cluster, proto=prototype) + except Prototype.DoesNotExist: + pass + + check_hc_requires(shc_list=host_comp_list) + check_bound_components(shc_list=host_comp_list) + check_maintenance_mode(cluster=cluster, host_comp_list=host_comp_list) + except AdcmEx as e: + if e.code == "COMPONENT_CONSTRAINT_ERROR": + e.msg = ( + f"Host-component map of upgraded cluster should satisfy " + f"constraints of new bundle. Now error is: {e.msg}" + ) + + raise AdcmEx(code=e.code, msg=e.msg) from e + + +def _check_upgrade_hc(action, new_hc): + post_upgrade_hc = [] + clear_hc = copy.deepcopy(new_hc) + buff = 0 + for host_comp in new_hc: + if "component_prototype_id" in host_comp: + if not hasattr(action, "upgrade"): + raise AdcmEx( + code="WRONG_ACTION_HC", + msg="Hc map with components prototype available only in upgrade action", + ) + + proto = Prototype.obj.get( + type="component", + id=host_comp["component_prototype_id"], + bundle=action.upgrade.bundle, + ) + for hc_acl in action.hostcomponentmap: + if proto.name == hc_acl["component"]: + buff += 1 + if hc_acl["action"] != HcAclAction.ADD.value: + raise AdcmEx( + code="WRONG_ACTION_HC", + msg="New components from bundle with upgrade you can only add, not remove", + ) + + if buff == 0: + raise AdcmEx(code="INVALID_INPUT", msg="hc_acl doesn't allow actions with this component") + + post_upgrade_hc.append(host_comp) + clear_hc.remove(host_comp) + + return post_upgrade_hc, clear_hc diff --git a/python/cm/services/job/config.py b/python/cm/services/job/config.py deleted file mode 100644 index 5a60f902cd..0000000000 --- a/python/cm/services/job/config.py +++ /dev/null @@ -1,192 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from pathlib import Path -from typing import Any - -from django.conf import settings - -from cm.models import ( - Action, - Cluster, - ClusterObject, - Host, - HostProvider, - ObjectType, - ServiceComponent, - get_object_cluster, -) -from cm.services.job.inventory import get_adcm_configuration -from cm.services.job.types import ( - ADCMActionType, - ClusterActionType, - ComponentActionType, - HostActionType, - HostProviderActionType, - JobConfig, - JobData, - JobEnv, - Selector, - ServiceActionType, -) -from cm.services.job.utils import JobScope, get_bundle_root, get_script_path - -IMPLEMENTED_ACTION_PROTO_TYPES = ( - ObjectType.ADCM, - ObjectType.CLUSTER, - ObjectType.SERVICE, - ObjectType.COMPONENT, - ObjectType.PROVIDER, - ObjectType.HOST, -) -ADCM_HOSTGROUP = "127.0.0.1" - - -def get_job_config(job_scope: JobScope) -> dict[str, Any]: - if (action_proto_type := job_scope.action.prototype.type) not in IMPLEMENTED_ACTION_PROTO_TYPES: - raise NotImplementedError(f'Job Config can\'t be generated for action of "{action_proto_type}" object') - - return JobConfig( - adcm={"config": get_adcm_configuration()}, - context=get_context( - action=job_scope.action, - object_type=job_scope.object.prototype.type, - selector=job_scope.task.selector, - ), - env=JobEnv( - run_dir=str(settings.RUN_DIR), - log_dir=str(settings.LOG_DIR), - tmp_dir=str(Path(settings.RUN_DIR, f"{job_scope.job_id}", "tmp")), - stack_dir=str(Path(get_bundle_root(action=job_scope.action), job_scope.action.prototype.bundle.hash)), - status_api_token=str(settings.STATUS_SECRET_KEY), - ), - job=_get_job_data(job_scope=job_scope), - ).dict(exclude_unset=True) - - -def get_context(action: Action, object_type: str, selector: Selector) -> dict[str, int | str]: - context = {f"{k}_id": v["id"] for k, v in selector.items()} - context["type"] = object_type - - if object_type == ObjectType.HOST and action.host_action: - context["type"] = action.prototype.type - - return context - - -def _get_job_data(job_scope: JobScope) -> JobData: - cluster = get_object_cluster(obj=job_scope.object) - - job_data = JobData( - id=job_scope.job_id, - action=job_scope.action.name, - job_name=job_scope.action.name, - command=job_scope.action.name, - script=job_scope.action.script, - verbose=job_scope.task.verbose, - playbook=get_script_path(action=job_scope.action, job=job_scope.job), - action_type_specification=_get_action_type_specific_data( - cluster=cluster, obj=job_scope.object, action=job_scope.action - ), - ) - - if job_scope.action.params: - job_data.params = job_scope.action.params - - job_data.script = job_scope.job.script - job_data.job_name = job_scope.job.name - job_data.command = job_scope.job.name - if job_scope.job.params: - job_data.params = job_scope.job.params - - if cluster is not None: - job_data.cluster_id = cluster.pk - - if job_scope.config: - job_data.config = job_scope.config - - return job_data - - -def _get_action_type_specific_data( - cluster: Cluster, obj: ClusterObject | ServiceComponent | HostProvider | Host, action: Action -) -> ( - ClusterActionType - | ServiceActionType - | ComponentActionType - | HostProviderActionType - | HostActionType - | ADCMActionType -): - match action.prototype.type: - case ObjectType.SERVICE: - if action.host_action: - service = ClusterObject.objects.get(prototype=action.prototype, cluster=cluster) - - return ServiceActionType( - action_proto_type="service", - hostgroup=service.name, - service_id=service.pk, - service_type_id=service.prototype_id, - ) - - return ServiceActionType( - action_proto_type="service", - hostgroup=obj.prototype.name, - service_id=obj.pk, - service_type_id=obj.prototype_id, - ) - - case ObjectType.COMPONENT: - if action.host_action: - service = ClusterObject.objects.get(prototype=action.prototype.parent, cluster=cluster) - comp = ServiceComponent.objects.get(prototype=action.prototype, cluster=cluster, service=service) - - return ComponentActionType( - action_proto_type="component", - hostgroup=f"{service.name}.{comp.name}", - service_id=service.pk, - component_id=comp.pk, - component_type_id=comp.prototype_id, - ) - - return ComponentActionType( - action_proto_type="component", - hostgroup=f"{obj.service.prototype.name}.{obj.prototype.name}", - service_id=obj.service_id, - component_id=obj.pk, - component_type_id=obj.prototype_id, - ) - - case ObjectType.CLUSTER: - return ClusterActionType(action_proto_type="cluster", hostgroup=ObjectType.CLUSTER.name) - - case ObjectType.HOST: - obj: Host - return HostActionType( - action_proto_type="host", - hostgroup=ObjectType.HOST.name, - hostname=obj.fqdn, - host_id=obj.pk, - host_type_id=obj.prototype_id, - provider_id=obj.provider_id, - ) - - case ObjectType.PROVIDER: - return HostProviderActionType( - action_proto_type="provider", - hostgroup=ObjectType.PROVIDER.name, - provider_id=obj.pk, - ) - - case ObjectType.ADCM: - return ADCMActionType(action_proto_type="adcm", hostgroup=ADCM_HOSTGROUP) diff --git a/python/cm/services/job/inventory/_base.py b/python/cm/services/job/inventory/_base.py index 63130a673c..695ef5e0db 100644 --- a/python/cm/services/job/inventory/_base.py +++ b/python/cm/services/job/inventory/_base.py @@ -18,8 +18,8 @@ from core.types import ADCMCoreType, CoreObjectDescriptor, HostID, HostName, ObjectID from django.db.models import F +from cm.converters import core_type_to_model from cm.models import ( - Action, Cluster, ClusterObject, Host, @@ -51,14 +51,13 @@ ) -def get_inventory_data( - obj: Cluster | ClusterObject | ServiceComponent | HostProvider | Host, action: Action, delta: dict | None = None -) -> dict: - if isinstance(obj, HostProvider) or (isinstance(obj, Host) and not action.host_action): - return _get_inventory_for_action_from_hostprovider_bundle(object_=obj) +def get_inventory_data(target: CoreObjectDescriptor, is_host_action: bool, delta: dict | None = None) -> dict: + target_object = core_type_to_model(target.type).objects.get(id=target.id) + if isinstance(target_object, HostProvider) or (isinstance(target_object, Host) and not is_host_action): + return _get_inventory_for_action_from_hostprovider_bundle(object_=target_object) return _get_inventory_for_action_from_cluster_bundle( - object_=obj, is_host_action=action.host_action, delta=delta or {} + object_=target_object, is_host_action=is_host_action, delta=delta or {} ) diff --git a/python/cm/services/job/inventory/_config.py b/python/cm/services/job/inventory/_config.py index cad0286150..c26ab6a3aa 100644 --- a/python/cm/services/job/inventory/_config.py +++ b/python/cm/services/job/inventory/_config.py @@ -99,9 +99,6 @@ def get_group_config_alternatives_for_hosts_in_cluster_groups( return result -# todo unite with one above - - def get_group_config_alternatives_for_hosts_in_hostprovider_groups( group_configs: Iterable[GroupConfigInfo], hostprovider_vars: dict, diff --git a/python/cm/services/job/run/__init__.py b/python/cm/services/job/run/__init__.py index dd5b7c57bd..f9bc18bd43 100644 --- a/python/cm/services/job/run/__init__.py +++ b/python/cm/services/job/run/__init__.py @@ -11,5 +11,6 @@ # limitations under the License. from cm.services.job.run._impl import get_default_runner, get_restart_runner +from cm.services.job.run._task import restart_task, run_task -__all__ = ["get_default_runner", "get_restart_runner"] +__all__ = ["get_default_runner", "get_restart_runner", "run_task", "restart_task"] diff --git a/python/cm/services/job/run/_impl.py b/python/cm/services/job/run/_impl.py index a89936412d..a3d86f0f52 100644 --- a/python/cm/services/job/run/_impl.py +++ b/python/cm/services/job/run/_impl.py @@ -14,7 +14,7 @@ import os import logging -from core.job.runners import ADCMSettings, AnsibleSettings, ExternalSettings, JobProcessor +from core.job.runners import ADCMSettings, AnsibleSettings, ExternalSettings, IntegrationsSettings, JobProcessor from core.job.types import ExecutionStatus from django.conf import settings from django.utils import timezone @@ -66,6 +66,7 @@ def get_restart_runner(): def _prepare_settings() -> ExternalSettings: return ExternalSettings( - adcm=ADCMSettings(code_root_dir=settings.CODE_DIR, run_dir=settings.RUN_DIR), + adcm=ADCMSettings(code_root_dir=settings.CODE_DIR, run_dir=settings.RUN_DIR, log_dir=settings.LOG_DIR), ansible=AnsibleSettings(ansible_secret_script=settings.CODE_DIR / "ansible_secret.py"), + integrations=IntegrationsSettings(status_server_token=settings.STATUS_SECRET_KEY), ) diff --git a/python/cm/services/job/run/_target_factories.py b/python/cm/services/job/run/_target_factories.py index 63f4689767..82790754d2 100644 --- a/python/cm/services/job/run/_target_factories.py +++ b/python/cm/services/job/run/_target_factories.py @@ -9,56 +9,154 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - +from configparser import ConfigParser from functools import partial from pathlib import Path -from typing import Generator, Iterable, Literal +from typing import Any, Generator, Iterable, Literal +import json from core.job.executors import BundleExecutorConfig, ExecutorConfig from core.job.runners import ExecutionTarget, ExternalSettings from core.job.types import Job, ScriptType, Task +from core.types import ADCMCoreType +from django.db.transaction import atomic +from rbac.roles import re_apply_policy_for_jobs from cm.ansible_plugin import finish_check from cm.api import get_hc, save_hc -from cm.job import check_hostcomponentmap, re_prepare_job -from cm.models import JobLog, LogStorage, Prototype, ServiceComponent, TaskLog +from cm.models import ( + Cluster, + HostComponent, + LogStorage, + Prototype, + ServiceComponent, + TaskLog, +) +from cm.services.adcm import adcm_config, get_adcm_config_id +from cm.services.bundle import detect_path_for_file_in_bundle +from cm.services.job._utils import cook_delta, get_old_hc +from cm.services.job.checks import check_hostcomponentmap +from cm.services.job.inventory import get_adcm_configuration, get_inventory_data from cm.services.job.run.executors import ( AnsibleExecutorConfig, AnsibleProcessExecutor, InternalExecutor, PythonProcessExecutor, ) -from cm.services.job.utils import JobScope +from cm.services.job.types import ( + ADCMActionType, + ClusterActionType, + ComponentActionType, + HostActionType, + HostProviderActionType, + JobConfig, + JobData, + JobEnv, + ServiceActionType, +) from cm.status_api import send_prototype_and_state_update_event -from cm.upgrade import bundle_revert, bundle_switch -def _prepare_ansible_environment(job: Job) -> None: - # todo rework re-prepare, so it won't request what is shouldn't - # probably will require something else but job_info here +class ExecutionTargetFactory: + def __init__(self): + self._default_ansible_finalizers = (finish_check_logs,) + self._supported_internal_scripts = { + "bundle_switch": internal_script_bundle_switch, + "bundle_revert": internal_script_bundle_revert, + "hc_apply": internal_script_hc_apply, + } + + def __call__( + self, task: Task, jobs: Iterable[Job], configuration: ExternalSettings + ) -> Generator[ExecutionTarget, None, None]: + for job_info in jobs: + work_dir = configuration.adcm.run_dir / str(job_info.id) + finalizers = ( + partial(save_fs_logs_to_db, work_dir=work_dir, log_type="stderr"), + partial(save_fs_logs_to_db, work_dir=work_dir, log_type="stdout"), + ) + match job_info.type: + case ScriptType.ANSIBLE: + executor = AnsibleProcessExecutor( + config=AnsibleExecutorConfig( + job_script=job_info.script, + work_dir=work_dir, + bundle=task.bundle, + tags=job_info.params.ansible_tags, + verbose=task.verbose, + venv=task.action.venv, + ansible_secret_script=configuration.ansible.ansible_secret_script, + ) + ) + finalizers = (*self._default_ansible_finalizers, *finalizers) + environment_builders = (prepare_ansible_environment,) + case ScriptType.PYTHON: + executor = PythonProcessExecutor( + config=BundleExecutorConfig( + job_script=job_info.script, + work_dir=work_dir, + bundle=task.bundle, + ) + ) + environment_builders = () + case ScriptType.INTERNAL: + internal_script_func = self._supported_internal_scripts.get(job_info.script) + if not internal_script_func: + message = f"Unknown internal script {job_info.type}, can't build runner for it" + raise NotImplementedError(message) + + script = partial(internal_script_func, task=task) + executor = InternalExecutor(config=ExecutorConfig(work_dir=work_dir), script=script) + environment_builders = () + case _: + message = f"Can't convert job of type {job_info.type}" + raise NotImplementedError(message) + + yield ExecutionTarget( + job=job_info, executor=executor, environment_builders=environment_builders, finalizers=finalizers + ) - # fixme with null object will fail at - # `cm.services.job.config.get_job_config` | line: object_type=job_scope.object.prototype.type, - re_prepare_job(job_scope=JobScope(job_id=job.id, object=JobLog.objects.get(id=job.id).task.task_object)) +# INTERNAL SCRIPTS -def _finish_check_logs(job: Job) -> None: - finish_check(job.id) +@atomic() +def internal_script_bundle_switch(task: Task) -> int: + from cm.upgrade import bundle_switch -def _save_fs_logs_to_db(job: Job, work_dir: Path, log_type: Literal["stdout", "stderr"]) -> None: - # todo maybe format can be unified with one that's used by `WithErrOutLogsMixin` - log_path = work_dir / f"{job.type.value}-{log_type}.txt" - if not log_path.is_file(): - # todo raise exception? - only if each step "is catchable" - return + task_ = TaskLog.objects.get(id=task.id) - corresponding_log = LogStorage.objects.filter(job_id=job.id, name=job.type.value, type=log_type).first() - if not corresponding_log: - return + bundle_switch(obj=task_.task_object, upgrade=task_.action.upgrade) + _switch_hc_if_required(task=task_) + + re_apply_policy_for_jobs(action_object=task_.task_object, task=task_) + + return 0 + + +@atomic() +def internal_script_bundle_revert(task: Task) -> int: + from cm.upgrade import bundle_revert + + task_ = TaskLog.objects.get(id=task.id) + + try: + bundle_revert(obj=task_.task_object) + finally: + send_prototype_and_state_update_event(object_=task_.task_object) + + _switch_hc_if_required(task=task_) + + re_apply_policy_for_jobs(action_object=task_.task_object, task=task_) + + return 0 - corresponding_log.body = log_path.read_text(encoding="utf-8") - corresponding_log.save(update_fields=["body"]) + +@atomic() +def internal_script_hc_apply(task: Task) -> int: + TaskLog.objects.filter(id=task.id).update(restore_hc_on_fail=False) + + return 0 def _switch_hc_if_required(task: TaskLog): @@ -73,7 +171,7 @@ def _switch_hc_if_required(task: TaskLog): cluster = task.task_object old_hc = get_hc(cluster) new_hc = [] - for hostcomponent in [*task.post_upgrade_hc_map, *old_hc]: + for hostcomponent in [*(task.post_upgrade_hc_map or ()), *(old_hc or ())]: if hostcomponent not in new_hc: new_hc.append(hostcomponent) @@ -93,89 +191,187 @@ def _switch_hc_if_required(task: TaskLog): save_hc(cluster, host_map) -def _internal_script_bundle_switch(task: Task) -> int: - task_ = TaskLog.objects.get(id=task.id) - - bundle_switch(obj=task_.task_object, upgrade=task_.action.upgrade) - _switch_hc_if_required(task=task_) - - return 0 - - -def _internal_script_bundle_revert(task: Task) -> int: - task = TaskLog.objects.get(id=task.id) - - try: - bundle_revert(obj=task.task_object) - finally: - send_prototype_and_state_update_event(object_=task.task_object) - - _switch_hc_if_required(task=task) - - return 0 +# ENVIRONMENT BUILDERS + + +def prepare_ansible_environment(task: Task, job: Job, configuration: ExternalSettings) -> None: + job_config = prepare_ansible_job_config(task=task, job=job, configuration=configuration) + job_run_dir = configuration.adcm.run_dir / str(job.id) + with (job_run_dir / "config.json").open(mode="w", encoding="utf-8") as config_file: + json.dump(obj=job_config, fp=config_file, sort_keys=True, separators=(",", ":")) + + inventory = prepare_ansible_inventory(task=task) + with (job_run_dir / "inventory.json").open(mode="w", encoding="utf-8") as file_descriptor: + json.dump(obj=inventory, fp=file_descriptor, separators=(",", ":")) + + config_parser = ConfigParser() + config_parser["defaults"] = { + "stdout_callback": "yaml", + "callback_whitelist": "profile_tasks", + } + + forks = adcm_config(get_adcm_config_id()).config["ansible_settings"]["forks"] + config_parser["defaults"]["forks"] = str(forks) + + jinja_2_native = getattr(job.params, "jinja_2_native", None) + if jinja_2_native is not None: + config_parser["defaults"]["jinja2_native"] = str(jinja_2_native) + + with (job_run_dir / "ansible.cfg").open(mode="w", encoding="utf-8") as config_file: + config_parser.write(config_file) + + +def prepare_ansible_inventory(task: Task) -> dict[str, Any]: + delta = {} + if task.hostcomponent.saved: + cluster_id = None + if task.owner: + if task.owner.type == ADCMCoreType.CLUSTER: + cluster_id = task.owner.id + elif task.owner.related_objects.cluster: + cluster_id = task.owner.related_objects.cluster.id + + if not cluster_id: + message = f"Can't detect cluster id for {task.id} {task.action.name} based on: {task.owner=}" + raise RuntimeError(message) + + new_hc = [] + for hostcomponent in HostComponent.objects.filter(cluster_id=cluster_id): + new_hc.append((hostcomponent.service, hostcomponent.host, hostcomponent.component)) + + delta = cook_delta( + cluster=Cluster.objects.get(id=cluster_id), + new_hc=new_hc, + action_hc=task.action.hc_acl, + old=get_old_hc(saved_hostcomponent=task.hostcomponent.saved), + ) + + return get_inventory_data(target=task.target, is_host_action=task.action.is_host_action, delta=delta) + + +def prepare_ansible_job_config(task: Task, job: Job, configuration: ExternalSettings) -> dict[str, Any]: + # prepare context + context = {f"{k}_id": v["id"] for k, v in task.selector.items()} + context["type"] = task.owner.type.value.replace("hostp", "p") + + playbook = detect_path_for_file_in_bundle( + bundle_root=task.bundle.root, + config_yaml_dir=task.bundle.config_dir, + file=job.script, + ) + + job_data = JobData( + id=job.id, + action=task.action.name, + job_name=job.name, + command=job.name, + script=job.script, + verbose=task.verbose, + playbook=str(playbook), + action_type_specification=_get_owner_specific_data(task=task), + ) + + if task.owner: + if task.owner.type == ADCMCoreType.CLUSTER: + job_data.cluster_id = task.owner.id + elif task.owner.related_objects.cluster is not None: + job_data.cluster_id = task.owner.related_objects.cluster.id + + if task.config: + job_data.config = task.config + + params: dict = job.params.dict() + if not params["ansible_tags"]: + # if it's empty, it shouldn't be included + # and since it's the only "pre-defined" field we want empty dict if that's the case + params.pop("ansible_tags") + + if params: + job_data.params = params + + return JobConfig( + adcm={"config": get_adcm_configuration()}, + context=context, + env=JobEnv( + run_dir=str(configuration.adcm.run_dir), + log_dir=str(configuration.adcm.log_dir), + tmp_dir=str(configuration.adcm.run_dir / str(job.id) / "tmp"), + stack_dir=str(task.bundle.root), + status_api_token=configuration.integrations.status_server_token, + ), + job=job_data, + ).dict(exclude_unset=True) + + +def _get_owner_specific_data( + task: Task, +) -> ( + ClusterActionType + | ServiceActionType + | ComponentActionType + | HostProviderActionType + | HostActionType + | ADCMActionType +): + owner = task.owner + if not owner: + message = "Can't get owner task data for task without owner" + raise RuntimeError(message) + + match owner.type: + case ADCMCoreType.CLUSTER: + return ClusterActionType(action_proto_type="cluster", hostgroup="CLUSTER") + case ADCMCoreType.HOSTPROVIDER: + return HostProviderActionType( + action_proto_type="provider", + hostgroup="PROVIDER", + provider_id=task.owner.id, + ) + case ADCMCoreType.HOST: + return HostActionType( + action_proto_type="host", + hostgroup="HOST", + hostname=task.owner.name, + host_id=task.owner.id, + host_type_id=task.owner.prototype_id, + provider_id=task.owner.related_objects.hostprovider.id, + ) + case ADCMCoreType.SERVICE: + return ServiceActionType( + action_proto_type="service", + hostgroup=task.owner.name, + service_id=task.owner.id, + service_type_id=task.owner.prototype_id, + ) + case ADCMCoreType.COMPONENT: + return ComponentActionType( + action_proto_type="component", + hostgroup=f"{owner.related_objects.service.name}.{owner.name}", + service_id=owner.related_objects.service.id, + component_id=owner.id, + component_type_id=owner.prototype_id, + ) + case _: + # ADCM will go in here for now, because ansible config is undefined for it + message = f"Can't get task data for task with owner {owner.type}" + raise NotImplementedError(message) -def _internal_script_hc_apply(task: Task) -> int: - TaskLog.objects.filter(id=task.id).update(restore_hc_on_fail=False) +# FINALIZERS - return 0 +def finish_check_logs(job: Job) -> None: + finish_check(job.id) -class ExecutionTargetFactory: - def __init__(self): - self._default_ansible_finalizers = (_finish_check_logs,) - self._supported_internal_scripts = { - "bundle_switch": _internal_script_bundle_switch, - "bundle_revert": _internal_script_bundle_revert, - "hc_apply": _internal_script_hc_apply, - } - def __call__( - self, task: Task, jobs: Iterable[Job], configuration: ExternalSettings - ) -> Generator[ExecutionTarget, None, None]: - for job_info in jobs: - work_dir = configuration.adcm.run_dir / str(job_info.id) - finalizers = ( - partial(_save_fs_logs_to_db, work_dir=work_dir, log_type="stderr"), - partial(_save_fs_logs_to_db, work_dir=work_dir, log_type="stdout"), - ) - match job_info.type: - case ScriptType.ANSIBLE: - executor = AnsibleProcessExecutor( - config=AnsibleExecutorConfig( - script_file=Path(job_info.script), - work_dir=work_dir, - bundle_root=task.bundle_root, - tags=job_info.params.ansible_tags, - verbose=task.verbose, - venv=task.venv, - ansible_secret_script=configuration.ansible.ansible_secret_script, - ) - ) - finalizers = (*self._default_ansible_finalizers, *finalizers) - environment_builders = (_prepare_ansible_environment,) - case ScriptType.PYTHON: - executor = PythonProcessExecutor( - config=BundleExecutorConfig( - script_file=Path(job_info.script), - work_dir=work_dir, - bundle_root=task.bundle_root, - ) - ) - environment_builders = () - case ScriptType.INTERNAL: - internal_script_func = self._supported_internal_scripts.get(job_info.script) - if not internal_script_func: - message = f"Unknown internal script {job_info.type}, can't build runner for it" - raise NotImplementedError(message) +def save_fs_logs_to_db(job: Job, work_dir: Path, log_type: Literal["stdout", "stderr"]) -> None: + log_path = work_dir / f"{job.type.value}-{log_type}.txt" + if not log_path.is_file(): + return - script = partial(internal_script_func, task=task) - executor = InternalExecutor(config=ExecutorConfig(work_dir=work_dir), script=script) - environment_builders = () - case _: - message = f"Can't convert job of type {job_info.type}" - raise NotImplementedError(message) + corresponding_log = LogStorage.objects.filter(job_id=job.id, name=job.type.value, type=log_type).first() + if not corresponding_log: + return - yield ExecutionTarget( - job=job_info, executor=executor, environment_builders=environment_builders, finalizers=finalizers - ) + corresponding_log.body = log_path.read_text(encoding="utf-8") + corresponding_log.save(update_fields=["body"]) diff --git a/python/cm/services/job/run/_task.py b/python/cm/services/job/run/_task.py new file mode 100644 index 0000000000..bd9c101862 --- /dev/null +++ b/python/cm/services/job/run/_task.py @@ -0,0 +1,56 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path +from typing import Literal +import logging +import subprocess + +from django.conf import settings + +from cm.hierarchy import Tree +from cm.issue import lock_affected_objects +from cm.models import TaskLog +from cm.utils import get_env_with_venv_path + +logger = logging.getLogger("adcm") + + +def run_task(task: TaskLog) -> None: + _run_task(task=task, command="start") + + +def restart_task(task: TaskLog) -> None: + _run_task(task=task, command="restart") + + +def _run_task(task: TaskLog, command: Literal["start", "restart"]): + err_file = open( # noqa: SIM115 + Path(settings.LOG_DIR, "task_runner.err"), + "a+", + encoding=settings.ENCODING_UTF_8, + ) + + cmd = [ + str(settings.CODE_DIR / "task_runner.py"), + command, + str(task.pk), + ] + logger.info("task run cmd: %s", " ".join(cmd)) + proc = subprocess.Popen( # noqa: SIM115 + args=cmd, stderr=err_file, env=get_env_with_venv_path(venv=task.action.venv) + ) + logger.info("task run #%s, python process %s", task.pk, proc.pid) + + tree = Tree(obj=task.task_object) + affected_objs = (node.value for node in tree.get_all_affected(node=tree.built_from)) + lock_affected_objects(task=task, objects=affected_objs) diff --git a/python/cm/services/job/run/_task_finalizers.py b/python/cm/services/job/run/_task_finalizers.py new file mode 100644 index 0000000000..2a0f40a1eb --- /dev/null +++ b/python/cm/services/job/run/_task_finalizers.py @@ -0,0 +1,92 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from logging import Logger +from operator import itemgetter + +from core.job.types import Task +from core.types import CoreObjectDescriptor +from django.conf import settings + +from cm.api import save_hc +from cm.converters import core_type_to_model +from cm.issue import unlock_affected_objects, update_hierarchy_issues +from cm.models import ClusterObject, Host, JobLog, MaintenanceMode, ServiceComponent, TaskLog, get_object_cluster + +# todo "unwrap" these functions to use repo without directly calling ORM, +# try to rework functions like `save_hc` also, because they rely on API v1 input +# which is in no way correct approach + + +def set_job_lock(job_id: int) -> None: + job = JobLog.objects.select_related("task").get(pk=job_id) + if job.task.lock and job.task.task_object: + job.task.lock.reason = job.cook_reason() + job.task.lock.save(update_fields=["reason"]) + + +def set_hostcomponent(task: Task, logger: Logger): + task_object = TaskLog.objects.prefetch_related("task_object").get(id=task.id).task_object + + cluster = get_object_cluster(task_object) + if cluster is None: + logger.error("no cluster in task #%s", task.id) + + return + + new_hostcomponent = task.hostcomponent.saved + hosts = { + entry.pk: entry for entry in Host.objects.filter(id__in=set(map(itemgetter("host_id"), new_hostcomponent))) + } + services = { + entry.pk: entry + for entry in ClusterObject.objects.filter(id__in=set(map(itemgetter("service_id"), new_hostcomponent))) + } + components = { + entry.pk: entry + for entry in ServiceComponent.objects.filter(id__in=set(map(itemgetter("component_id"), new_hostcomponent))) + } + + host_comp_list = [ + (services[entry["service_id"]], hosts[entry["host_id"]], components[entry["component_id"]]) + for entry in new_hostcomponent + ] + + logger.warning("task #%s is failed, restore old hc", task.id) + + save_hc(cluster, host_comp_list) + + +def remove_task_lock(task_id: int) -> None: + unlock_affected_objects(TaskLog.objects.get(pk=task_id)) + + +def update_issues(object_: CoreObjectDescriptor): + update_hierarchy_issues(obj=core_type_to_model(core_type=object_.type).objects.get(id=object_.id)) + + +def update_object_maintenance_mode(action_name: str, object_: CoreObjectDescriptor): + obj = core_type_to_model(core_type=object_.type).objects.get(id=object_.id) + + if ( + action_name in {settings.ADCM_TURN_ON_MM_ACTION_NAME, settings.ADCM_HOST_TURN_ON_MM_ACTION_NAME} + and obj.maintenance_mode == MaintenanceMode.CHANGING + ): + obj.maintenance_mode = MaintenanceMode.OFF + obj.save() + + if ( + action_name in {settings.ADCM_TURN_OFF_MM_ACTION_NAME, settings.ADCM_HOST_TURN_OFF_MM_ACTION_NAME} + and obj.maintenance_mode == MaintenanceMode.CHANGING + ): + obj.maintenance_mode = MaintenanceMode.ON + obj.save() diff --git a/python/cm/services/job/run/executors.py b/python/cm/services/job/run/executors.py index 6785db5998..edda0967fc 100644 --- a/python/cm/services/job/run/executors.py +++ b/python/cm/services/job/run/executors.py @@ -24,6 +24,7 @@ from typing_extensions import Self from cm.errors import AdcmEx +from cm.services.bundle import detect_path_for_file_in_bundle from cm.utils import get_env_with_venv_path @@ -43,7 +44,11 @@ def __init__(self, config: AnsibleExecutorConfig): super().__init__(config=config) def _prepare_command(self) -> list[str]: - playbook = self._config.script_file + playbook = detect_path_for_file_in_bundle( + bundle_root=self._config.bundle.root, + config_yaml_dir=self._config.bundle.config_dir, + file=self._config.job_script, + ) cmd = [ "ansible-playbook", "--vault-password-file", @@ -52,7 +57,7 @@ def _prepare_command(self) -> list[str]: f"@{self._config.work_dir}/config.json", "-i", f"{self._config.work_dir}/inventory.json", - playbook, + str(playbook), ] if self._config.tags: @@ -70,7 +75,8 @@ def _get_environment_variables(self) -> dict: # This condition is intended to support compatibility. # Since older bundle versions may contain their own ansible.cfg - if not Path(self._config.bundle_root, "ansible.cfg").is_file(): + if not Path(self._config.bundle.root, "ansible.cfg").is_file(): + # bundle root dir (workdir) is used as in `stack_dir` in ansible job config env["ANSIBLE_CONFIG"] = str(self._config.work_dir / "ansible.cfg") return env @@ -80,7 +86,12 @@ class PythonProcessExecutor(ProcessExecutor): script_type = "python" def _prepare_command(self) -> list[str]: - return ["python", self._config.script_file] + script_fullpath = detect_path_for_file_in_bundle( + bundle_root=self._config.bundle.root, + config_yaml_dir=self._config.bundle.config_dir, + file=self._config.job_script, + ) + return ["python", str(script_fullpath)] class InternalExecutor(Executor, WithErrOutLogsMixin): @@ -98,6 +109,8 @@ def execute(self) -> Self: except AdcmEx as err: self._err_log.write(err.msg) return_code = 1 + finally: + self._close_logs() self._result = ExecutionResult(code=return_code) diff --git a/python/cm/services/job/run/repo.py b/python/cm/services/job/run/repo.py index f54a6674c4..a3107c6a1f 100644 --- a/python/cm/services/job/run/repo.py +++ b/python/cm/services/job/run/repo.py @@ -10,20 +10,28 @@ # See the License for the specific language governing permissions and # limitations under the License. from contextlib import suppress +from copy import deepcopy +from functools import reduce +from pathlib import Path from typing import Collection, Iterable +import operator from core.errors import NotFoundError -from core.job.dto import JobUpdateDTO, LogCreateDTO, TaskPayloadDTO, TaskUpdateDTO +from core.job.dto import JobUpdateDTO, LogCreateDTO, TaskMutableFieldsDTO, TaskPayloadDTO, TaskUpdateDTO from core.job.types import ( ActionInfo, + BundleInfo, ExecutionStatus, HostComponentChanges, Job, JobParams, JobSpec, + RelatedObjects, ScriptType, StateChanges, Task, + TaskActionInfo, + TaskOwner, ) from core.types import ( ActionID, @@ -32,6 +40,7 @@ CoreObjectDescriptor, HostID, NamedCoreObject, + NamedCoreObjectWithPrototype, PrototypeDescriptor, ) from django.conf import settings @@ -76,8 +85,8 @@ class JobRepoImpl: HostProvider: {"object_id": F("id"), "object_name": F("name"), "type_name": Value("provider")}, } - @staticmethod - def get_task(id: int) -> Task: # noqa: A002 + @classmethod + def get_task(cls, id: int) -> Task: # noqa: A002 try: task_record: TaskLog = ( TaskLog.objects.select_related("action__prototype") @@ -93,35 +102,36 @@ def get_task(id: int) -> Task: # noqa: A002 raise RuntimeError(message) action_prototype = task_record.action.prototype - target_ = bundle_root = None + target_ = bundle = None if target := task_record.task_object: target_ = NamedCoreObject( id=target.pk, type=db_record_type_to_core_type(db_record_type=target.prototype.type), name=target.name ) if action_prototype.type == "adcm": - bundle_root = settings.BASE_DIR / "conf" / "adcm" + bundle = BundleInfo(root=settings.BASE_DIR / "conf" / "adcm", config_dir=Path()) else: - bundle_root = settings.BUNDLE_DIR / action_prototype.bundle.hash / action_prototype.path - - owner = None - if task_record.owner_type and task_record.owner_id: - owner_type = ADCMCoreType(task_record.owner_type) - # object can be deleted at any point, so if it doesn't exist anymore, owner should be None - if core_type_to_model(core_type=owner_type).objects.filter(id=task_record.owner_id).exists(): - owner = CoreObjectDescriptor(id=task_record.owner_id, type=owner_type) + bundle = BundleInfo( + root=settings.BUNDLE_DIR / action_prototype.bundle.hash, config_dir=Path(action_prototype.path) + ) return Task( id=id, target=target_, - owner=owner, - is_upgrade=Upgrade.objects.filter(action=task_record.action).exists(), - name=task_record.action.name, - display_name=task_record.action.display_name, - bundle_root=bundle_root, - venv=task_record.action.venv, + owner=cls._get_task_owner(task_record=task_record), + selector=task_record.selector, + action=TaskActionInfo( + name=task_record.action.name, + display_name=task_record.action.display_name, + venv=task_record.action.venv, + hc_acl=task_record.action.hostcomponentmap, + is_upgrade=Upgrade.objects.filter(action=task_record.action).exists(), + is_host_action=task_record.action.host_action, + ), + bundle=bundle, verbose=task_record.verbose, + config=task_record.config, hostcomponent=HostComponentChanges( - to_set=task_record.hostcomponentmap, + saved=task_record.hostcomponentmap, post_upgrade=task_record.post_upgrade_hc_map, restore_on_fail=task_record.restore_hc_on_fail, ), @@ -137,6 +147,17 @@ def get_task(id: int) -> Task: # noqa: A002 ), ) + @staticmethod + def get_task_mutable_fields(id: int) -> TaskMutableFieldsDTO: # noqa: A002 + task_row = TaskLog.objects.values("hostcomponentmap", "post_upgrade_hc_map", "restore_hc_on_fail").get(id=id) + return TaskMutableFieldsDTO( + hostcomponent=HostComponentChanges( + saved=task_row["hostcomponentmap"], + post_upgrade=task_row["post_upgrade_hc_map"], + restore_on_fail=task_row["restore_hc_on_fail"], + ) + ) + @classmethod def create_task( cls, target: CoreObjectDescriptor, owner: CoreObjectDescriptor, action: ActionInfo, payload: TaskPayloadDTO @@ -186,8 +207,7 @@ def get_job(cls, id: int) -> Job: # noqa: A002 @staticmethod def update_task(id: int, data: TaskUpdateDTO) -> None: # noqa: A002 - # todo probably better to do `exclude_unset` - fields_to_change: dict = data.dict(exclude_none=True) + fields_to_change: dict = data.dict(exclude_unset=True) if "status" in fields_to_change: fields_to_change["status"] = fields_to_change["status"].value @@ -195,8 +215,7 @@ def update_task(id: int, data: TaskUpdateDTO) -> None: # noqa: A002 @staticmethod def update_job(id: int, data: JobUpdateDTO) -> None: # noqa: A002 - # todo probably better to do `exclude_unset` - fields_to_change: dict = data.dict(exclude_none=True) + fields_to_change: dict = data.dict(exclude_unset=True) if "status" in fields_to_change: fields_to_change["status"] = fields_to_change["status"].value @@ -239,10 +258,12 @@ def update_owner_multi_states( @staticmethod def _job_from_job_log(job: JobLog) -> Job: - ansible_tags = job.params.get("ansible_tags") or "" + params = deepcopy(job.params) + ansible_tags = params.pop("ansible_tags", "") or "" if not isinstance(ansible_tags, str): # todo I don't like to fix it here, # but not sure we can validate it now on config.yaml load + # see https://tracker.yandex.ru/ADCM-5325 ansible_tags = "" if isinstance(ansible_tags, (list, tuple)): ansible_tags = ",".join(map(str, ansible_tags)) @@ -250,10 +271,11 @@ def _job_from_job_log(job: JobLog) -> Job: return Job( id=job.id, pid=job.pid, + name=job.name, type=ScriptType(job.script_type), status=ExecutionStatus(job.status), script=job.script, - params=JobParams(ansible_tags=ansible_tags), + params=JobParams(ansible_tags=ansible_tags, **params), on_fail=StateChanges( state=job.state_on_fail, multi_state_set=tuple(job.multi_state_on_fail_set or ()), @@ -325,6 +347,105 @@ def _get_host_related_selector(cls, host_id: HostID, action_owner: PrototypeDesc return query + @classmethod + def _get_task_owner(cls, task_record: TaskLog) -> TaskOwner | None: + if not (task_record.owner_type and task_record.owner_id): + return None + + owner_type = ADCMCoreType(task_record.owner_type) + owner_model = core_type_to_model(core_type=owner_type) + # object can be deleted at any point, so if it doesn't exist anymore, owner should be None + if not owner_model.objects.filter(id=task_record.owner_id).exists(): + return None + + owner_id = task_record.owner_id + + related_cluster_values = ("cluster_id", "cluster__prototype_id", "cluster__name") + related_service_values = ("service_id", "service__prototype_id", "service__prototype__name") + related_hostprovider_values = ("provider_id", "provider__prototype_id", "provider__name") + + match owner_type: + case ADCMCoreType.ADCM | ADCMCoreType.CLUSTER | ADCMCoreType.HOSTPROVIDER: + return TaskOwner( + id=owner_id, + type=owner_type, + **owner_model.objects.values("name", "prototype_id").get(id=owner_id), + related_objects=RelatedObjects(), + ) + case ADCMCoreType.SERVICE: + data = owner_model.objects.values("prototype__name", "prototype_id", *related_cluster_values).get( + id=owner_id + ) + cluster = NamedCoreObjectWithPrototype( + id=data["cluster_id"], + prototype_id=data["cluster__prototype_id"], + type=ADCMCoreType.CLUSTER, + name=data["cluster__name"], + ) + return TaskOwner( + id=owner_id, + type=ADCMCoreType.SERVICE, + prototype_id=data["prototype_id"], + name=data["prototype__name"], + related_objects=RelatedObjects(cluster=cluster), + ) + case ADCMCoreType.COMPONENT: + data = owner_model.objects.values( + "prototype__name", "prototype_id", *related_cluster_values, *related_service_values + ).get(id=owner_id) + cluster = NamedCoreObjectWithPrototype( + id=data["cluster_id"], + prototype_id=data["cluster__prototype_id"], + type=ADCMCoreType.CLUSTER, + name=data["cluster__name"], + ) + service = NamedCoreObjectWithPrototype( + id=data["service_id"], + prototype_id=data["service__prototype_id"], + type=ADCMCoreType.SERVICE, + name=data["service__prototype__name"], + ) + return TaskOwner( + id=owner_id, + type=ADCMCoreType.COMPONENT, + prototype_id=data["prototype_id"], + name=data["prototype__name"], + related_objects=RelatedObjects(cluster=cluster, service=service), + ) + case ADCMCoreType.HOST: + data = owner_model.objects.values( + "prototype_id", + *related_cluster_values, + *related_hostprovider_values, + name=F("fqdn"), + ).get(id=owner_id) + cluster = ( + NamedCoreObjectWithPrototype( + id=data["cluster_id"], + prototype_id=data["cluster__prototype_id"], + type=ADCMCoreType.CLUSTER, + name=data["cluster__name"], + ) + if data["cluster_id"] + else None + ) + hostprovider = NamedCoreObjectWithPrototype( + id=data["provider_id"], + prototype_id=data["provider__prototype_id"], + type=ADCMCoreType.HOSTPROVIDER, + name=data["provider__name"], + ) + return TaskOwner( + id=owner_id, + type=ADCMCoreType.HOST, + prototype_id=data["prototype_id"], + name=data["name"], + related_objects=RelatedObjects(cluster=cluster, hostprovider=hostprovider), + ) + case _: + message = f"Can't detect owner of type {owner_type}" + raise NotImplementedError(message) + class ActionRepoImpl: @staticmethod @@ -338,12 +459,41 @@ def get_action(id: ActionID) -> ActionInfo: # noqa: A002 ), ) - @staticmethod - def get_job_specs(id: ActionID) -> Iterable[JobSpec]: # noqa: A002 + @classmethod + def get_job_specs(cls, id: ActionID) -> Iterable[JobSpec]: # noqa: A002 try: if Action.objects.values_list("type", flat=True).get(id=id) == ActionType.JOB: - return [JobSpec.from_orm(Action.objects.get(id=id))] + return [cls._from_entry_to_spec(cls._qs_with_spec_values(Action.objects.get_queryset()).get(id=id))] except Action.DoesNotExist: return [] - return [JobSpec.from_orm(sub_action) for sub_action in SubAction.objects.filter(action_id=id).order_by("id")] + return [ + cls._from_entry_to_spec(sub_action) + for sub_action in cls._qs_with_spec_values(SubAction.objects.filter(action_id=id)).order_by("id") + ] + + @staticmethod + def _qs_with_spec_values(query: QuerySet) -> QuerySet: + return query.values( + "name", + "display_name", + "script", + "script_type", + "allow_to_terminate", + "state_on_fail", + "multi_state_on_fail_set", + "multi_state_on_fail_unset", + "params", + ) + + @staticmethod + def _from_entry_to_spec(entry: dict) -> JobSpec: + # in db it can be dict, list or anything else actually + source_params = entry.pop("params", {}) or {} + # try to fix if it's not dict here, until + if isinstance(source_params, list) and all(isinstance(entry, dict) for entry in source_params): + source_params = reduce(operator.or_, source_params, {}) + elif not isinstance(source_params, dict): + source_params = {} + + return JobSpec(**entry, params=source_params) diff --git a/python/cm/services/job/run/runners.py b/python/cm/services/job/run/runners.py index dbc6678682..fb7da7f534 100644 --- a/python/cm/services/job/run/runners.py +++ b/python/cm/services/job/run/runners.py @@ -9,16 +9,23 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from operator import itemgetter +from logging import Logger from typing import Any, Protocol import os import signal -import logging from core.job.dto import JobUpdateDTO, TaskUpdateDTO from core.job.runners import ExecutionTarget, RunnerRuntime, TaskRunner from core.job.types import ExecutionStatus, Job, Task -from core.types import ADCMCoreType, CoreObjectDescriptor, NamedCoreObject +from core.types import CoreObjectDescriptor + +from cm.services.job.run._task_finalizers import ( + remove_task_lock, + set_hostcomponent, + set_job_lock, + update_issues, + update_object_maintenance_mode, +) NO_PROCESS_PID = 0 @@ -39,132 +46,12 @@ def reset_objects_in_mm(self) -> Any: ... -def set_job_lock(job_id: int) -> None: - # todo move it to `cm.services.job` somewhere - from cm.models import JobLog - - job = JobLog.objects.select_related("task").get(pk=job_id) - if job.task.lock and job.task.task_object: - job.task.lock.reason = job.cook_reason() - job.task.lock.save(update_fields=["reason"]) - - -def set_hostcomponent(task: Task, logger: logging.Logger): - # todo move it to `cm.services.job` somewhere - from cm.api import save_hc # fixme no way it can be in `cm.api` - from cm.models import ClusterObject, Host, ServiceComponent, TaskLog, get_object_cluster - - # todo no need in task here, just take owner from task - task_ = TaskLog.objects.prefetch_related("task_object").get(id=task.id) - - cluster = get_object_cluster(task_.task_object) - if cluster is None: - logger.error("no cluster in task #%s", task_.pk) - - return - - new_hostcomponent = task.hostcomponent.to_set - hosts = { - entry.pk: entry for entry in Host.objects.filter(id__in=set(map(itemgetter("host_id"), new_hostcomponent))) - } - services = { - entry.pk: entry - for entry in ClusterObject.objects.filter(id__in=set(map(itemgetter("service_id"), new_hostcomponent))) - } - components = { - entry.pk: entry - for entry in ServiceComponent.objects.filter(id__in=set(map(itemgetter("component_id"), new_hostcomponent))) - } - - host_comp_list = [ - (services[entry["service_id"]], hosts[entry["host_id"]], components[entry["component_id"]]) - for entry in new_hostcomponent - ] - - logger.warning("task #%s is failed, restore old hc", task_.pk) - - save_hc(cluster, host_comp_list) - - -def remove_task_lock(task_id: int) -> None: - from cm.issue import unlock_affected_objects - from cm.models import TaskLog - - unlock_affected_objects(TaskLog.objects.get(pk=task_id)) - - -def update_issues(object_: CoreObjectDescriptor): - # todo move it to `cm.services.job` somewhere - from cm.converters import core_type_to_model - from cm.issue import update_hierarchy_issues - - update_hierarchy_issues(obj=core_type_to_model(core_type=object_.type).objects.get(id=object_.id)) - - -def update_object_maintenance_mode(action_name: str, object_: CoreObjectDescriptor): - # todo move it to `cm.services.job` somewhere - from django.conf import settings - - from cm.converters import core_type_to_model - from cm.models import MaintenanceMode - - obj = core_type_to_model(core_type=object_.type).objects.get(id=object_.id) - - if ( - action_name in {settings.ADCM_TURN_ON_MM_ACTION_NAME, settings.ADCM_HOST_TURN_ON_MM_ACTION_NAME} - and obj.maintenance_mode == MaintenanceMode.CHANGING - ): - obj.maintenance_mode = MaintenanceMode.OFF - obj.save() - - if ( - action_name in {settings.ADCM_TURN_OFF_MM_ACTION_NAME, settings.ADCM_HOST_TURN_OFF_MM_ACTION_NAME} - and obj.maintenance_mode == MaintenanceMode.CHANGING - ): - obj.maintenance_mode = MaintenanceMode.ON - obj.save() - - -def audit_job_finish( - owner: NamedCoreObject, display_name: str, is_upgrade: bool, job_result: ExecutionStatus -) -> None: # todo probably shouldn't be here at all - from audit.cases.common import get_or_create_audit_obj - from audit.cef_logger import cef_logger - from audit.models import AuditLog, AuditLogOperationResult, AuditLogOperationType, AuditObjectType - - operation_name = f"{display_name} {'upgrade' if is_upgrade else 'action'} completed" - - if owner.type == ADCMCoreType.HOSTPROVIDER: - obj_type = AuditObjectType.PROVIDER - else: - obj_type = AuditObjectType(owner.type.value) - - audit_object = get_or_create_audit_obj( - object_id=str(owner.id), - object_name=owner.name, - object_type=obj_type, - ) - operation_result = ( - AuditLogOperationResult.SUCCESS if job_result == ExecutionStatus.SUCCESS else AuditLogOperationResult.FAIL - ) - - audit_log = AuditLog.objects.create( - audit_object=audit_object, - operation_name=operation_name, - operation_type=AuditLogOperationType.UPDATE, - operation_result=operation_result, - object_changes={}, - ) - - cef_logger(audit_instance=audit_log, signature_id="Action completion") - - class JobSequenceRunner(TaskRunner): _notifier: EventNotifier _status_server = StatusServerInteractor def __init__( - self, *, notifier: EventNotifier, status_server: StatusServerInteractor, logger: logging.Logger, **kwargs: Any + self, *, notifier: EventNotifier, status_server: StatusServerInteractor, logger: Logger, **kwargs: Any ): super().__init__(**kwargs) @@ -206,7 +93,8 @@ def run(self, task_id: int): last_processed_job = None last_job_result = None for current_job in configured_jobs: - self._prepare_job_environment(target=current_job) + task = self._get_updated_task(task=task) + self._prepare_job_environment(task=task, target=current_job) last_processed_job = current_job.job last_job_result = self._execute_job(target=current_job) @@ -240,7 +128,7 @@ def _configure(self, task_id: int) -> tuple[Task, tuple[ExecutionTarget, ...]]: task = self._repo.get_task(id=task_id) - if not (task.target and task.bundle_root): + if not (task.target and task.bundle): message = "Can't run task with no owner and/or bundle info" raise RuntimeError(message) @@ -266,11 +154,21 @@ def _start(self, task_id: int) -> None: self._runtime.status = ExecutionStatus.RUNNING self._notifier.send_task_status_update_event(task_id=self._runtime.task_id, status=self._runtime.status.value) - def _prepare_job_environment(self, target: ExecutionTarget) -> None: + def _get_updated_task(self, task: Task) -> Task: + """ + Update fields that can be changed during task execution EXCEPT owner object's related info + """ + new_fields = self._repo.get_task_mutable_fields(id=task.id) + if task.hostcomponent == new_fields.hostcomponent: + return task + + return Task(**(task.dict() | {"hostcomponent": new_fields.hostcomponent})) + + def _prepare_job_environment(self, task: Task, target: ExecutionTarget) -> None: (self._settings.adcm.run_dir / str(target.job.id) / "tmp").mkdir(parents=True, exist_ok=True) for prepare_environment in target.environment_builders: - prepare_environment(job=target.job) + prepare_environment(task=task, job=target.job, configuration=self._settings) def _execute_job(self, target: ExecutionTarget) -> ExecutionStatus: target.executor.execute() @@ -284,8 +182,6 @@ def _execute_job(self, target: ExecutionTarget) -> ExecutionStatus: ), ) - # todo add object update based on job state/multi_state update rules - set_job_lock(job_id=target.job.id) result = target.executor.wait_finished().result @@ -301,9 +197,25 @@ def _execute_job(self, target: ExecutionTarget) -> ExecutionStatus: id=target.job.id, data=JobUpdateDTO(status=job_status, finish_date=self._environment.now()) ) + # There a some approaches to implement finalizers: + # 1. "safe" finalizers that doesn't invoke their own errors, fail task on first error + # 2. catch all finalizers' exceptions, write as error, continue task + # 3. catch finalizers' exceptions, write as error, let all finalizers finish, fail task on error + # + # Currently **3rd** one is implemented, + # meaning we'll try to execute all specified finalizers, + # log their exceptions and raise the last exception + exception_to_raise = None for finalizer in target.finalizers: - # todo should we catch any exception here and just log it? - finalizer(job=target.job) + try: + finalizer(job=target.job) + except Exception as err: + exception_to_raise = err + message = "Unhandled exception occurred during after-job finalization" + self._logger.exception(message) + + if exception_to_raise: + raise exception_to_raise return job_status @@ -318,23 +230,29 @@ def _should_proceed(self, last_job_result: ExecutionStatus) -> bool: return last_job_result == ExecutionStatus.ABORTED def _finish(self, task: Task, last_job: Job | None): + from audit.utils import audit_job_finish + task_result = self._runtime.status remove_task_lock(task_id=task.id) audit_job_finish( owner=task.target, - display_name=task.display_name, - is_upgrade=task.is_upgrade, + display_name=task.action.display_name, + is_upgrade=task.action.is_upgrade, job_result=task_result, ) finished_task = self._repo.get_task(id=task.id) if finished_task.owner: - self._update_owner_object(owner=finished_task.owner, finished_task=finished_task, last_job=last_job) + self._update_owner_object( + owner=CoreObjectDescriptor(id=finished_task.owner.id, type=finished_task.owner.type), + finished_task=finished_task, + last_job=last_job, + ) if finished_task.target: - update_object_maintenance_mode(action_name=finished_task.name, object_=finished_task.target) + update_object_maintenance_mode(action_name=finished_task.action.name, object_=finished_task.target) self._repo.update_task(id=task.id, data=TaskUpdateDTO(finish_date=self._environment.now(), status=task_result)) self._notifier.send_task_status_update_event(task_id=self._runtime.task_id, status=task_result) @@ -351,7 +269,7 @@ def _update_owner_object(self, owner: CoreObjectDescriptor, finished_task: Task, if ( self._runtime.status in {ExecutionStatus.FAILED, ExecutionStatus.ABORTED, ExecutionStatus.BROKEN} - and finished_task.hostcomponent.to_set is not None + and finished_task.hostcomponent.saved is not None and finished_task.hostcomponent.restore_on_fail ): set_hostcomponent(task=finished_task, logger=self._logger) @@ -364,7 +282,7 @@ def _update_owner_state(self, task: Task, job: Job, owner: CoreObjectDescriptor) multi_state_unset = task.on_success.multi_state_unset state = task.on_success.state if not state: - self._logger.warning('task for "%s" success state is not set', task.display_name) + self._logger.warning('task for "%s" success state is not set', task.action.display_name) elif self._runtime.status == ExecutionStatus.FAILED: job_on_fail = job.on_fail @@ -373,7 +291,7 @@ def _update_owner_state(self, task: Task, job: Job, owner: CoreObjectDescriptor) multi_state_set = job_on_fail.multi_state_set or task_on_fail.multi_state_set multi_state_unset = job_on_fail.multi_state_unset or task_on_fail.multi_state_unset if not state: - self._logger.warning('task for "%s" fail state is not set', task.display_name) + self._logger.warning('task for "%s" fail state is not set', task.action.display_name) else: if self._runtime.status != ExecutionStatus.ABORTED: @@ -388,7 +306,7 @@ def _update_owner_state(self, task: Task, job: Job, owner: CoreObjectDescriptor) owner=owner, add_multi_states=multi_state_set, remove_multi_states=multi_state_unset ) - if task.is_upgrade: + if task.action.is_upgrade: self._notifier.send_prototype_update_event(object_=owner) else: self._notifier.send_update_event(object_=owner, changes={"state": state}) diff --git a/python/cm/services/job/utils.py b/python/cm/services/job/utils.py deleted file mode 100644 index 3815fcfd26..0000000000 --- a/python/cm/services/job/utils.py +++ /dev/null @@ -1,74 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from dataclasses import dataclass -from pathlib import Path - -from core.types import ObjectID -from django.conf import settings -from django.utils.functional import cached_property -from pydantic import Json - -from cm.models import ( - Action, - JobLog, - TaskLog, -) -from cm.services.types import ADCMEntityType - - -@dataclass -class JobScope: - job_id: ObjectID - object: ADCMEntityType - - @cached_property - def task(self) -> TaskLog: - return TaskLog.objects.select_related("action", "action__prototype", "action__prototype__bundle").get( - pk=self.job.task_id - ) - - @cached_property - def job(self) -> JobLog: - return JobLog.objects.get(pk=self.job_id) - - @cached_property - def hosts(self) -> Json: - return self.task.hosts or None - - @cached_property - def config(self) -> Json: - return self.task.config or None - - @cached_property - def action(self) -> Action | None: - return self.task.action - - -def get_script_path(action: Action, job: JobLog | None) -> str: - # fixme remove `if` here. - # currently left for "backward compatibility", but actually script should always be set for job - # and job should always be passed in here - script = job.script if job else action.script - - relative_path_part = "./" - if script.startswith(relative_path_part): - script = Path(action.prototype.path, script.lstrip(relative_path_part)) - - return str(Path(get_bundle_root(action=action), action.prototype.bundle.hash, script)) - - -def get_bundle_root(action: Action) -> str: - if action.prototype.type == "adcm": - return str(Path(settings.BASE_DIR, "conf")) - - return str(settings.BUNDLE_DIR) diff --git a/python/cm/tests/test_hc.py b/python/cm/tests/test_hc.py index 1ace21cd35..e02967eb71 100644 --- a/python/cm/tests/test_hc.py +++ b/python/cm/tests/test_hc.py @@ -20,8 +20,8 @@ from cm.api import add_host_to_cluster, save_hc from cm.errors import AdcmEx -from cm.job import check_hostcomponentmap from cm.models import Action, Bundle, ClusterObject, Host, Prototype, ServiceComponent +from cm.services.job.checks import check_hostcomponentmap from cm.tests.test_upgrade import ( cook_cluster, cook_cluster_bundle, diff --git a/python/cm/tests/test_inventory/base.py b/python/cm/tests/test_inventory/base.py index b322bc5083..d84038ee03 100644 --- a/python/cm/tests/test_inventory/base.py +++ b/python/cm/tests/test_inventory/base.py @@ -16,11 +16,13 @@ from adcm.tests.base import BaseTestCase, BusinessLogicMixin from api_v2.config.utils import convert_adcm_meta_to_attr, convert_attr_to_adcm_meta +from core.types import CoreObjectDescriptor from django.contrib.contenttypes.models import ContentType from jinja2 import Template from cm.adcm_config.ansible import ansible_decrypt from cm.api import add_hc, update_obj_config +from cm.converters import model_name_to_core_type from cm.models import ( Action, ADCMEntity, @@ -134,7 +136,10 @@ def check_data_by_template(self, data: Mapping[str, dict], templates_data: Templ def assert_inventory( self, obj: ADCMEntity, action: Action, expected_topology: dict, expected_data: dict, delta: Delta | None = None ) -> None: - actual_inventory = decrypt_secrets(source=get_inventory_data(obj=obj, action=action, delta=delta)) + target = CoreObjectDescriptor(id=obj.id, type=model_name_to_core_type(obj.__class__.__name__)) + actual_inventory = decrypt_secrets( + source=get_inventory_data(target=target, is_host_action=action.host_action, delta=delta) + ) self.check_hosts_topology(data=actual_inventory["all"]["children"], expected=expected_topology) self.check_data_by_template(data=actual_inventory, templates_data=expected_data) diff --git a/python/cm/tests/test_inventory/test_action_config.py b/python/cm/tests/test_inventory/test_action_config.py index cb4952eb20..dfae546467 100644 --- a/python/cm/tests/test_inventory/test_action_config.py +++ b/python/cm/tests/test_inventory/test_action_config.py @@ -13,16 +13,20 @@ from unittest.mock import patch from core.job.dto import TaskPayloadDTO +from core.job.runners import ADCMSettings, AnsibleSettings, ExternalSettings, IntegrationsSettings from core.types import ADCMCoreType, CoreObjectDescriptor from django.conf import settings from cm.adcm_config.ansible import ansible_decrypt from cm.converters import model_name_to_core_type +from cm.models import Action, ServiceComponent +from cm.services.job.action import ActionRunPayload, run_action from cm.job import ActionRunPayload, run_action from cm.models import Action, JobLog, ServiceComponent, SubAction, TaskLog from cm.services.job.config import get_job_config from cm.services.job.prepare import prepare_task_for_action -from cm.services.job.utils import JobScope +from cm.services.job.run._target_factories import prepare_ansible_job_config +from cm.services.job.run.repo import JobRepoImpl from cm.tests.test_inventory.base import BaseInventoryTestCase @@ -96,6 +100,12 @@ def setUp(self) -> None: "component_type_id": self.component.prototype_id, } + self.configuration = ExternalSettings( + adcm=ADCMSettings(code_root_dir=settings.CODE_DIR, run_dir=settings.RUN_DIR, log_dir=settings.LOG_DIR), + ansible=AnsibleSettings(ansible_secret_script=settings.CODE_DIR / "ansible_secret.py"), + integrations=IntegrationsSettings(status_server_token=settings.STATUS_SECRET_KEY), + ) + def test_action_config(self) -> None: # Thou action has a defined config # `prepare_job_config` itself doesn't check input config sanity, @@ -112,23 +122,20 @@ def test_action_config(self) -> None: obj_ = CoreObjectDescriptor( id=object_.pk, type=model_name_to_core_type(model_name=object_.__class__.__name__.lower()) ) - task = TaskLog.objects.get( - id=prepare_task_for_action( - target=obj_, - owner=obj_, - action=action.pk, - payload=TaskPayloadDTO(conf=config), - ).id + task = prepare_task_for_action( + target=obj_, + owner=obj_, + action=action.pk, + payload=TaskPayloadDTO(conf=config), ) - - job = JobLog.objects.filter(task=task).first() + job, *_ = JobRepoImpl.get_task_jobs(task.id) with self.subTest(f"Own Action for {object_.__class__.__name__}"): expected_data = self.render_json_template( file=self.templates_dir / "action_configs" / f"{type_name}.json.j2", - context={**self.context, "job_id": job.pk}, + context={**self.context, "job_id": job.id}, ) - job_config = get_job_config(job_scope=JobScope(job_id=job.pk, object=object_)) + job_config = prepare_ansible_job_config(task=task, job=job, configuration=self.configuration) self.assertDictEqual(job_config, expected_data) @@ -139,47 +146,46 @@ def test_action_config(self) -> None: ): action = Action.objects.filter(prototype=object_.prototype, name="with_config_on_host").first() target = CoreObjectDescriptor(id=self.host_1.pk, type=ADCMCoreType.HOST) - task = TaskLog.objects.get( - id=prepare_task_for_action( - target=target, - owner=CoreObjectDescriptor( - id=object_.pk, type=model_name_to_core_type(object_.__class__.__name__.lower()) - ), - action=action.pk, - payload=TaskPayloadDTO(verbose=True, conf=config), - ).id + + task = prepare_task_for_action( + target=target, + owner=CoreObjectDescriptor( + id=object_.pk, type=model_name_to_core_type(object_.__class__.__name__.lower()) + ), + action=action.pk, + payload=TaskPayloadDTO(verbose=True, conf=config), ) - job = JobLog.objects.filter(task=task).first() + job, *_ = JobRepoImpl.get_task_jobs(task.id) with self.subTest(f"Host Action for {object_.__class__.__name__}"): expected_data = self.render_json_template( file=self.templates_dir / "action_configs" / f"{type_name}_on_host.json.j2", - context={**self.context, "job_id": job.pk}, + context={**self.context, "job_id": job.id}, ) - job_config = get_job_config(job_scope=JobScope(job_id=job.pk, object=self.host_1)) + job_config = prepare_ansible_job_config(task=task, job=job, configuration=self.configuration) self.assertDictEqual(job_config, expected_data) def test_action_config_with_secrets_bug_adcm_5305(self): """ Actually bug is about `run_action`, because it prepares `config` for task, - but it was caught within `get_job_config` generation, so checked here + but it was caught within `prepare_ansible_job_config` generation, so checked here """ raw_value = "12345ddd" action = Action.objects.filter(prototype=self.service.prototype, name="name_and_pass").first() - with patch("cm.job.run_task"): + with patch("cm.services.job.action.run_task"): task = run_action( action=action, obj=self.service, payload=ActionRunPayload(conf={"rolename": "test_user", "rolepass": raw_value}), - hosts=[], ) self.assertIn("__ansible_vault", task.config["rolepass"]) self.assertEqual(ansible_decrypt(task.config["rolepass"]["__ansible_vault"]), raw_value) - job = JobLog.objects.filter(task=task).first() - job_config = get_job_config(job_scope=JobScope(job_id=job.pk, object=self.service)) + task = JobRepoImpl.get_task(id=task.id) + job, *_ = JobRepoImpl.get_task_jobs(task.id) + job_config = prepare_ansible_job_config(task=task, job=job, configuration=self.configuration) self.assertIn("__ansible_vault", job_config["job"]["config"]["rolepass"]) self.assertEqual(ansible_decrypt(job_config["job"]["config"]["rolepass"]["__ansible_vault"]), raw_value) diff --git a/python/cm/tests/test_inventory/test_group_config.py b/python/cm/tests/test_inventory/test_group_config.py index 5cada67d62..ac254a41ff 100644 --- a/python/cm/tests/test_inventory/test_group_config.py +++ b/python/cm/tests/test_inventory/test_group_config.py @@ -11,7 +11,9 @@ # limitations under the License. from api_v2.service.utils import bulk_add_services_to_cluster +from core.types import CoreObjectDescriptor +from cm.converters import model_name_to_core_type from cm.models import ( Action, ObjectType, @@ -181,7 +183,8 @@ def test_group_config_in_inventory(self) -> None: ): with self.subTest(object_.__class__.__name__): action = Action.objects.filter(prototype=object_.prototype).first() - actual_inventory = decrypt_secrets(get_inventory_data(obj=object_, action=action)) + target = CoreObjectDescriptor(id=object_.id, type=model_name_to_core_type(object_.__class__.__name__)) + actual_inventory = decrypt_secrets(get_inventory_data(target=target, is_host_action=action.host_action)) self.check_hosts_topology(actual_inventory["all"]["children"], expected_topology) self.assertDictEqual(actual_inventory["all"]["vars"], expected_parts["vars"]) for group in actual_inventory["all"]["children"].values(): diff --git a/python/cm/tests/test_inventory/test_imports.py b/python/cm/tests/test_inventory/test_imports.py index c76e3143c4..61a4bb5a84 100644 --- a/python/cm/tests/test_inventory/test_imports.py +++ b/python/cm/tests/test_inventory/test_imports.py @@ -11,7 +11,10 @@ # limitations under the License. from typing import Iterable +from core.types import ADCMCoreType, CoreObjectDescriptor + from cm.api import DataForMultiBind, multi_bind +from cm.converters import model_name_to_core_type from cm.models import ( Action, ADCMModel, @@ -163,7 +166,8 @@ def test_hostprovider_objects_success(self) -> None: ): with self.subTest(object_.__class__.__name__): action = Action.objects.filter(prototype=object_.prototype, name="dummy").first() - actual_inventory = decrypt_secrets(get_inventory_data(obj=object_, action=action)) + target = CoreObjectDescriptor(id=object_.id, type=model_name_to_core_type(object_.__class__.__name__)) + actual_inventory = decrypt_secrets(get_inventory_data(target=target, is_host_action=action.host_action)) expected_inventory = self.render_json_template( file=self.templates_dir / "configs_and_imports" / template, context=self.context ) @@ -182,7 +186,10 @@ def test_cluster_objects_no_import_success(self) -> None: for object_ in (self.cluster, self.service, self.component): with self.subTest(object_.__class__.__name__): action = Action.objects.filter(prototype=object_.prototype, name="dummy").first() - actual_vars = decrypt_secrets(get_inventory_data(obj=object_, action=action)["all"]["vars"]) + target = CoreObjectDescriptor(id=object_.id, type=model_name_to_core_type(object_.__class__.__name__)) + actual_vars = decrypt_secrets( + get_inventory_data(target=target, is_host_action=action.host_action)["all"]["vars"] + ) self.assertDictEqual(actual_vars, expected_vars) def test_cluster_objects_single_import_success(self) -> None: @@ -214,7 +221,10 @@ def test_cluster_objects_single_import_success(self) -> None: for object_ in (self.cluster, self.service, self.component): with self.subTest(object_.__class__.__name__): action = Action.objects.filter(prototype=object_.prototype, name="dummy").first() - actual_vars = decrypt_secrets(get_inventory_data(obj=object_, action=action)["all"]["vars"]) + target = CoreObjectDescriptor(id=object_.id, type=model_name_to_core_type(object_.__class__.__name__)) + actual_vars = decrypt_secrets( + get_inventory_data(target=target, is_host_action=action.host_action)["all"]["vars"] + ) self.assertDictEqual(actual_vars, expected_vars) def test_cluster_objects_multi_import_success(self) -> None: @@ -256,7 +266,10 @@ def test_cluster_objects_multi_import_success(self) -> None: for object_ in (self.cluster, self.service, self.component): with self.subTest(object_.__class__.__name__): action = Action.objects.filter(prototype=object_.prototype, name="dummy").first() - actual_vars = decrypt_secrets(get_inventory_data(obj=object_, action=action)["all"]["vars"]) + target = CoreObjectDescriptor(id=object_.id, type=model_name_to_core_type(object_.__class__.__name__)) + actual_vars = decrypt_secrets( + get_inventory_data(target=target, is_host_action=action.host_action)["all"]["vars"] + ) self.assertDictEqual(actual_vars, expected_vars) def test_imports_have_default_no_import_success(self) -> None: @@ -362,7 +375,8 @@ def test_group_config_effect_on_import_with_default(self) -> None: self.bind_objects((self.service_with_defaults, [self.export_cluster_1])) action = Action.objects.filter(prototype=self.service_with_defaults.prototype, name="dummy").first() - result = decrypt_secrets(get_inventory_data(obj=self.service_with_defaults, action=action))["all"] + target = CoreObjectDescriptor(id=self.service_with_defaults.id, type=ADCMCoreType.SERVICE) + result = decrypt_secrets(get_inventory_data(target=target, is_host_action=action.host_action))["all"] expected_vars_imports = { "very_complex": { "just_integer": 4, diff --git a/python/cm/tests/test_inventory/test_inventory.py b/python/cm/tests/test_inventory/test_inventory.py index 14b6589223..c3873af0cf 100644 --- a/python/cm/tests/test_inventory/test_inventory.py +++ b/python/cm/tests/test_inventory/test_inventory.py @@ -11,18 +11,15 @@ # limitations under the License. -from json import loads from pathlib import Path +from unittest.mock import patch -from adcm.tests.base import APPLICATION_JSON, BaseTestCase -from django.conf import settings -from django.urls import reverse +from adcm.tests.base import BaseTestCase +from core.types import CoreObjectDescriptor from init_db import init as init_adcm -from rest_framework.response import Response -from rest_framework.status import HTTP_201_CREATED from cm.api import add_hc, add_service_to_cluster, update_obj_config -from cm.job import re_prepare_job +from cm.converters import model_name_to_core_type from cm.models import ( Action, ClusterObject, @@ -34,10 +31,10 @@ ServiceComponent, TaskLog, ) +from cm.services.job.action import ActionRunPayload, ObjectWithAction, run_action from cm.services.job.inventory import get_inventory_data from cm.services.job.inventory._constants import MAINTENANCE_MODE_GROUP_SUFFIX from cm.services.job.types import HcAclAction -from cm.services.job.utils import JobScope from cm.tests.utils import ( gen_bundle, gen_cluster, @@ -142,8 +139,9 @@ def test_prepare_job_inventory(self): ] for obj, inv in data: + target = CoreObjectDescriptor(id=obj.id, type=model_name_to_core_type(obj.__class__.__name__)) with self.subTest(obj=obj, inv=inv): - actual_data = get_inventory_data(obj=obj, action=action) + actual_data = get_inventory_data(target=target, is_host_action=action.host_action) self.assertDictEqual(actual_data, inv) @@ -271,25 +269,18 @@ def _get_hc_request_data(*new_hc_items: dict) -> list[dict]: return hc_request_data - def get_inventory_data(self, data: dict, kwargs: dict) -> dict: + def get_children_from_inventory(self, action: Action, object_: ObjectWithAction, payload: ActionRunPayload) -> dict: + from cm.services.job.run._target_factories import prepare_ansible_inventory + from cm.services.job.run.repo import JobRepoImpl + self.assertEqual(TaskLog.objects.count(), 0) self.assertEqual(JobLog.objects.count(), 0) - response: Response = self.client.post( - path=reverse(viewname="v1:run-task", kwargs=kwargs), - data=data, - content_type=APPLICATION_JSON, - ) - self.assertEqual(response.status_code, HTTP_201_CREATED) - - job = JobLog.objects.last() + with patch("cm.services.job.action.run_task"): + task = JobRepoImpl.get_task(run_action(action=action, obj=object_, payload=payload).id) - (settings.RUN_DIR / str(job.pk)).mkdir(exist_ok=True, parents=True) - re_prepare_job(job_scope=JobScope(job_id=job.pk, object=job.task.task_object)) - - inventory_file = settings.RUN_DIR / str(job.pk) / "inventory.json" - with Path(inventory_file).open(encoding=settings.ENCODING_UTF_8) as f: - return loads(s=f.read())["all"]["children"] + inventory = prepare_ansible_inventory(task=task) + return inventory["all"]["children"] def test_groups_remove_host_not_in_mm_success(self): self.host_hc_acl_3.maintenance_mode = MaintenanceMode.ON @@ -298,13 +289,10 @@ def test_groups_remove_host_not_in_mm_success(self): # remove: hc_c1_h2 hc_request_data = self._get_hc_request_data(self.hc_c1_h1, self.hc_c1_h3, self.hc_c2_h1, self.hc_c2_h2) - inventory_data = self.get_inventory_data( - data={"hc": hc_request_data, "verbose": False}, - kwargs={ - "cluster_id": self.cluster_hc_acl.pk, - "object_type": "cluster", - "action_id": self.action_hc_acl.pk, - }, + inventory_data = self.get_children_from_inventory( + action=self.action_hc_acl, + object_=self.cluster_hc_acl, + payload=ActionRunPayload(hostcomponent=hc_request_data, verbose=False), ) target_key_remove = ( @@ -344,13 +332,10 @@ def test_groups_remove_host_in_mm_success(self): # remove: hc_c1_h3 hc_request_data = self._get_hc_request_data(self.hc_c1_h1, self.hc_c1_h2, self.hc_c2_h1, self.hc_c2_h2) - inventory_data = self.get_inventory_data( - data={"hc": hc_request_data, "verbose": False}, - kwargs={ - "cluster_id": self.cluster_hc_acl.pk, - "object_type": "cluster", - "action_id": self.action_hc_acl.pk, - }, + inventory_data = self.get_children_from_inventory( + action=self.action_hc_acl, + object_=self.cluster_hc_acl, + payload=ActionRunPayload(hostcomponent=hc_request_data, verbose=False), ) target_key_remove = ( @@ -390,13 +375,10 @@ def test_vars_in_mm_group(self): attr={"group_keys": {"some_string": True, "float": False}}, ) - inventory_data = self.get_inventory_data( - data={"verbose": False}, - kwargs={ - "cluster_id": self.cluster_target_group.pk, - "object_type": "cluster", - "action_id": Action.objects.get(name="not_host_action").id, - }, + inventory_data = self.get_children_from_inventory( + action=Action.objects.get(name="not_host_action"), + object_=self.cluster_target_group, + payload=ActionRunPayload(verbose=False), ) self.assertDictEqual( @@ -422,14 +404,8 @@ def test_host_in_target_group_hostaction_on_host_in_mm_success(self): self.host_target_group_1.maintenance_mode = MaintenanceMode.ON self.host_target_group_1.save() - target_hosts_data = self.get_inventory_data( - data={"verbose": False}, - kwargs={ - "cluster_id": self.cluster_target_group.pk, - "host_id": self.host_target_group_1.pk, - "object_type": "host", - "action_id": self.action_target_group.pk, - }, + target_hosts_data = self.get_children_from_inventory( + action=self.action_target_group, object_=self.host_target_group_1, payload=ActionRunPayload(verbose=False) )["target"]["hosts"] self.assertIn(self.host_target_group_1.fqdn, target_hosts_data) @@ -438,14 +414,8 @@ def test_host_in_target_group_hostaction_on_host_not_in_mm_success(self): self.host_target_group_2.maintenance_mode = MaintenanceMode.OFF self.host_target_group_2.save() - target_hosts_data = self.get_inventory_data( - data={"verbose": False}, - kwargs={ - "cluster_id": self.cluster_target_group.pk, - "host_id": self.host_target_group_2.pk, - "object_type": "host", - "action_id": self.action_target_group.pk, - }, + target_hosts_data = self.get_children_from_inventory( + action=self.action_target_group, object_=self.host_target_group_2, payload=ActionRunPayload(verbose=False) )["target"]["hosts"] self.assertIn(self.host_target_group_2.fqdn, target_hosts_data) diff --git a/python/cm/tests/test_job.py b/python/cm/tests/test_job.py index 1bb166d494..9560cfa0a0 100644 --- a/python/cm/tests/test_job.py +++ b/python/cm/tests/test_job.py @@ -12,11 +12,10 @@ from pathlib import Path from signal import SIGTERM -from unittest.mock import Mock, mock_open, patch +from unittest.mock import patch from urllib.parse import urljoin from adcm.tests.base import APPLICATION_JSON, BaseTestCase -from core.types import ADCMCoreType, CoreObjectDescriptor, PrototypeDescriptor from django.conf import settings from django.urls import reverse from django.utils import timezone @@ -24,47 +23,23 @@ from rest_framework.response import Response from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_409_CONFLICT -from cm.api import add_cluster, add_service_to_cluster from cm.issue import lock_affected_objects -from cm.job import ( - check_cluster, - check_service_task, - get_state, - prepare_job, - re_prepare_job, - restore_hc, - set_action_state, - set_job_start_status, -) from cm.models import ( - ADCM, Action, Bundle, - Cluster, - ClusterObject, - Host, - HostProvider, JobLog, JobStatus, Prototype, - ServiceComponent, - SubAction, TaskLog, ) -from cm.services.job.config import ( - get_context, - get_job_config, -) -from cm.services.job.run.repo import JobRepoImpl -from cm.services.job.utils import JobScope, get_bundle_root, get_script_path -from cm.tests.utils import ( - gen_action, - gen_bundle, - gen_cluster, - gen_job_log, - gen_prototype, - gen_task_log, -) +from cm.tests.utils import gen_cluster + + +def get_bundle_root(action: Action) -> str: + if action.prototype.type == "adcm": + return str(Path(settings.BASE_DIR, "conf")) + + return str(settings.BUNDLE_DIR) class TestJob(BaseTestCase): @@ -148,7 +123,15 @@ def test_set_job_status(self): status = JobStatus.RUNNING pid = 10 - set_job_start_status(job_id=job.id, pid=pid) + job = JobLog.objects.get(id=job.id) + job.status = JobStatus.RUNNING + job.start_date = timezone.now() + job.pid = pid + job.save(update_fields=["status", "start_date", "pid"]) + + if job.task.lock and job.task.task_object: + job.task.lock.reason = job.cook_reason() + job.task.lock.save(update_fields=["reason"]) job = JobLog.objects.get(id=job.id) @@ -156,207 +139,6 @@ def test_set_job_status(self): self.assertEqual(job.pid, pid) self.assertEqual(task.lock.reason["placeholder"]["job"]["name"], action.display_name) - def test_get_state_single_job(self): - bundle = gen_bundle() - cluster_proto = gen_prototype(bundle, "cluster") - cluster = gen_cluster(prototype=cluster_proto) - action = gen_action(prototype=cluster_proto) - action.state_on_success = "success" - action.state_on_fail = "fail" - action.multi_state_on_success_set = ["success"] - action.multi_state_on_success_unset = ["success unset"] - action.multi_state_on_fail_set = ["fail"] - action.multi_state_on_fail_unset = ["fail unset"] - action.save() - task = gen_task_log(cluster, action) - job = gen_job_log(task) - - # status: expected state, expected multi_state set, expected multi_state unset - test_data = [ - [JobStatus.SUCCESS, "success", ["success"], ["success unset"]], - [JobStatus.FAILED, "fail", ["fail"], ["fail unset"]], - [JobStatus.ABORTED, None, [], []], - ] - for status, exp_state, exp_m_state_set, exp_m_state_unset in test_data: - state, m_state_set, m_state_unset = get_state(action, job, status) - - self.assertEqual(state, exp_state) - self.assertListEqual(m_state_set, exp_m_state_set) - self.assertListEqual(m_state_unset, exp_m_state_unset) - - def test_get_state_multi_job(self): - bundle = gen_bundle() - cluster_proto = gen_prototype(bundle, "cluster") - cluster = gen_cluster(prototype=cluster_proto) - action = gen_action(prototype=cluster_proto) - action.state_on_success = "success" - action.state_on_fail = "fail" - action.multi_state_on_success_set = ["success"] - action.multi_state_on_success_unset = ["success unset"] - action.multi_state_on_fail_set = ["fail"] - action.multi_state_on_fail_unset = ["fail unset"] - action.save() - task = gen_task_log(cluster, action) - job = gen_job_log(task) - job.state_on_fail = "sub_action fail" - job.save() - - # status: expected state, expected multi_state set, expected multi_state unset - test_data = [ - [JobStatus.SUCCESS, "success", ["success"], ["success unset"]], - [JobStatus.FAILED, "sub_action fail", ["fail"], ["fail unset"]], - [JobStatus.ABORTED, None, [], []], - ] - for status, exp_state, exp_m_state_set, exp_m_state_unset in test_data: - state, m_state_set, m_state_unset = get_state(action, job, status) - - self.assertEqual(state, exp_state) - self.assertListEqual(m_state_set, exp_m_state_set) - self.assertListEqual(m_state_unset, exp_m_state_unset) - - def test_set_action_state(self): - bundle = Bundle.objects.create() - prototype = Prototype.objects.create(bundle=bundle) - cluster = Cluster.objects.create(prototype=prototype) - cluster_object = ClusterObject.objects.create(prototype=prototype, cluster=cluster) - host = Host.objects.create(prototype=prototype) - host_provider = HostProvider.objects.create(prototype=prototype) - adcm = ADCM.objects.create(prototype=prototype) - action = Action.objects.create(prototype=prototype) - task = TaskLog.objects.create( - action=action, - object_id=1, - start_date=timezone.now(), - finish_date=timezone.now(), - ) - to_set = "to set" - to_unset = "to unset" - for obj in (adcm, cluster, cluster_object, host_provider, host): - obj.set_multi_state(to_unset) - - data = [ - (cluster_object, "running", to_set, to_unset), - (cluster, "removed", to_set, to_unset), - (host, None, to_set, to_unset), - (host_provider, "stopped", to_set, to_unset), - (adcm, "initiated", to_set, to_unset), - ] - - for obj, state, ms_to_set, ms_to_unset in data: - with self.subTest(obj=obj, state=state): - set_action_state(action, task, obj, state, [ms_to_set], [ms_to_unset]) - - self.assertEqual(obj.state, state or "created") - self.assertIn(to_set, obj.multi_state) - self.assertNotIn(to_unset, obj.multi_state) - - @patch("cm.job.save_hc") - def test_restore_hc(self, mock_save_hc): - bundle = Bundle.objects.create() - prototype = Prototype.objects.create(bundle=bundle) - cluster = Cluster.objects.create(prototype=prototype) - cluster_object = ClusterObject.objects.create(prototype=prototype, cluster=cluster) - host = Host.objects.create(prototype=prototype, cluster=cluster) - component = Prototype.objects.create(parent=prototype, type="component", bundle=bundle) - service_component = ServiceComponent.objects.create( - cluster=cluster, - service=cluster_object, - prototype=component, - ) - hostcomponentmap = [ - { - "host_id": host.id, - "service_id": cluster_object.id, - "component_id": service_component.id, - }, - ] - action = Action.objects.create(prototype=prototype, hostcomponentmap=hostcomponentmap) - task = TaskLog.objects.create( - action=action, - task_object=cluster, - start_date=timezone.now(), - finish_date=timezone.now(), - selector={"cluster": cluster.id}, - hostcomponentmap=hostcomponentmap, - ) - - restore_hc(task, action, JobStatus.FAILED) - mock_save_hc.assert_called_once_with(cluster, [(cluster_object, host, service_component)]) - - @patch("cm.job.raise_adcm_ex") - def test_check_service_task(self, mock_err): - bundle = Bundle.objects.create() - prototype = Prototype.objects.create(bundle=bundle) - cluster = Cluster.objects.create(prototype=prototype) - cluster_object = ClusterObject.objects.create(prototype=prototype, cluster=cluster) - action = Action.objects.create(prototype=prototype) - - service = check_service_task(cluster.id, action) - - self.assertEqual(cluster_object, service) - self.assertEqual(mock_err.call_count, 0) - - @patch("cm.job.raise_adcm_ex") - def test_check_cluster(self, mock_err): - bundle = Bundle.objects.create() - prototype = Prototype.objects.create(bundle=bundle) - cluster = Cluster.objects.create(prototype=prototype) - - test_cluster = check_cluster(cluster.id) - - self.assertEqual(cluster, test_cluster) - self.assertEqual(mock_err.call_count, 0) - - @patch("cm.job.prepare_ansible_config") - @patch("cm.job.get_job_config") - @patch("cm.job.get_inventory_data") - def test_prepare_job(self, mock_get_inventory_data, mock_get_job_config, mock_prepare_ansible_config): - bundle = Bundle.objects.create() - prototype = Prototype.objects.create(bundle=bundle) - cluster = Cluster.objects.create(prototype=prototype) - action = Action.objects.create(prototype=prototype) - task = TaskLog.objects.create( - task_object=cluster, - action=action, - start_date=timezone.now(), - finish_date=timezone.now(), - ) - job = JobLog.objects.create(name=action.name, start_date=timezone.now(), finish_date=timezone.now(), task=task) - job_scope = JobScope(job_id=job.pk, object=cluster) - - mocked_open = mock_open() - with patch.object(Path, "open", mocked_open), patch("cm.job.json.dump"): - prepare_job(job_scope=job_scope, delta={}) - - mock_get_inventory_data.assert_called_once_with(obj=cluster, action=action, delta={}) - mock_get_job_config.assert_called_once_with(job_scope=job_scope) - mock_prepare_ansible_config.assert_called_once_with(job_id=job.id, action=action) - - def test_prepare_context(self): - bundle = Bundle.objects.create() - proto1 = Prototype.objects.create(bundle=bundle, type="cluster") - action1 = Action.objects.create(prototype=proto1) - add_cluster(proto1, "Garbage") - cluster = add_cluster(proto1, "Ontario") - selector = JobRepoImpl._get_selector_for_core_object( - target=CoreObjectDescriptor(id=cluster.pk, type=ADCMCoreType.CLUSTER), - owner=PrototypeDescriptor(id=action1.prototype_id, type=ADCMCoreType(action1.prototype_type)), - ) - context = get_context(action=action1, object_type=cluster.prototype.type, selector=selector) - - self.assertDictEqual(context, {"type": "cluster", "cluster_id": cluster.id}) - - proto2 = Prototype.objects.create(bundle=bundle, type="service") - action2 = Action.objects.create(prototype=proto2) - service = add_service_to_cluster(cluster, proto2) - selector = JobRepoImpl._get_selector_for_core_object( - target=CoreObjectDescriptor(id=service.pk, type=ADCMCoreType.SERVICE), - owner=PrototypeDescriptor(id=action2.prototype_id, type=ADCMCoreType(action2.prototype_type)), - ) - context = get_context(action=action2, object_type=service.prototype.type, selector=selector) - - self.assertDictEqual(context, {"type": "service", "service_id": service.id, "cluster_id": cluster.id}) - def test_get_bundle_root(self): bundle = Bundle.objects.create() prototype = Prototype.objects.create(bundle=bundle) @@ -372,224 +154,6 @@ def test_get_bundle_root(self): self.assertEqual(path, test_path) - @patch("cm.services.job.utils.get_bundle_root") - def test_cook_script(self, mock_get_bundle_root): - bundle = Bundle.objects.create(hash="6525d392dc9d1fb3273fb4244e393672579d75f3") - prototype = Prototype.objects.create(bundle=bundle) - action = Action.objects.create(prototype=prototype) - sub_action = SubAction.objects.create(action=action, script="ansible/sleep.yaml") - mock_get_bundle_root.return_value = str(settings.BUNDLE_DIR) - - data = [ - ( - sub_action, - "main.yaml", - str(Path(settings.BUNDLE_DIR, action.prototype.bundle.hash, "ansible/sleep.yaml")), - ), - ( - None, - "main.yaml", - str(Path(settings.BUNDLE_DIR, action.prototype.bundle.hash, "main.yaml")), - ), - ( - None, - "./main.yaml", - str(Path(settings.BUNDLE_DIR, action.prototype.bundle.hash, "main.yaml")), - ), - ] - - for data_sub_action, script, test_path in data: - with self.subTest(sub_action=sub_action, script=script): - action.script = script - action.save() - - path = get_script_path(action, data_sub_action) - - self.assertEqual(path, test_path) - mock_get_bundle_root.assert_called_once_with(action=action) - mock_get_bundle_root.reset_mock() - - @patch("cm.services.job.config.get_script_path") - @patch("cm.services.job.config.get_bundle_root") - @patch("cm.services.job.config.get_context") - @patch("cm.services.job.inventory._config.get_objects_configurations") - @patch("cm.services.job.config.get_adcm_configuration") - def test_prepare_job_config( - self, - mock_get_adcm_configuration, - mock_get_objects_configurations, - mock_get_context, - mock_get_bundle_root, - mock_get_script_path, - ): - bundle = Bundle.objects.create() - proto1 = Prototype.objects.create(bundle=bundle, type="cluster") - cluster = Cluster.objects.create(prototype=proto1) - proto2 = Prototype.objects.create(bundle=bundle, type="service", name="Hive") - service = add_service_to_cluster(cluster, proto2) - cluster_action = Action.objects.create(prototype=proto1) - service_action = Action.objects.create(prototype=proto2) - - mock_get_context.return_value = {"type": "cluster", "cluster_id": 1} - mock_get_bundle_root.return_value = str(settings.BUNDLE_DIR) - mock_get_script_path.return_value = str( - Path(settings.BUNDLE_DIR, cluster_action.prototype.bundle.hash, cluster_action.script), - ) - - proto4 = Prototype.objects.create(bundle=bundle, type="provider") - provider_action = Action.objects.create(prototype=proto4) - provider = HostProvider.objects.create(prototype=proto4, name="hostprovider") - proto5 = Prototype.objects.create(bundle=bundle, type="host") - host_action = Action.objects.create(prototype=proto5) - host = Host.objects.create(prototype=proto5, provider=provider) - - mock_get_objects_configurations.return_value = { - (ADCMCoreType.CLUSTER, cluster.pk): {}, - (ADCMCoreType.SERVICE, service.pk): {}, - (ADCMCoreType.HOST, host.pk): {}, - (ADCMCoreType.HOSTPROVIDER, provider.pk): {}, - } - mock_get_adcm_configuration.return_value = {} - - data = [ - ("service", service, service_action), - ("cluster", cluster, cluster_action), - ("host", host, host_action), - ("provider", provider, provider_action), - ] - - for prototype_type, obj, action in data: - task = TaskLog.objects.create( - task_object=obj, - action=action, - start_date=timezone.now(), - finish_date=timezone.now(), - config="test", - selector={}, - ) - job = JobLog.objects.create( - name=action.name, start_date=timezone.now(), finish_date=timezone.now(), task=task - ) - - with self.subTest(prototype_type=prototype_type, obj=obj): - actual_job_config = get_job_config(job_scope=JobScope(job_id=job.pk, object=obj)) - - job_config = { - "adcm": {"config": {}}, - "context": {"type": "cluster", "cluster_id": 1}, - "env": { - "run_dir": str(settings.RUN_DIR), - "log_dir": str(settings.LOG_DIR), - "tmp_dir": str(Path(settings.RUN_DIR, f"{job.id}", "tmp")), - "stack_dir": str(Path(settings.BUNDLE_DIR, action.prototype.bundle.hash)), - "status_api_token": str(settings.STATUS_SECRET_KEY), - }, - "job": { - "id": job.pk, - "action": action.name, - "job_name": "", - "command": "", - "script": "", - "verbose": False, - "playbook": str(settings.BUNDLE_DIR), - "config": "test", - }, - } - if prototype_type == "service": - job_config["job"].update( - { - "hostgroup": obj.prototype.name, - "service_id": obj.id, - "service_type_id": obj.prototype.id, - "cluster_id": cluster.id, - }, - ) - - elif prototype_type == "cluster": - job_config["job"]["cluster_id"] = cluster.id - job_config["job"]["hostgroup"] = "CLUSTER" - elif prototype_type == "host": - job_config["job"].update( - { - "hostgroup": "HOST", - "hostname": obj.fqdn, - "host_id": obj.id, - "host_type_id": obj.prototype.id, - "provider_id": obj.provider.id, - }, - ) - elif prototype_type == "provider": - job_config["job"].update({"hostgroup": "PROVIDER", "provider_id": obj.id}) - elif prototype_type == "adcm": - job_config["job"]["hostgroup"] = "127.0.0.1" - - self.assertDictEqual(job_config, actual_job_config) - mock_get_adcm_configuration.assert_called() - mock_get_context.assert_called_with( - action=action, object_type=obj.prototype.type, selector=job.task.selector - ) - mock_get_bundle_root.assert_called_with(action=action) - mock_get_script_path.assert_called_with(action=action, job=job) - - @patch("cm.job.cook_delta") - @patch("cm.job.get_old_hc") - @patch("cm.job.get_actual_hc") - @patch("cm.job.prepare_job") - def test_re_prepare_job(self, mock_prepare_job, mock_get_actual_hc, mock_get_old_hc, mock_cook_delta): - new_hc = Mock() - mock_get_actual_hc.return_value = new_hc - old_hc = Mock() - mock_get_old_hc.return_value = old_hc - delta = Mock() - mock_cook_delta.return_value = delta - - bundle = Bundle.objects.create() - prototype = Prototype.objects.create(bundle=bundle, type="cluster") - cluster = Cluster.objects.create(prototype=prototype) - cluster_object = ClusterObject.objects.create(prototype=prototype, cluster=cluster) - host = Host.objects.create(prototype=prototype, cluster=cluster) - component = Prototype.objects.create(parent=prototype, type="component", bundle=bundle) - service_component = ServiceComponent.objects.create( - cluster=cluster, - service=cluster_object, - prototype=component, - ) - action = Action.objects.create( - prototype=prototype, - hostcomponentmap=[{"service": "", "component": "", "action": ""}], - ) - SubAction.objects.create(action=action) - hostcomponentmap = [ - { - "host_id": host.id, - "service_id": cluster_object.id, - "component_id": service_component.id, - }, - ] - task = TaskLog.objects.create( - action=action, - task_object=cluster, - start_date=timezone.now(), - finish_date=timezone.now(), - hostcomponentmap=hostcomponentmap, - config={"sleeptime": 1}, - ) - job = JobLog.objects.create( - task=task, - start_date=timezone.now(), - finish_date=timezone.now(), - ) - - job_scope = JobScope(job_id=job.pk, object=cluster) - re_prepare_job(job_scope=job_scope) - - mock_get_actual_hc.assert_called_once_with(cluster=cluster) - mock_get_old_hc.assert_called_once_with(saved_hostcomponent=task.hostcomponentmap) - mock_cook_delta.assert_called_once_with( - cluster=cluster, new_hc=new_hc, action_hc=action.hostcomponentmap, old=old_hc - ) - mock_prepare_job.assert_called_once_with(job_scope=job_scope, delta=delta) - def test_job_termination_allowed_action_termination_allowed(self): self.init_adcm() self.login() @@ -604,7 +168,7 @@ def test_job_termination_allowed_action_termination_allowed(self): if action is None: raise AssertionError(f"Can't find '{action_name}' action in cluster '{self.multijob_cluster_name}'") - with patch("cm.job.run_task"): + with patch("cm.services.job.run.run_task"): response, job = self.run_action_get_target_job( action=action, job_display_name=job_display_name, @@ -640,7 +204,7 @@ def test_job_termination_disallowed_action_termination_allowed(self): if action is None: raise AssertionError(f"Can't find '{action_name}' action in cluster '{self.multijob_cluster_name}'") - with patch("cm.job.run_task"): + with patch("cm.services.job.run.run_task"): response, job = self.run_action_get_target_job( action=action, job_display_name=job_display_name, @@ -676,7 +240,7 @@ def test_job_termination_not_defined_action_termination_allowed(self): if action is None: raise AssertionError(f"Can't find '{action_name}' action in cluster '{self.multijob_cluster_name}'") - with patch("cm.job.run_task"): + with patch("cm.services.job.run.run_task"): response, job = self.run_action_get_target_job( action=action, job_display_name=job_display_name, @@ -712,7 +276,7 @@ def test_job_termination_allowed_action_termination_not_allowed(self): if action is None: raise AssertionError(f"Can't find '{action_name}' action in cluster '{self.multijob_cluster_name}'") - with patch("cm.job.run_task"): + with patch("cm.services.job.run.run_task"): response, job = self.run_action_get_target_job( action=action, job_display_name=job_display_name, @@ -748,7 +312,7 @@ def test_job_termination_disallowed_action_termination_not_allowed(self): if action is None: raise AssertionError(f"Can't find '{action_name}' action in cluster '{self.multijob_cluster_name}'") - with patch("cm.job.run_task"): + with patch("cm.services.job.run.run_task"): response, job = self.run_action_get_target_job( action=action, job_display_name=job_display_name, @@ -784,7 +348,7 @@ def test_job_termination_not_defined_action_termination_not_allowed(self): if action is None: raise AssertionError(f"Can't find '{action_name}' action in cluster '{self.multijob_cluster_name}'") - with patch("cm.job.run_task"): + with patch("cm.services.job.run.run_task"): response, job = self.run_action_get_target_job( action=action, job_display_name=job_display_name, @@ -820,7 +384,7 @@ def test_job_termination_not_allowed_if_job_not_in_running_status(self): if action is None: raise AssertionError(f"Can't find '{action_name}' action in cluster '{self.multijob_cluster_name}'") - with patch("cm.job.run_task"): + with patch("cm.services.job.run.run_task"): response, job = self.run_action_get_target_job( action=action, job_display_name=job_display_name, diff --git a/python/cm/tests/test_migrations/__init__.py b/python/cm/tests/test_migrations/__init__.py new file mode 100644 index 0000000000..f3e59d81d8 --- /dev/null +++ b/python/cm/tests/test_migrations/__init__.py @@ -0,0 +1,12 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/python/cm/tests/test_migrations/test_0116_and_0117.py b/python/cm/tests/test_migrations/test_0116_and_0117.py new file mode 100644 index 0000000000..4ee6efe7ee --- /dev/null +++ b/python/cm/tests/test_migrations/test_0116_and_0117.py @@ -0,0 +1,103 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from core.job.types import ScriptType +from django_test_migrations.contrib.unittest_case import MigratorTestCase + + +class TestDirectMigration(MigratorTestCase): + migrate_from = ("cm", "0115_auto_20231025_1823") + migrate_to = ("cm", "0117_post_autonomous_joblogs") + + def prepare(self): + TaskLog = self.old_state.apps.get_model("cm", "TaskLog") + JobLog = self.old_state.apps.get_model("cm", "JobLog") + Action = self.old_state.apps.get_model("cm", "Action") + SubAction = self.old_state.apps.get_model("cm", "SubAction") + Prototype = self.old_state.apps.get_model("cm", "Prototype") + Bundle = self.old_state.apps.get_model("cm", "Bundle") + Cluster = self.old_state.apps.get_model("cm", "Cluster") + + ContentType = self.old_state.apps.get_model("contenttypes", "ContentType") + + bundle = Bundle.objects.create(name="cool", version="342", hash="lfj21opfijoi") + prototype = Prototype.objects.create(bundle=bundle, type="cluster", name="protoname", version="200.400") + + cluster = Cluster.objects.create(prototype=prototype, name="bazar") + + action = Action.objects.create( + prototype=prototype, + name="reg_name", + display_name="Name To Display", + type="task", + script="sc", + script_type=ScriptType.ANSIBLE, + allow_to_terminate=True, + ) + + self.sub_1 = SubAction.objects.create( + action=action, + name="sub_1", + display_name="Number One", + allow_to_terminate=False, + script="script1", + script_type=ScriptType.ANSIBLE, + state_on_fail="onfail", + multi_state_on_fail_set=["one", "two"], + multi_state_on_fail_unset=["old"], + ) + self.sub_2 = SubAction.objects.create( + action=action, + name="sub_2", + display_name="Second", + allow_to_terminate=None, + script="script2", + script_type=ScriptType.PYTHON, + state_on_fail="onfailtwo", + multi_state_on_fail_set=[], + multi_state_on_fail_unset=["hello"], + ) + self.sub_3 = SubAction.objects.create( + action=action, name="sub_3", allow_to_terminate=True, script="script1", script_type=ScriptType.INTERNAL + ) + + task = TaskLog.objects.create( + action=action, object_id=cluster.pk, object_type=ContentType.objects.create(app_label="cm", model="cluster") + ) + self.job_1 = JobLog.objects.create(action=action, task=task, sub_action=self.sub_1) + self.job_2 = JobLog.objects.create(action=action, task=task, sub_action=self.sub_2) + self.job_3 = JobLog.objects.create(action=action, task=task, sub_action=self.sub_3) + self.job_4 = JobLog.objects.create(action=action, task=task, sub_action=None) + + def test_migration_0116_0117_move_data(self): + Action = self.new_state.apps.get_model("cm", "Action") + JobLog = self.new_state.apps.get_model("cm", "JobLog") + + self.assertEqual(Action.objects.count(), 1) + + for job_, sub_ in ((self.job_1, self.sub_1), (self.job_2, self.sub_2), (self.job_3, self.sub_3)): + job = JobLog.objects.get(pk=job_.pk) + for field in ( + "name", + "display_name", + "script", + "script_type", + "state_on_fail", + "multi_state_on_fail_set", + "multi_state_on_fail_unset", + ): + self.assertEqual(getattr(job, field), getattr(sub_, field)) + + self.assertSetEqual( + set(JobLog.objects.values_list("id", "allow_to_terminate").all()), + {(self.job_1.pk, False), (self.job_2.pk, True), (self.job_3.pk, True), (self.job_4.pk, False)}, + ) diff --git a/python/cm/tests/test_task_log.py b/python/cm/tests/test_task_log.py index 02eeb73a36..ecc9bc28cb 100644 --- a/python/cm/tests/test_task_log.py +++ b/python/cm/tests/test_task_log.py @@ -20,10 +20,6 @@ from core.types import ADCMCoreType, CoreObjectDescriptor from django.conf import settings from django.test import override_settings -from django.urls import reverse -from django.utils import timezone -from rest_framework.response import Response -from rest_framework.status import HTTP_200_OK from cm.issue import lock_affected_objects, unlock_affected_objects from cm.models import ( @@ -31,8 +27,6 @@ Bundle, Cluster, ConcernType, - JobLog, - LogStorage, Prototype, SubAction, TaskLog, @@ -85,132 +79,6 @@ def test_unlock_affected(self): self.assertFalse(cluster.locked) self.assertIsNone(task.lock) - # todo looks like useless test - @override_settings(RUN_DIR=settings.BASE_DIR / "python" / "cm" / "tests" / "files" / "task_log_download") - def test_download(self): - bundle = Bundle.objects.create() - cluster = Cluster.objects.create( - prototype=Prototype.objects.create( - bundle=bundle, - type="cluster", - name="test_cluster_prototype", - ), - name="test_cluster", - ) - action = Action.objects.create( - display_name="test_cluster_action", - prototype=cluster.prototype, - type="task", - state_available="any", - ) - task = TaskLog.objects.create( - task_object=cluster, - action=action, - start_date=timezone.now(), - finish_date=timezone.now(), - ) - - cluster_2 = Cluster.objects.create( - prototype=Prototype.objects.create( - bundle=bundle, - type="cluster", - name="test_cluster_prototype_2", - ), - name="test_cluster_2", - ) - cluster_3 = Cluster.objects.create( - prototype=Prototype.objects.create( - bundle=bundle, - type="cluster", - name="test_cluster_prototype_3", - ), - name="test_cluster_3", - ) - cluster_4 = Cluster.objects.create( - prototype=Prototype.objects.create( - bundle=bundle, - type="cluster", - name="test_cluster_prototype_4", - ), - name="test_cluster_4", - ) - cluster_5 = Cluster.objects.create( - prototype=Prototype.objects.create( - bundle=bundle, - type="cluster", - name="test_cluster_prototype_5", - ), - name="test_cluster_5", - ) - JobLog.objects.create( - task=TaskLog.objects.create( - task_object=cluster, - action=Action.objects.create( - display_name="test_subaction_job_1", - prototype=cluster_2.prototype, - type="job", - state_available="any", - ), - start_date=timezone.now(), - finish_date=timezone.now(), - ), - start_date=timezone.now(), - finish_date=timezone.now(), - ) - JobLog.objects.create( - task=TaskLog.objects.create( - task_object=cluster, - action=Action.objects.create( - display_name="test_subaction_job_2", - prototype=cluster_3.prototype, - type="job", - state_available="any", - ), - start_date=timezone.now(), - finish_date=timezone.now(), - ), - start_date=timezone.now(), - finish_date=timezone.now(), - ) - JobLog.objects.create( - task=TaskLog.objects.create( - task_object=cluster, - action=Action.objects.create( - display_name="test_subaction_job_3", - prototype=cluster_4.prototype, - type="job", - state_available="any", - ), - start_date=timezone.now(), - finish_date=timezone.now(), - ), - start_date=timezone.now(), - finish_date=timezone.now(), - ) - job_no_files = JobLog.objects.create( - task=TaskLog.objects.create( - task_object=cluster, - action=Action.objects.create( - display_name="test_subaction_job_4", - prototype=cluster_5.prototype, - type="job", - state_available="any", - ), - start_date=timezone.now(), - finish_date=timezone.now(), - ), - start_date=timezone.now(), - finish_date=timezone.now(), - ) - LogStorage.objects.create(job=job_no_files, body="stdout db", type="stdout", format="txt") - LogStorage.objects.create(job=job_no_files, body="stderr db", type="stderr", format="txt") - - response: Response = self.client.get( - path=reverse(viewname="v1:tasklog-download", kwargs={"task_pk": task.pk}), - ) - - self.assertEqual(response.status_code, HTTP_200_OK) - @override_settings(RUN_DIR=settings.BASE_DIR / "python" / "cm" / "tests" / "files" / "task_log_download") def test_download_negative(self): bundle = Bundle.objects.create() diff --git a/python/cm/upgrade.py b/python/cm/upgrade.py index 0d9f641f4d..0af92232ee 100644 --- a/python/cm/upgrade.py +++ b/python/cm/upgrade.py @@ -33,7 +33,6 @@ ) from cm.errors import raise_adcm_ex from cm.issue import update_hierarchy_issues -from cm.job import ActionRunPayload, run_action from cm.logger import logger from cm.models import ( ADCMEntity, @@ -53,6 +52,7 @@ ServiceComponent, Upgrade, ) +from cm.services.job.action import ActionRunPayload, run_action from cm.status_api import send_prototype_and_state_update_event from cm.utils import obj_ref @@ -501,7 +501,6 @@ def do_upgrade( action=upgrade.action, obj=obj, payload=ActionRunPayload(conf=config, attr=attr, hostcomponent=hostcomponent, verbose=verbose), - hosts=[], ) task_id = task.id diff --git a/python/core/job/dto.py b/python/core/job/dto.py index 0a6f9678da..444a28fcd0 100644 --- a/python/core/job/dto.py +++ b/python/core/job/dto.py @@ -13,7 +13,7 @@ from pydantic import BaseModel -from core.job.types import ExecutionStatus +from core.job.types import ExecutionStatus, HostComponentChanges class TaskUpdateDTO(BaseModel): @@ -45,3 +45,7 @@ class TaskPayloadDTO(BaseModel): hostcomponent: list[dict] | None = None post_upgrade_hostcomponent: list[dict] | None = None + + +class TaskMutableFieldsDTO(BaseModel): + hostcomponent: HostComponentChanges diff --git a/python/core/job/errors.py b/python/core/job/errors.py new file mode 100644 index 0000000000..9691906c16 --- /dev/null +++ b/python/core/job/errors.py @@ -0,0 +1,17 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from core.types import ADCMMessageError + + +class TaskCreateError(ADCMMessageError): + ... diff --git a/python/core/job/executors.py b/python/core/job/executors.py index f1485e7dd2..3c05f8d730 100644 --- a/python/core/job/executors.py +++ b/python/core/job/executors.py @@ -19,6 +19,8 @@ from pydantic import BaseModel from typing_extensions import Self +from core.job.types import BundleInfo + class ExecutionResult(NamedTuple): code: int @@ -45,8 +47,8 @@ class ExecutorConfig(BaseModel): class BundleExecutorConfig(ExecutorConfig): - script_file: Path - bundle_root: Path + job_script: str + bundle: BundleInfo class Executor(ABC): @@ -96,7 +98,7 @@ def execute(self) -> Self: self._open_logs(log_dir=self._config.work_dir, log_prefix=self.script_type) - os.chdir(self._config.bundle_root) + os.chdir(self._config.bundle.root) self._process = subprocess.Popen( command, # noqa S603 env=environment, @@ -120,6 +122,7 @@ def _prepare_command(self) -> list[str]: def _get_environment_variables(self) -> dict: env = os.environ.copy() - env["PYTHONPATH"] = f"./pmod:{self._config.bundle_root}/pmod:{env.get('PYTHONPATH', '')}".rstrip(":") + # it is expected to be bundle root as in `stack_dir` + env["PYTHONPATH"] = f"./pmod:{self._config.bundle.root}/pmod:{env.get('PYTHONPATH', '')}".rstrip(":") return env diff --git a/python/core/job/repo.py b/python/core/job/repo.py index 1deeae7819..be72d8cb49 100644 --- a/python/core/job/repo.py +++ b/python/core/job/repo.py @@ -12,7 +12,7 @@ from typing import Collection, Iterable, Protocol -from core.job.dto import JobUpdateDTO, LogCreateDTO, TaskPayloadDTO, TaskUpdateDTO +from core.job.dto import JobUpdateDTO, LogCreateDTO, TaskMutableFieldsDTO, TaskPayloadDTO, TaskUpdateDTO from core.job.types import ActionInfo, Job, JobSpec, Task from core.types import ActionID, CoreObjectDescriptor @@ -32,6 +32,9 @@ def update_task(self, id: int, data: TaskUpdateDTO) -> None: # noqa: A002 def get_task_jobs(self, task_id: int) -> Iterable[Job]: ... + def get_task_mutable_fields(self, id: int) -> TaskMutableFieldsDTO: # noqa: A002 + ... + def create_jobs(self, task_id: int, jobs: Iterable[JobSpec]) -> None: ... @@ -44,8 +47,6 @@ def update_job(self, id: int, data: JobUpdateDTO) -> None: # noqa: A002 def create_logs(self, logs: Iterable[LogCreateDTO]) -> None: ... - # todo quite strange to keep it here, - # on the other hand statuses are more about actions/task/jobs that anything else def update_owner_state(self, owner: CoreObjectDescriptor, state: str) -> None: ... diff --git a/python/core/job/runners.py b/python/core/job/runners.py index 1b22fc4542..3a7a1f4e0e 100644 --- a/python/core/job/runners.py +++ b/python/core/job/runners.py @@ -24,15 +24,21 @@ class ADCMSettings(NamedTuple): code_root_dir: Path run_dir: Path + log_dir: Path class AnsibleSettings(NamedTuple): ansible_secret_script: Path +class IntegrationsSettings(NamedTuple): + status_server_token: str + + class ExternalSettings(NamedTuple): adcm: ADCMSettings ansible: AnsibleSettings + integrations: IntegrationsSettings class JobFinalizer(Protocol): @@ -41,7 +47,7 @@ def __call__(self, job: Job) -> None: class JobEnvironmentBuilder(Protocol): - def __call__(self, job: Job) -> None: + def __call__(self, task: Task, job: Job, configuration: ExternalSettings) -> None: ... @@ -53,13 +59,13 @@ class ExecutionTarget(NamedTuple): finalizers: Iterable[JobFinalizer] -class ExecutionTargetFactoryProtocol(Protocol): +class JobToExecutionTargetConverter(Protocol): def __call__(self, task: Task, jobs: Iterable[Job], configuration: ExternalSettings) -> Iterable[ExecutionTarget]: ... class JobProcessor(NamedTuple): - convert: ExecutionTargetFactoryProtocol + convert: JobToExecutionTargetConverter # id will always return True in bool cast filter_predicate: Callable[[Job], bool] = id diff --git a/python/core/job/task.py b/python/core/job/task.py index 45f1f32ad3..139bb732b9 100644 --- a/python/core/job/task.py +++ b/python/core/job/task.py @@ -12,6 +12,7 @@ from core.job.dto import LogCreateDTO, TaskPayloadDTO +from core.job.errors import TaskCreateError from core.job.repo import ActionRepoInterface, JobRepoInterface from core.job.types import JobSpec from core.types import ActionID, CoreObjectDescriptor @@ -36,21 +37,20 @@ def compose_task( ! WARNING ! Currently, stdout/stderr logs are created alongside the jobs for policies to be re-applied correctly after this method is called. - It must be changed. + + It may be changed if favor of creating logs when job is actually prepared/started. """ job_specifications = get_specifications_for_jobs(action=action, repo=action_repo) if not job_specifications: - # todo fix error type message = f"Can't compose task for action #{action}, because no associated jobs found" - raise RuntimeError(message) + raise TaskCreateError(message) action_info = action_repo.get_action(id=action) task = job_repo.create_task(target=target, owner=owner, action=action_info, payload=payload) job_repo.create_jobs(task_id=task.id, jobs=job_specifications) - # todo fix warning from docstring logs = [] for job in job_repo.get_task_jobs(task_id=task.id): logs.append(LogCreateDTO(job_id=job.id, name=job.type.value, type="stdout", format="txt")) diff --git a/python/core/job/types.py b/python/core/job/types.py index c89c238b7a..8810fe6221 100644 --- a/python/core/job/types.py +++ b/python/core/job/types.py @@ -14,9 +14,17 @@ from pathlib import Path from typing import NamedTuple -from pydantic import BaseModel +from pydantic import BaseModel, Extra -from core.types import ActionID, CoreObjectDescriptor, NamedCoreObject, PrototypeDescriptor +from core.types import ( + ActionID, + ADCMCoreType, + NamedCoreObject, + NamedCoreObjectWithPrototype, + ObjectID, + PrototypeDescriptor, + PrototypeID, +) # str is required for pydantic to correctly cast enum to value when calling `.dict` @@ -49,28 +57,66 @@ class StateChanges(NamedTuple): class HostComponentChanges(NamedTuple): - to_set: list[dict] | None + saved: list[dict] | None post_upgrade: list[dict] | None restore_on_fail: bool +class BundleInfo(NamedTuple): + # root is directory of bundle like /adcm/data/bundle/somehash + root: Path + # relative path to directory with `config.yaml` within `root` + config_dir: Path + + +class RelatedObjects(NamedTuple): + # must be specified for Service/Component and Host (if linked) + cluster: NamedCoreObjectWithPrototype | None = None + # must be specified for Component + service: NamedCoreObjectWithPrototype | None = None + # must be specified for Host + hostprovider: NamedCoreObjectWithPrototype | None = None + + +class TaskOwner(NamedTuple): + id: ObjectID + type: ADCMCoreType + name: str + prototype_id: PrototypeID + + related_objects: RelatedObjects + + +class TaskActionInfo(NamedTuple): + name: str + display_name: str + + venv: str + hc_acl: list[dict] + + is_upgrade: bool + is_host_action: bool + + class Task(BaseModel): id: int # Owner is an object on which action is defined - owner: CoreObjectDescriptor | None - bundle_root: Path | None + owner: TaskOwner | None + bundle: BundleInfo | None # Target is an object on which action should be performed # it's the same as owner for all cases except `host_action: true` target: NamedCoreObject | None - name: str - display_name: str - is_upgrade: bool + selector: dict + + action: TaskActionInfo + verbose: bool - venv: str hostcomponent: HostComponentChanges + config: dict | None + on_success: StateChanges on_fail: StateChanges @@ -91,19 +137,20 @@ class JobSpec(BaseModel): # extra params: dict - class Config: # simplify existing objects retrieval - orm_mode = True - # it is validated, because we want to fail here on incorrect data # rather than when we will use it class JobParams(BaseModel): ansible_tags: str + class Config: + extra = Extra.allow + class Job(BaseModel): id: int pid: int + name: str type: ScriptType status: ExecutionStatus script: str diff --git a/python/core/types.py b/python/core/types.py index bc84aea642..1a7d9c89fd 100644 --- a/python/core/types.py +++ b/python/core/types.py @@ -79,3 +79,10 @@ class NamedCoreObject(NamedTuple): id: ObjectID type: ADCMCoreType name: str + + +class NamedCoreObjectWithPrototype(NamedTuple): + id: ObjectID + prototype_id: PrototypeID + type: ADCMCoreType + name: str diff --git a/python/init_db.py b/python/init_db.py index 7c6327e2b0..e332edf1fc 100755 --- a/python/init_db.py +++ b/python/init_db.py @@ -15,6 +15,8 @@ import json import logging +from django.utils import timezone + import adcm.init_django # noqa: F401, isort:skip from cm.bundle import load_adcm @@ -28,6 +30,9 @@ ConcernType, GroupCheckLog, HostProvider, + JobLog, + JobStatus, + TaskLog, ) from django.conf import settings from rbac.models import User @@ -86,6 +91,17 @@ def recheck_issues(): update_hierarchy_issues(obj) +def abort_all(): + for task in TaskLog.objects.filter(status=JobStatus.RUNNING): + task.status = JobStatus.ABORTED + task.finish_date = timezone.now() + task.save(update_fields=["status", "finish_date"]) + + unlock_affected_objects(task=task) + + JobLog.objects.filter(status=JobStatus.RUNNING).update(status=JobStatus.ABORTED, finish_date=timezone.now()) + + def init(adcm_conf_file: Path = Path(settings.BASE_DIR, "conf", "adcm", "config.yaml")): logger.info("Start initializing ADCM DB...") if not User.objects.filter(username="admin").exists(): diff --git a/python/job_runner.py b/python/job_runner.py deleted file mode 100755 index 5b6a9ae0c7..0000000000 --- a/python/job_runner.py +++ /dev/null @@ -1,243 +0,0 @@ -#!/usr/bin/env python3 -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from pathlib import Path -import os -import sys -import json -import subprocess - -import adcm.init_django # noqa: F401, isort:skip - -from cm.ansible_plugin import finish_check -from cm.api import get_hc, save_hc -from cm.errors import AdcmEx -from cm.job import check_hostcomponentmap, set_job_final_status, set_job_start_status -from cm.logger import logger -from cm.models import JobLog, JobStatus, Prototype, ServiceComponent -from cm.status_api import send_prototype_and_state_update_event -from cm.upgrade import bundle_revert, bundle_switch -from cm.utils import get_env_with_venv_path -from django.conf import settings -from django.db.transaction import atomic -from rbac.roles import re_apply_policy_for_jobs - - -def open_file(root, tag, job_id): - fname = f"{root}/{job_id}/{tag}.txt" - return Path(fname).open(mode="w", encoding=settings.ENCODING_UTF_8) # noqa: SIM115 - - -def read_config(job_id): - with Path(f"{settings.RUN_DIR}/{job_id}/config.json").open(encoding=settings.ENCODING_UTF_8) as file_descriptor: - return json.load(file_descriptor) - - -def set_job_status(job_id: int, return_code: int) -> int: - if return_code == 0: - set_job_final_status(job_id=job_id, status=JobStatus.SUCCESS) - return 0 - elif return_code == -15: # noqa: RET505 - set_job_final_status(job_id=job_id, status=JobStatus.ABORTED) - return 15 - else: - set_job_final_status(job_id=job_id, status=JobStatus.FAILED) - return return_code - - -def set_pythonpath(env, stack_dir): - pmod_path = f"./pmod:{stack_dir}/pmod" - if "PYTHONPATH" in env: - env["PYTHONPATH"] = pmod_path + ":" + env["PYTHONPATH"] - else: - env["PYTHONPATH"] = pmod_path - return env - - -def set_ansible_config(env, job_id): - env["ANSIBLE_CONFIG"] = str(settings.RUN_DIR / f"{job_id}/ansible.cfg") - return env - - -def get_configured_env(job_config: dict) -> dict: - job_id = job_config["job"]["id"] - stack_dir = job_config["env"]["stack_dir"] - env = os.environ.copy() - env = set_pythonpath(env=env, stack_dir=stack_dir) - env = get_env_with_venv_path(venv=JobLog.objects.get(id=job_id).action.venv, existing_env=env) - - # This condition is intended to support compatibility. - # Since older bundle versions may contain their own ansible.cfg - if not Path(stack_dir, "ansible.cfg").is_file(): - env = set_ansible_config(env=env, job_id=job_id) - logger.info("set ansible config for job:%s", job_id) - - return env - - -def get_venv(job_id: int) -> str: - return JobLog.objects.get(id=job_id).action.venv - - -def process_err_out_file(job_id, job_type): - out_file = open_file(settings.RUN_DIR, f"{job_type}-stdout", job_id) - err_file = open_file(settings.RUN_DIR, f"{job_type}-stderr", job_id) - return out_file, err_file - - -def start_subprocess(job_id, cmd, conf, out_file, err_file): - logger.info("job run cmd: %s", " ".join(cmd)) - process = subprocess.Popen( # noqa: SIM115 - cmd, # noqa: S603 - env=get_configured_env(job_config=conf), - stdout=out_file, - stderr=err_file, - ) - - set_job_start_status(job_id=job_id, pid=process.pid) # todo not implemented in runners - logger.info("run job #%s, pid %s", job_id, process.pid) - return_code = process.wait() - - finish_check(job_id) # todo not implemented in runners - return_code = set_job_status(job_id=job_id, return_code=return_code) # todo not implemented in runners - - out_file.close() - err_file.close() - - logger.info("finish job subprocess #%s, pid %s, ret %s", job_id, process.pid, return_code) - return return_code - - -def run_ansible(job_id: int) -> None: - logger.debug("job_runner.py starts to run ansible job %s", job_id) - conf = read_config(job_id) - playbook = conf["job"]["playbook"] - out_file, err_file = process_err_out_file(job_id, "ansible") - - os.chdir(conf["env"]["stack_dir"]) - cmd = [ - "ansible-playbook", - "--vault-password-file", - f"{settings.CODE_DIR}/ansible_secret.py", - "-e", - f"@{settings.RUN_DIR}/{job_id}/config.json", - "-i", - f"{settings.RUN_DIR}/{job_id}/inventory.json", - playbook, - ] - if "params" in conf["job"] and "ansible_tags" in conf["job"]["params"]: - cmd.append("--tags=" + conf["job"]["params"]["ansible_tags"]) - if "verbose" in conf["job"] and conf["job"]["verbose"]: - cmd.append("-vvvv") - ret = start_subprocess(job_id, cmd, conf, out_file, err_file) - sys.exit(ret) - - -def run_internal(job: JobLog) -> None: - set_job_start_status(job_id=job.id, pid=0) - out_file, err_file = process_err_out_file(job_id=job.id, job_type="internal") - script = job.script - return_code = 0 - status = JobStatus.SUCCESS - - try: - with atomic(): - object_ = job.task.task_object - if script == "bundle_switch": - bundle_switch(obj=object_, upgrade=job.action.upgrade) - elif script == "bundle_revert": - bundle_revert(obj=object_) - elif script == "hc_apply": - job.task.restore_hc_on_fail = False - job.task.save(update_fields=["restore_hc_on_fail"]) - - if script != "hc_apply": - # todo wut? - switch_hc(task=job.task, action=job.action) - - # todo shouldn't we reapply policies for every job? - # yes -- for `bundle_switch` and `bundle_revert` - re_apply_policy_for_jobs(action_object=object_, task=job.task) - except AdcmEx as e: - err_file.write(e.msg) - return_code = 1 - status = JobStatus.FAILED - finally: - if script == "bundle_revert": # todo not implemented in runner - send_prototype_and_state_update_event(object_=object_) - - set_job_final_status(job_id=job.id, status=status) - out_file.close() - err_file.close() - sys.exit(return_code) - - -def run_python(job: JobLog) -> None: - out_file, err_file = process_err_out_file(job.id, "python") - conf = read_config(job.id) - script_path = conf["job"]["playbook"] - os.chdir(conf["env"]["stack_dir"]) - cmd = ["python", script_path] - ret = start_subprocess(job.id, cmd, conf, out_file, err_file) - sys.exit(ret) - - -def switch_hc(task, action): - if task.task_object.prototype.type != "cluster": - return - - cluster = task.task_object - old_hc = get_hc(cluster) - new_hc = [] - for hostcomponent in [*task.post_upgrade_hc_map, *old_hc]: - if hostcomponent not in new_hc: - new_hc.append(hostcomponent) - - task.hostcomponentmap = old_hc - task.post_upgrade_hc_map = None - task.save() - - for hostcomponent in new_hc: - if "component_prototype_id" in hostcomponent: - proto = Prototype.objects.get(type="component", id=hostcomponent.pop("component_prototype_id")) - comp = ServiceComponent.objects.get(cluster=cluster, prototype=proto) - hostcomponent["component_id"] = comp.id - hostcomponent["service_id"] = comp.service.id - - host_map, _ = check_hostcomponentmap(cluster, action, new_hc) - if host_map is not None: - save_hc(cluster, host_map) - - -def main(job_id): - logger.debug("job_runner.py called as: %s", sys.argv) - job = JobLog.objects.get(id=job_id) - job_type = job.script_type - if job_type == "internal": - run_internal(job=job) - elif job_type == "python": - run_python(job=job) - else: - run_ansible(job_id=job_id) - - -def do_job(): - if len(sys.argv) < 2: - print(f"\nUsage:\n{os.path.basename(sys.argv[0])} job_id\n") # noqa: PTH119 - sys.exit(4) - else: - main(sys.argv[1]) - - -if __name__ == "__main__": - do_job() diff --git a/python/runner.py b/python/runner.py deleted file mode 100755 index df2ebaabed..0000000000 --- a/python/runner.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import os -import sys -import signal -import logging -import argparse - -import adcm.init_django # noqa: F401, isort:skip -from cm.services.job.run import get_default_runner, get_restart_runner - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("command", choices=["start", "restart"]) - parser.add_argument("task_id", type=int) - args = parser.parse_args() - - runner = get_restart_runner() if args.command == "restart" else get_default_runner() - - logger = logging.getLogger("task_runner_err") - - exit_ = {"code": 0} - - def terminate(signum, frame): - _ = frame - - logger.info(f"Cancelling runner at {os.getpid()} with {signum}") - - exit_["code"] = signum - try: - runner.terminate() - except: # noqa: E722 - logger.exception("Unhandled error occurred during runner termination") - - runner.consider_broken() - - exit_["code"] = 1 - - signal.signal(signal.SIGTERM, terminate) - - try: - runner.run(task_id=args.task_id) - except: # noqa: E722 - logger.exception("Unhandled error occurred during runner execution") - - runner.consider_broken() - - exit_["code"] = 1 - - sys.exit(exit_["code"]) - - -if __name__ == "__main__": - main() diff --git a/python/task_runner.py b/python/task_runner.py index ef2cab94ae..df2ebaabed 100755 --- a/python/task_runner.py +++ b/python/task_runner.py @@ -10,203 +10,56 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from logging import getLogger import os import sys -import time import signal -import subprocess +import logging +import argparse import adcm.init_django # noqa: F401, isort:skip +from cm.services.job.run import get_default_runner, get_restart_runner -from cm.errors import AdcmEx -from cm.job import finish_task, re_prepare_job, write_job_config -from cm.logger import logger -from cm.models import ADCM, JobLog, JobStatus, LogStorage, TaskLog -from cm.services.job.config import get_job_config -from cm.services.job.utils import JobScope -from cm.status_api import send_task_status_update_event -from cm.utils import get_env_with_venv_path -from django.conf import settings -from django.core.exceptions import ObjectDoesNotExist -from django.utils import timezone -error_logger = getLogger("task_runner_err") -TASK_ID = 0 +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("command", choices=["start", "restart"]) + parser.add_argument("task_id", type=int) + args = parser.parse_args() + runner = get_restart_runner() if args.command == "restart" else get_default_runner() -def terminate_job(task, jobs): - running_job = jobs.get(status=JobStatus.RUNNING) + logger = logging.getLogger("task_runner_err") - if running_job.pid: - try: - os.kill(running_job.pid, signal.SIGTERM) - except OSError as e: - raise AdcmEx("NOT_ALLOWED_TERMINATION", f"Failed to terminate process: {e}") from e - finish_task(task, running_job, JobStatus.ABORTED) - else: - finish_task(task, None, JobStatus.ABORTED) + exit_ = {"code": 0} + def terminate(signum, frame): + _ = frame -def terminate_task(signum, frame): # noqa: ARG001 - logger.info("cancel task #%s, signal: #%s", TASK_ID, signum) - task = TaskLog.objects.get(id=TASK_ID) - jobs = JobLog.objects.filter(task_id=TASK_ID) + logger.info(f"Cancelling runner at {os.getpid()} with {signum}") - i = 0 - while i < 10: - if jobs.filter(status=JobStatus.RUNNING): - terminate_job(task, jobs) - break - i += 1 - time.sleep(0.5) + exit_["code"] = signum + try: + runner.terminate() + except: # noqa: E722 + logger.exception("Unhandled error occurred during runner termination") - if i == 10: - logger.warning("no jobs running for task #%s", TASK_ID) - finish_task(task, None, JobStatus.ABORTED) + runner.consider_broken() - sys.exit(signum) + exit_["code"] = 1 + signal.signal(signal.SIGTERM, terminate) -signal.signal(signal.SIGTERM, terminate_task) + try: + runner.run(task_id=args.task_id) + except: # noqa: E722 + logger.exception("Unhandled error occurred during runner execution") + runner.consider_broken() -def run_job(task_id, job_id, err_file): - logger.debug("task run job #%s of task #%s", job_id, task_id) - cmd = [ - str(settings.CODE_DIR / "job_runner.py"), - str(job_id), - ] - logger.info("task run job cmd: %s", " ".join(cmd)) + exit_["code"] = 1 - try: - # noqa: SIM115 - proc = subprocess.Popen( - args=cmd, stderr=err_file, env=get_env_with_venv_path(venv=TaskLog.objects.get(id=task_id).action.venv) - ) - return proc.wait() # noqa: TRY300 - except Exception as error: # noqa: BLE001 - logger.error("exception running job %s: %s", job_id, error) - return 1 - - -def set_log_body(job): - name = job.script_type - log_storages = LogStorage.objects.filter(job=job, name=name, type__in=["stdout", "stderr"]) - for log_storage in log_storages: - file_path = ( - settings.RUN_DIR / f"{log_storage.job.id}" / f"{log_storage.name}-{log_storage.type}.{log_storage.format}" - ) - with open(file_path, encoding=settings.ENCODING_UTF_8) as f: - body = f.read() - - LogStorage.objects.filter(job=job, name=log_storage.name, type=log_storage.type).update(body=body) - - -def run_task(task_id: int, args: str | None = None) -> None: - logger.debug("task_runner.py called as: %s", sys.argv) - try: - task = TaskLog.objects.get(id=task_id) - except ObjectDoesNotExist: - logger.error("no task %s", task_id) - - return - - task.pid = os.getpid() - task.restore_hc_on_fail = True - task.start_date = timezone.now() - task.status = JobStatus.RUNNING - task.save(update_fields=["pid", "restore_hc_on_fail", "start_date", "status"]) - - send_task_status_update_event(task_id=task.pk, status=JobStatus.RUNNING.value) - - jobs = JobLog.objects.filter(task_id=task.id).order_by("id") - if not jobs: - logger.error("no jobs for task %s", task.id) - finish_task(task, None, JobStatus.FAILED) - - return - - with open(settings.LOG_DIR / "job_runner.err", mode="a+", encoding=settings.ENCODING_UTF_8) as err_file: - logger.info("run task #%s", task_id) - - job = None - count = 0 - res = 0 - - # It needs to be defined outside of jobs loop, because task_object can be deleted during job execution - task_object = task.task_object - - for job in jobs: - try: - job.refresh_from_db() - if args == "restart" and job.status == JobStatus.SUCCESS: - logger.info('skip job #%s status "%s" of task #%s', job.id, job.status, task_id) - continue - - task.refresh_from_db() - - job_scope = JobScope(job_id=job.pk, object=task_object) - # This should be reworked somehow, - # because preparation of job depends on its type, - # not parent object. - # For now, I don't see another point where it can be patched - # without reworking the whole job preparation tree - if not isinstance(job_scope.object, ADCM): - re_prepare_job(job_scope=job_scope) - else: - write_job_config(job_id=job_scope.job_id, config=get_job_config(job_scope=job_scope)) - - res = run_job(task.id, job.id, err_file) - set_log_body(job) - - # For multi jobs task object state and/or config can be changed by adcm plugins - if task.task_object is not None: - try: - task.task_object.refresh_from_db() - except ObjectDoesNotExist: - task.object_id = 0 - task.object_type = None - - job.refresh_from_db() - count += 1 - if res != 0: - task.refresh_from_db() - if job.status == JobStatus.ABORTED and task.status != JobStatus.ABORTED: - continue - - break - except Exception: # noqa: BLE001ion-caught - error_logger.exception("Task #%s: Error processing job #%s", task_id, job.pk) - res = 1 - break - - if job is not None: - job.refresh_from_db() - - if job is not None and job.status == JobStatus.ABORTED: - finish_task(task, job, JobStatus.ABORTED) - elif res == 0: - finish_task(task, job, JobStatus.SUCCESS) - else: - finish_task(task, job, JobStatus.FAILED) - - logger.info("finish task #%s, ret %s", task_id, res) - - -def do_task(): - global TASK_ID - - if len(sys.argv) < 2: - print(f"\nUsage:\n{os.path.basename(sys.argv[0])} task_id [restart]\n") # noqa: PTH119 - sys.exit(4) - elif len(sys.argv) > 2: - TASK_ID = sys.argv[1] - run_task(sys.argv[1], sys.argv[2]) - else: - TASK_ID = sys.argv[1] - run_task(sys.argv[1]) + sys.exit(exit_["code"]) if __name__ == "__main__": - do_task() + main() From 6b2b6abf14af984098543011a08e270bd2ed5356 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Thu, 14 Mar 2024 08:39:11 +0000 Subject: [PATCH 003/208] ADCM-5318 Extract `SubAction` from `Action` Changed: 1. Removed fields `script`, `script_type`, `params`, `log_files` from `Action` and `StageAction` models 2. Moved `script`, `script_type`, `params` info from `Action` (type="job") to `SubAction` (both migration and bundle upload) 3. Retrieve Job Spec based on `SubAction` only --- python/api/action/serializers.py | 24 +- python/api/tests/test_host.py | 1 - python/cm/bundle.py | 4 - .../0118_extract_sub_actions_from_actions.py | 113 +++++++++ python/cm/models.py | 5 - python/cm/services/job/run/repo.py | 7 - python/cm/stack.py | 27 ++- python/cm/tests/test_migrations/test_0118.py | 222 ++++++++++++++++++ python/cm/tests/test_task_log.py | 1 - python/cm/tests/utils.py | 7 +- python/rbac/tests/test_role.py | 15 -- 11 files changed, 379 insertions(+), 47 deletions(-) create mode 100644 python/cm/migrations/0118_extract_sub_actions_from_actions.py create mode 100644 python/cm/tests/test_migrations/test_0118.py diff --git a/python/api/action/serializers.py b/python/api/action/serializers.py index 7c707dfc66..e348d49efe 100644 --- a/python/api/action/serializers.py +++ b/python/api/action/serializers.py @@ -71,8 +71,8 @@ class StackActionSerializer(EmptySerializer): display_name = CharField(required=False) description = CharField(required=False) ui_options = JSONField(required=False) - script = CharField() - script_type = CharField() + script = SerializerMethodField() + script_type = SerializerMethodField() state_on_success = CharField() state_on_fail = CharField() hostcomponentmap = JSONField(required=False) @@ -81,6 +81,26 @@ class StackActionSerializer(EmptySerializer): host_action = BooleanField(read_only=True) start_impossible_reason = SerializerMethodField() + def get_script(self, action: Action) -> str: + if action.type == "task": + return "" + + sub = action.subaction_set.first() + if not sub: + return "" + + return sub.script + + def get_script_type(self, action: Action) -> str: + if action.type == "task": + return "" + + sub = action.subaction_set.first() + if not sub: + return "ansible" + + return sub.script_type + def get_start_impossible_reason(self, action: Action): if self.context.get("obj"): return action.get_start_impossible_reason(self.context["obj"]) diff --git a/python/api/tests/test_host.py b/python/api/tests/test_host.py index ecd3611ff6..899c446390 100644 --- a/python/api/tests/test_host.py +++ b/python/api/tests/test_host.py @@ -91,7 +91,6 @@ def test_change_mm_on_with_action_success(self): name=settings.ADCM_HOST_TURN_ON_MM_ACTION_NAME, type=ActionType.JOB, state_available="any", - script_type="ansible", ) with patch("adcm.utils.run_action") as start_task_mock: diff --git a/python/cm/bundle.py b/python/cm/bundle.py index 7a9783f830..0903872fa6 100644 --- a/python/cm/bundle.py +++ b/python/cm/bundle.py @@ -738,8 +738,6 @@ def copy_stage_actions(stage_actions, prototype): ( "name", "type", - "script", - "script_type", "state_available", "state_unavailable", "state_on_success", @@ -750,8 +748,6 @@ def copy_stage_actions(stage_actions, prototype): "multi_state_on_success_unset", "multi_state_on_fail_set", "multi_state_on_fail_unset", - "params", - "log_files", "hostcomponentmap", "display_name", "description", diff --git a/python/cm/migrations/0118_extract_sub_actions_from_actions.py b/python/cm/migrations/0118_extract_sub_actions_from_actions.py new file mode 100644 index 0000000000..b4daa188f4 --- /dev/null +++ b/python/cm/migrations/0118_extract_sub_actions_from_actions.py @@ -0,0 +1,113 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated by Django 3.2.19 on 2024-03-13 04:22 + +from django.db import migrations, models + + +def extract_sub_actions_from_actions(apps, schema_editor): + SubAction = apps.get_model("cm", "SubAction") + Action = apps.get_model("cm", "Action") + + fields_to_migrate = ( + "name", + "display_name", + "script", + "script_type", + "params", + "allow_to_terminate", + "state_on_fail", + "multi_state_on_fail_set", + "multi_state_on_fail_unset", + ) + + sub_actions_to_create = [] + for action in Action.objects.filter(type="job"): + action_data_for_subaction = {param: getattr(action, param) for param in fields_to_migrate} + sub_actions_to_create.append(SubAction(action=action, **action_data_for_subaction)) + + SubAction.objects.bulk_create(sub_actions_to_create) + + +def return_sub_actions_into_actions(apps, schema_editor): + SubAction = apps.get_model("cm", "SubAction") + Action = apps.get_model("cm", "Action") + + fields_to_return = ("script", "script_type", "params") + + sub_actions_to_delete: list[int] = [] + for sub_action in SubAction.objects.select_related("action").filter(action__type="job"): + for param in fields_to_return: + setattr(sub_action.action, param, getattr(sub_action, param)) + sub_action.action.save() + + sub_actions_to_delete.append(sub_action.pk) + + SubAction.objects.filter(pk__in=sub_actions_to_delete).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("cm", "0117_post_autonomous_joblogs"), + ] + + operations = [ + migrations.RunPython(extract_sub_actions_from_actions, reverse_code=return_sub_actions_into_actions), + # alter for reverse compatibility + migrations.AlterField( + model_name="action", + name="script_type", + field=models.CharField( + max_length=1000, + choices=(("ansible", "ansible"), ("python", "python"), ("internal", "internal")), + blank=True, + ), + ), + migrations.AlterField( + model_name="action", + name="script", + field=models.CharField(max_length=1000, blank=True), + ), + migrations.RemoveField( + model_name="action", + name="log_files", + ), + migrations.RemoveField( + model_name="action", + name="params", + ), + migrations.RemoveField( + model_name="action", + name="script", + ), + migrations.RemoveField( + model_name="action", + name="script_type", + ), + migrations.RemoveField( + model_name="stageaction", + name="log_files", + ), + migrations.RemoveField( + model_name="stageaction", + name="params", + ), + migrations.RemoveField( + model_name="stageaction", + name="script", + ), + migrations.RemoveField( + model_name="stageaction", + name="script_type", + ), + ] diff --git a/python/cm/models.py b/python/cm/models.py index e5e444e8ff..2d2fa923b6 100644 --- a/python/cm/models.py +++ b/python/cm/models.py @@ -1028,8 +1028,6 @@ class AbstractAction(ADCMModel): ui_options = models.JSONField(default=dict) type = models.CharField(max_length=1000, choices=ActionType.choices) - script = models.CharField(max_length=1000) - script_type = models.CharField(max_length=1000, choices=SCRIPT_TYPE) state_available = models.JSONField(default=list) state_unavailable = models.JSONField(default=list) @@ -1043,9 +1041,6 @@ class AbstractAction(ADCMModel): multi_state_on_fail_set = models.JSONField(default=list) multi_state_on_fail_unset = models.JSONField(default=list) - params = models.JSONField(default=dict) - log_files = models.JSONField(default=list) - hostcomponentmap = models.JSONField(default=list) allow_to_terminate = models.BooleanField(default=False) partial_execution = models.BooleanField(default=False) diff --git a/python/cm/services/job/run/repo.py b/python/cm/services/job/run/repo.py index a3107c6a1f..4e0f5e3ca7 100644 --- a/python/cm/services/job/run/repo.py +++ b/python/cm/services/job/run/repo.py @@ -51,7 +51,6 @@ from cm.models import ( ADCM, Action, - ActionType, Cluster, ClusterObject, Host, @@ -461,12 +460,6 @@ def get_action(id: ActionID) -> ActionInfo: # noqa: A002 @classmethod def get_job_specs(cls, id: ActionID) -> Iterable[JobSpec]: # noqa: A002 - try: - if Action.objects.values_list("type", flat=True).get(id=id) == ActionType.JOB: - return [cls._from_entry_to_spec(cls._qs_with_spec_values(Action.objects.get_queryset()).get(id=id))] - except Action.DoesNotExist: - return [] - return [ cls._from_entry_to_spec(sub_action) for sub_action in cls._qs_with_spec_values(SubAction.objects.filter(action_id=id)).order_by("id") diff --git a/python/cm/stack.py b/python/cm/stack.py index a1194ee553..87f12f7c37 100644 --- a/python/cm/stack.py +++ b/python/cm/stack.py @@ -587,6 +587,27 @@ def check_action_hc(proto: StagePrototype, conf: dict) -> None: def save_sub_actions(conf, action): if action.type != settings.TASK_TYPE: + sub_action = StageSubAction( + action=action, + script=conf["script"], + script_type=conf["script_type"], + name=action.name, + allow_to_terminate=action.allow_to_terminate, + ) + sub_action.display_name = action.display_name + + dict_to_obj(conf, "params", sub_action) + on_fail = conf.get(ON_FAIL, "") + if isinstance(on_fail, str): + sub_action.state_on_fail = on_fail + sub_action.multi_state_on_fail_set = [] + sub_action.multi_state_on_fail_unset = [] + elif isinstance(on_fail, dict): + sub_action.state_on_fail = _deep_get(on_fail, STATE, default="") + sub_action.multi_state_on_fail_set = _deep_get(on_fail, MULTI_STATE, SET, default=[]) + sub_action.multi_state_on_fail_unset = _deep_get(on_fail, MULTI_STATE, UNSET, default=[]) + + sub_action.save() return for sub in conf["scripts"]: @@ -713,17 +734,11 @@ def save_action(proto: StagePrototype, config: dict, bundle_hash: str, action_na action = StageAction(prototype=proto, name=action_name) action.type = config["type"] - if config["type"] == settings.JOB_TYPE: - action.script = config["script"] - action.script_type = config["script_type"] - dict_to_obj(dictionary=config, key="description", obj=action) dict_to_obj(dictionary=config, key="allow_to_terminate", obj=action) dict_to_obj(dictionary=config, key="partial_execution", obj=action) dict_to_obj(dictionary=config, key="host_action", obj=action) dict_to_obj(dictionary=config, key="ui_options", obj=action) - dict_to_obj(dictionary=config, key="params", obj=action) - dict_to_obj(dictionary=config, key="log_files", obj=action) dict_to_obj(dictionary=config, key="venv", obj=action) dict_to_obj(dictionary=config, key="allow_in_maintenance_mode", obj=action) dict_to_obj(dictionary=config, key="config_jinja", obj=action) diff --git a/python/cm/tests/test_migrations/test_0118.py b/python/cm/tests/test_migrations/test_0118.py new file mode 100644 index 0000000000..60cf21f50f --- /dev/null +++ b/python/cm/tests/test_migrations/test_0118.py @@ -0,0 +1,222 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from core.job.types import ScriptType +from django_test_migrations.contrib.unittest_case import MigratorTestCase + + +class TestDirectMigration(MigratorTestCase): + migrate_from = ("cm", "0117_post_autonomous_joblogs") + migrate_to = ("cm", "0118_extract_sub_actions_from_actions") + + def prepare(self): + Prototype = self.old_state.apps.get_model("cm", "Prototype") + Bundle = self.old_state.apps.get_model("cm", "Bundle") + bundle = Bundle.objects.create(name="cool", version="342", hash="lfj21opfijoi") + prototype = Prototype.objects.create(bundle=bundle, type="cluster", name="protoname", version="200.400") + + Action = self.old_state.apps.get_model("cm", "Action") + SubAction = self.old_state.apps.get_model("cm", "SubAction") + + self.action_1_data = { + "name": "simple_job", + "display_name": "Awesome And Simple", + "script": "path/to/script.yaml", + "script_type": ScriptType.ANSIBLE.value, + "state_on_fail": "failme", + "multi_state_on_fail_set": [], + "multi_state_on_fail_unset": ["cool"], + "params": {"ansible_tags": "some,thing,better", "jinja2_native": "yes", "custom": {"arbitrary": "stuff"}}, + } + self.action_1 = Action.objects.create( + prototype=prototype, + type="job", + state_on_success="best", + multi_state_on_success_set=["coolest"], + multi_state_on_success_unset=[], + allow_to_terminate=True, + **self.action_1_data, + ) + + self.action_2 = Action.objects.create( + prototype=prototype, + type="task", + state_on_success="best", + multi_state_on_success_set=["coolest"], + multi_state_on_success_unset=[], + **self.action_1_data | {"name": "simple_task"}, + ) + self.action_2_sub_1 = SubAction.objects.create( + action_id=self.action_2.pk, + name="step_1", + script="boom.lala", + script_type=ScriptType.ANSIBLE.value, + multi_state_on_fail_set=["heh"], + params={"another": "stuff"}, + ) + self.action_2_sub_2 = SubAction.objects.create( + action_id=self.action_2.pk, + name="step_2", + script="bundle_switch", + script_type=ScriptType.INTERNAL.value, + multi_state_on_fail_unset=["hoho"], + ) + + self.action_3_data = {"script": "./relative.py", "script_type": ScriptType.PYTHON.value, "params": {}} + self.action_3 = Action.objects.create( + prototype=prototype, + name="another_job", + type="job", + state_on_success="nothing", + multi_state_on_success_set=[], + multi_state_on_success_unset=["abs"], + allow_to_terminate=False, + **self.action_3_data, + ) + + self.sub_action_pre_migration_amount = SubAction.objects.count() + + def test_migration_0117_0118_move_data(self): + Action = self.new_state.apps.get_model("cm", "Action") + SubAction = self.new_state.apps.get_model("cm", "SubAction") + + self.assertEqual(Action.objects.count(), 3) + # 1 for each "job" typed action + self.assertEqual(SubAction.objects.count(), self.sub_action_pre_migration_amount + 2) + + new_action_1_sub = SubAction.objects.get(action_id=self.action_1.pk) + + for key, value in self.action_1_data.items(): + self.assertEqual(getattr(new_action_1_sub, key), value) + self.assertTrue(new_action_1_sub.allow_to_terminate) + + new_action_3_sub = SubAction.objects.get(action_id=self.action_3.pk) + + self.assertEqual(new_action_3_sub.name, self.action_3.name) + self.assertEqual(new_action_3_sub.display_name, self.action_3.display_name) + self.assertEqual(new_action_3_sub.script, self.action_3_data["script"]) + self.assertEqual(new_action_3_sub.script_type, self.action_3_data["script_type"]) + self.assertEqual(new_action_3_sub.params, {}) + self.assertEqual(new_action_3_sub.state_on_fail, "") + self.assertEqual(new_action_3_sub.multi_state_on_fail_set, []) + self.assertEqual(new_action_3_sub.multi_state_on_fail_unset, []) + self.assertFalse(new_action_3_sub.allow_to_terminate) + + +class TestReverseMigration(MigratorTestCase): + migrate_from = ("cm", "0118_extract_sub_actions_from_actions") + migrate_to = ("cm", "0117_post_autonomous_joblogs") + + def prepare(self): + Prototype = self.old_state.apps.get_model("cm", "Prototype") + Bundle = self.old_state.apps.get_model("cm", "Bundle") + bundle = Bundle.objects.create(name="cool", version="342", hash="lfj21opfijoi") + prototype = Prototype.objects.create(bundle=bundle, type="cluster", name="protoname", version="200.400") + + Action = self.old_state.apps.get_model("cm", "Action") + SubAction = self.old_state.apps.get_model("cm", "SubAction") + + self.action_1_data = { + "script": "path/to/script.yaml", + "script_type": ScriptType.ANSIBLE.value, + "state_on_fail": "failme", + "multi_state_on_fail_set": ["nice"], + "multi_state_on_fail_unset": ["cool"], + "params": {"ansible_tags": "some,thing,better", "jinja2_native": "yes", "custom": {"arbitrary": "stuff"}}, + } + self.action_1 = Action.objects.create( + prototype=prototype, + name="simple_job", + type="job", + state_on_success="best", + multi_state_on_success_set=["coolest"], + multi_state_on_success_unset=[], + allow_to_terminate=True, + state_on_fail="another", + multi_state_on_fail_set=["custom"], + multi_state_on_fail_unset=self.action_1_data["multi_state_on_fail_unset"], + ) + self.action_1_sub = SubAction.objects.create( + action_id=self.action_1.pk, name="another_name", display_name="another time", **self.action_1_data + ) + + self.action_2 = Action.objects.create( + prototype=prototype, + name="simple_task", + type="task", + state_on_success="best", + multi_state_on_success_set=["coolest"], + multi_state_on_success_unset=[], + **{k: v for k, v in self.action_1_data.items() if k not in {"script", "script_type", "params"}}, + ) + self.action_2_sub_1 = SubAction.objects.create( + action_id=self.action_2.pk, + name="step_1", + script="boom.lala", + script_type=ScriptType.ANSIBLE.value, + multi_state_on_fail_set=["heh"], + params={"another": "stuff"}, + ) + self.action_2_sub_2 = SubAction.objects.create( + action_id=self.action_2.pk, + name="step_2", + script="bundle_switch", + script_type=ScriptType.INTERNAL.value, + multi_state_on_fail_unset=["hoho"], + ) + + self.action_3_data = {"script": "./relative.py", "script_type": ScriptType.PYTHON.value, "params": {}} + self.action_3 = Action.objects.create( + prototype=prototype, + name="another_job", + type="job", + state_on_success="nothing", + multi_state_on_success_set=[], + multi_state_on_success_unset=["abs"], + ) + self.action_3_sub = SubAction.objects.create( + action_id=self.action_3.pk, name=self.action_3.name, **self.action_3_data + ) + + self.sub_action_pre_migration_amount = SubAction.objects.count() + + def test_migration_0118_to_0117_move_data(self): + Action = self.new_state.apps.get_model("cm", "Action") + SubAction = self.new_state.apps.get_model("cm", "SubAction") + + self.assertEqual(Action.objects.count(), 3) + # 1 for each "job" typed action + self.assertEqual(SubAction.objects.count(), self.sub_action_pre_migration_amount - 2) + + new_action_1 = Action.objects.get(id=self.action_1.pk) + + self.assertEqual(new_action_1.name, self.action_1.name) + self.assertEqual(new_action_1.display_name, self.action_1.display_name) + self.assertEqual(new_action_1.script, self.action_1_data["script"]) + self.assertEqual(new_action_1.script_type, self.action_1_data["script_type"]) + self.assertEqual(new_action_1.params, self.action_1_data["params"]) + self.assertEqual(new_action_1.state_on_fail, self.action_1.state_on_fail) + self.assertEqual(new_action_1.multi_state_on_fail_set, self.action_1.multi_state_on_fail_set) + self.assertEqual(new_action_1.multi_state_on_fail_unset, self.action_1_data["multi_state_on_fail_unset"]) + self.assertTrue(new_action_1.allow_to_terminate) + + new_action_3 = Action.objects.get(id=self.action_3.pk) + + self.assertEqual(new_action_3.name, self.action_3.name) + self.assertEqual(new_action_3.display_name, self.action_3.display_name) + self.assertEqual(new_action_3.script, self.action_3_data["script"]) + self.assertEqual(new_action_3.script_type, self.action_3_data["script_type"]) + self.assertEqual(new_action_3.params, self.action_3_data["params"]) + self.assertEqual(new_action_3.state_on_fail, self.action_3.state_on_fail) + self.assertEqual(new_action_3.multi_state_on_fail_set, self.action_3.multi_state_on_fail_set) + self.assertEqual(new_action_3.multi_state_on_fail_unset, self.action_3.multi_state_on_fail_unset) + self.assertFalse(new_action_3.allow_to_terminate) diff --git a/python/cm/tests/test_task_log.py b/python/cm/tests/test_task_log.py index ecc9bc28cb..b74169b6fa 100644 --- a/python/cm/tests/test_task_log.py +++ b/python/cm/tests/test_task_log.py @@ -94,7 +94,6 @@ def test_download_negative(self): display_name="Test cluster action", prototype=cluster.prototype, type="task", - script_type="ansible", state_available="any", name="test_cluster_action", ) diff --git a/python/cm/tests/utils.py b/python/cm/tests/utils.py index f84100bd87..1c9965d362 100644 --- a/python/cm/tests/utils.py +++ b/python/cm/tests/utils.py @@ -177,12 +177,7 @@ def gen_action(name: str | None = None, bundle=None, prototype=None) -> Action: bundle = bundle or gen_bundle() prototype = gen_prototype(bundle, "service") return Action.objects.create( - name=name or gen_name("action_"), - display_name=f"Test {prototype.type} action", - prototype=prototype, - type="task", - script="", - script_type="ansible", + name=name or gen_name("action_"), display_name=f"Test {prototype.type} action", prototype=prototype, type="task" ) diff --git a/python/rbac/tests/test_role.py b/python/rbac/tests/test_role.py index 9cd3b162c5..38951a3ef0 100644 --- a/python/rbac/tests/test_role.py +++ b/python/rbac/tests/test_role.py @@ -185,8 +185,6 @@ def setUp(self): self.cluster_action = Action.objects.create( name="cluster_action", type=ActionType.JOB, - script="./action.yaml", - script_type="ansible", state_available="any", prototype=self.clp, display_name="Cluster Action", @@ -194,8 +192,6 @@ def setUp(self): self.service1_action = Action.objects.create( name="service_1_action", type=ActionType.JOB, - script="./action.yaml", - script_type="ansible", state_available="any", prototype=self.sp_1, display_name="Service 1 Action", @@ -203,17 +199,12 @@ def setUp(self): self.component11_action = Action.objects.create( name="component_1_1_action", type=ActionType.JOB, - script="./action.yaml", - script_type="ansible", state_available="any", prototype=self.cop_11, display_name="Component 1 from Service 1 Action", ) self.component21_action = Action.objects.create( name="component_2_1_action", - type=ActionType.JOB, - script="./action.yaml", - script_type="ansible", state_available="any", prototype=self.cop_12, display_name="Component 2 from Service 1 Action", @@ -221,8 +212,6 @@ def setUp(self): self.service2_action = Action.objects.create( name="service_2_action", type=ActionType.JOB, - script="./action.yaml", - script_type="ansible", state_available="any", prototype=self.sp_2, display_name="Service 2 Action", @@ -230,8 +219,6 @@ def setUp(self): self.component12_action = Action.objects.create( name="component_1_2_action", type=ActionType.JOB, - script="./action.yaml", - script_type="ansible", state_available="any", prototype=self.cop_21, display_name="Component 1 from Service 2 Action", @@ -239,8 +226,6 @@ def setUp(self): self.component22_action = Action.objects.create( name="component_2_2_action", type=ActionType.JOB, - script="./action.yaml", - script_type="ansible", state_available="any", prototype=self.cop_22, display_name="Component 2 from Service 2 Action", From 9c405fafbfc1e2f85e3bd836c0e6486328ad8045 Mon Sep 17 00:00:00 2001 From: Dmitriy Bardin Date: Mon, 18 Mar 2024 08:24:50 +0000 Subject: [PATCH 004/208] ADCM-5365 - Adopt UI for BROKEN status from actions/tasks rework https://tracker.yandex.ru/ADCM-5365 --- .../JobsStatusIcon/JobsStatusIcon.constants.ts | 1 + .../JobsStatusIcon/JobsStatusIcon.module.scss | 6 ++++++ .../HeaderNotifications.module.scss | 4 ++++ .../HeaderNotifications/HeaderNotifications.tsx | 1 + .../JobPage/JobPageTable/JobPageTable.constants.ts | 1 + .../components/uikit/Statusable/Statusable.types.ts | 11 ++++++++++- adcm-web/app/src/models/adcm/jobs.ts | 1 + 7 files changed, 24 insertions(+), 1 deletion(-) diff --git a/adcm-web/app/src/components/common/Table/Cells/JobsStatusCell/JobsStatusIcon/JobsStatusIcon.constants.ts b/adcm-web/app/src/components/common/Table/Cells/JobsStatusCell/JobsStatusIcon/JobsStatusIcon.constants.ts index a87a76490f..3c060e3a97 100644 --- a/adcm-web/app/src/components/common/Table/Cells/JobsStatusCell/JobsStatusIcon/JobsStatusIcon.constants.ts +++ b/adcm-web/app/src/components/common/Table/Cells/JobsStatusCell/JobsStatusIcon/JobsStatusIcon.constants.ts @@ -8,4 +8,5 @@ export const jobStatusesIconsMap: { [key in AdcmJobStatus]: IconsNames } = { [AdcmJobStatus.Running]: 'g2-running-10x10', [AdcmJobStatus.Locked]: 'g2-locked-10x10', [AdcmJobStatus.Aborted]: 'g2-aborted-10x10', + [AdcmJobStatus.Broken]: 'g2-failed-10x10', }; diff --git a/adcm-web/app/src/components/common/Table/Cells/JobsStatusCell/JobsStatusIcon/JobsStatusIcon.module.scss b/adcm-web/app/src/components/common/Table/Cells/JobsStatusCell/JobsStatusIcon/JobsStatusIcon.module.scss index b6c27fedc9..eaeb61a5d4 100644 --- a/adcm-web/app/src/components/common/Table/Cells/JobsStatusCell/JobsStatusIcon/JobsStatusIcon.module.scss +++ b/adcm-web/app/src/components/common/Table/Cells/JobsStatusCell/JobsStatusIcon/JobsStatusIcon.module.scss @@ -5,6 +5,7 @@ --status-icon-color-aborted: var(--color-xdark); --status-icon-color-running: var(--color-xblue); --status-icon-color-created: var(--color-xwhite-off); + --status-icon-color-broken: var(--color-xgray-light); } body.theme-light { --status-icon-color-success: var(--color-xgreen-saturated); @@ -12,6 +13,7 @@ --status-icon-color-aborted: var(--color-xgray-light); --status-icon-color-running: var(--color-xblue); --status-icon-color-created: var(--color-xdark); + --status-icon-color-broken: var(--color-xgray-light); } } @@ -35,4 +37,8 @@ &_aborted { color: var(--status-icon-color-aborted); } + + &_broken { + color: var(--status-icon-color-broken); + } } diff --git a/adcm-web/app/src/components/layouts/partials/HeaderNotifications/HeaderNotifications.module.scss b/adcm-web/app/src/components/layouts/partials/HeaderNotifications/HeaderNotifications.module.scss index fe40d07c3f..1377565b41 100644 --- a/adcm-web/app/src/components/layouts/partials/HeaderNotifications/HeaderNotifications.module.scss +++ b/adcm-web/app/src/components/layouts/partials/HeaderNotifications/HeaderNotifications.module.scss @@ -69,6 +69,10 @@ &_aborted { --bell-marker-color: var(--color-xgray-light); } + + &_broken { + --bell-marker-color: var(--color-xgray-light); + } } .bellPopoverPanel { diff --git a/adcm-web/app/src/components/layouts/partials/HeaderNotifications/HeaderNotifications.tsx b/adcm-web/app/src/components/layouts/partials/HeaderNotifications/HeaderNotifications.tsx index 4e1807eb24..bef6066340 100644 --- a/adcm-web/app/src/components/layouts/partials/HeaderNotifications/HeaderNotifications.tsx +++ b/adcm-web/app/src/components/layouts/partials/HeaderNotifications/HeaderNotifications.tsx @@ -34,6 +34,7 @@ const HeaderNotifications: React.FC = () => { [s.headerNotifications_running]: status === AdcmJobStatus.Running, [s.headerNotifications_locked]: status === AdcmJobStatus.Locked, [s.headerNotifications_aborted]: status === AdcmJobStatus.Aborted, + [s.headerNotifications_broken]: status === AdcmJobStatus.Broken, }, ); diff --git a/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPageTable/JobPageTable.constants.ts b/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPageTable/JobPageTable.constants.ts index 11da25dd7c..3efc839662 100644 --- a/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPageTable/JobPageTable.constants.ts +++ b/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPageTable/JobPageTable.constants.ts @@ -31,4 +31,5 @@ export const jobStatusesMap: { [key in AdcmJobStatus]: BaseStatus } = { [AdcmJobStatus.Failed]: 'failed', [AdcmJobStatus.Aborted]: 'aborted', [AdcmJobStatus.Locked]: 'locked', + [AdcmJobStatus.Broken]: 'broken', }; diff --git a/adcm-web/app/src/components/uikit/Statusable/Statusable.types.ts b/adcm-web/app/src/components/uikit/Statusable/Statusable.types.ts index cc9d7cb4ca..076cd0436a 100644 --- a/adcm-web/app/src/components/uikit/Statusable/Statusable.types.ts +++ b/adcm-web/app/src/components/uikit/Statusable/Statusable.types.ts @@ -1 +1,10 @@ -export type BaseStatus = 'done' | 'running' | 'failed' | 'aborted' | 'created' | 'success' | 'locked' | 'unknown'; +export type BaseStatus = + | 'done' + | 'running' + | 'failed' + | 'aborted' + | 'created' + | 'success' + | 'locked' + | 'unknown' + | 'broken'; diff --git a/adcm-web/app/src/models/adcm/jobs.ts b/adcm-web/app/src/models/adcm/jobs.ts index cdf475295b..fb0c77e955 100644 --- a/adcm-web/app/src/models/adcm/jobs.ts +++ b/adcm-web/app/src/models/adcm/jobs.ts @@ -5,6 +5,7 @@ export enum AdcmJobStatus { Running = 'running', Locked = 'locked', Aborted = 'aborted', + Broken = 'broken', } export enum AdcmJobObjectType { From 93c872247d637b16be97da7f4a9fba2ed1eb8221 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Mon, 18 Mar 2024 10:14:16 +0000 Subject: [PATCH 005/208] ADCM-5345 Added linter for migrations and fix incorrect imports --- Makefile | 14 +-- dev/linters/migrations_checker.py | 101 ++++++++++++++++++ .../cm/migrations/0037_auto_20191120_1600.py | 3 - .../cm/migrations/0057_auto_20200831_1055.py | 9 +- .../cm/migrations/0058_encrypt_passwords.py | 21 +++- .../cm/migrations/0060_auto_20201201_1122.py | 7 +- .../cm/migrations/0067_tasklog_object_type.py | 12 ++- .../cm/migrations/0076_auto_20211013_1250.py | 5 +- .../migrations/0078_alter_groupconfig_name.py | 10 +- .../cm/migrations/0081_auto_20220114_1246.py | 9 +- .../cm/migrations/0102_auto_20230119_0755.py | 9 +- .../cm/migrations/0103_auto_20230131_0946.py | 11 +- python/rbac/migrations/0001_initial.py | 14 ++- 13 files changed, 191 insertions(+), 34 deletions(-) create mode 100644 dev/linters/migrations_checker.py diff --git a/Makefile b/Makefile index 0b0c2dcb54..7986e4662d 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ APP_TAG ?= $(subst /,_,$(BRANCH_NAME)) SELENOID_HOST ?= 10.92.2.65 SELENOID_PORT ?= 4444 ADCM_VERSION = "2.1.0" +PY_FILES = license_checker.py python dev/linters .PHONY: help @@ -35,16 +36,17 @@ unittests_postgresql: pretty: poetry install --no-root --with lint - poetry run python license_checker.py --fix --folders python go - poetry run ruff format license_checker.py python - poetry run ruff check --fix license_checker.py python + poetry run python license_checker.py --fix --folders dev/linters python go poetry run ruff format license_checker.py python + poetry run ruff check --fix $(PY_FILES) + poetry run ruff format $(PY_FILES) lint: poetry install --no-root --with lint - poetry run python license_checker.py --folders python go - poetry run ruff check license_checker.py python - poetry run ruff format --check python + poetry run python license_checker.py --folders dev/linters python go + poetry run ruff check $(PY_FILES) + poetry run ruff format --check $(PY_FILES) + poetry run python dev/linters/migrations_checker.py python version: @echo $(ADCM_VERSION) diff --git a/dev/linters/migrations_checker.py b/dev/linters/migrations_checker.py new file mode 100644 index 0000000000..0be2a1b4ce --- /dev/null +++ b/dev/linters/migrations_checker.py @@ -0,0 +1,101 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from enum import Enum +from pathlib import Path +from typing import Iterable, NamedTuple +import os +import ast +import sys +import argparse + +ALLOWED_IMPORTS = ( + "ansible", + "datetime", + "django", + "hashlib", + "json", + "os", + "typing", + "uuid", +) + + +class Outcome(Enum): + OK = "ok" + ERROR = "error" + + +class CheckResult(NamedTuple): + file: Path + outcome: Outcome + comment: str + + +def find_migration_files(root: Path) -> Iterable[Path]: + return filter(Path.is_file, root.rglob("**/migrations/*.py")) + + +def check_files(migration_files: Iterable[Path]) -> Iterable[CheckResult]: + for file in migration_files: + parsed = ast.parse(file.read_text()) + errors = tuple(check_imports(module=parsed)) + if not errors: + yield CheckResult(file=file, outcome=Outcome.OK, comment="") + continue + + for incorrect_node, disallowed_imports in errors: + line_info = f"{file}:{incorrect_node.lineno}" + yield CheckResult( + file=file, + outcome=Outcome.ERROR, + comment=f"{line_info} - Not allowed imports: {', '.join(disallowed_imports)}", + ) + + +def check_imports(module: ast.Module) -> Iterable[tuple[ast.Import | ast.ImportFrom, tuple[str, ...]]]: + for import_node in filter(lambda node: isinstance(node, (ast.Import, ast.ImportFrom)), ast.walk(module)): + if isinstance(import_node, ast.Import): + roots = (import_.name.split(".")[0] for import_ in import_node.names) + else: + roots = (import_node.module.split(".")[0],) + + disallowed_imports = tuple(root for root in roots if root not in ALLOWED_IMPORTS) + if disallowed_imports: + yield import_node, disallowed_imports + + +def main(): + parser = argparse.ArgumentParser(description='Checker for migrations ("foreign" imports)') + parser.add_argument("root", help="Directory to search from", type=Path) + args = parser.parse_args() + + if not args.root.is_dir(): + sys.stdout.write(f"Not a directory: {args.root}") + sys.exit(2) + + fails = list( + filter( + lambda result: result.outcome == Outcome.ERROR, + check_files(migration_files=find_migration_files(root=args.root)), + ) + ) + + if fails: + sys.stdout.write(f"Some files [{len(fails)}] contain foreign foreign imports:{os.linesep}") + sys.stdout.write(os.linesep.join(result.comment for result in fails)) + sys.stdout.write(os.linesep) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/python/cm/migrations/0037_auto_20191120_1600.py b/python/cm/migrations/0037_auto_20191120_1600.py index f63c97b83d..0a99bc0eb1 100644 --- a/python/cm/migrations/0037_auto_20191120_1600.py +++ b/python/cm/migrations/0037_auto_20191120_1600.py @@ -13,7 +13,6 @@ # Generated by Django 2.2.1 on 2019-11-20 16:00 import json -from cm.logger import logger from django.db import migrations @@ -29,7 +28,6 @@ def fix_task(apps, schema_editor): if action.prototype.type == "service": if "service" not in selector: selector["service"] = task.object_id - logger.debug("update task #%s new selector: %s", task.id, selector) task.selector = json.dumps(selector) task.save() @@ -48,7 +46,6 @@ def fix_job(apps, schema_editor): if action.prototype.type == "service": if "service" not in selector: selector["service"] = task.object_id - logger.debug("update job #%s new selector: %s", job.id, selector) job.selector = json.dumps(selector) job.save() diff --git a/python/cm/migrations/0057_auto_20200831_1055.py b/python/cm/migrations/0057_auto_20200831_1055.py index dfd1e4c553..9a166c0822 100644 --- a/python/cm/migrations/0057_auto_20200831_1055.py +++ b/python/cm/migrations/0057_auto_20200831_1055.py @@ -12,10 +12,13 @@ # Generated by Django 3.1 on 2020-08-31 10:55 -import cm.models from django.db import migrations, models +def get_default(): + return ["community"] + + def fix_default_json_fields_component(apps, schema_editor): Component = apps.get_model("cm", "Component") Component.objects.filter(constraint__exact="").update(constraint='[0, "+"]') @@ -262,7 +265,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="stageupgrade", name="from_edition", - field=models.JSONField(default=cm.models.get_default_from_edition), + field=models.JSONField(default=get_default), ), migrations.AlterField( model_name="stageupgrade", @@ -302,7 +305,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="upgrade", name="from_edition", - field=models.JSONField(default=cm.models.get_default_from_edition), + field=models.JSONField(default=get_default), ), migrations.AlterField( model_name="upgrade", diff --git a/python/cm/migrations/0058_encrypt_passwords.py b/python/cm/migrations/0058_encrypt_passwords.py index 818630e9a5..8fb8e4ea15 100644 --- a/python/cm/migrations/0058_encrypt_passwords.py +++ b/python/cm/migrations/0058_encrypt_passwords.py @@ -12,11 +12,28 @@ import json -from cm.adcm_config.ansible import ansible_encrypt_and_format -from cm.utils import obj_to_dict +from ansible.parsing.vault import VaultAES256, VaultSecret +from django.conf import settings from django.db import migrations +def obj_to_dict(obj, keys) -> dict: + dictionary = {} + for key in keys: + if hasattr(obj, key): + dictionary[key] = getattr(obj, key) + + return dictionary + + +def ansible_encrypt_and_format(msg: str) -> str: + vault = VaultAES256() + secret = VaultSecret(_bytes=bytes(settings.ANSIBLE_SECRET, "utf-8")) + ciphertext = vault.encrypt(b_plaintext=bytes(msg, "utf-8"), secret=secret) + + return f"{settings.ANSIBLE_VAULT_HEADER}\n{str(ciphertext, settings.ENCODING_UTF_8)}" + + def get_prototype_config(proto, PrototypeConfig): spec = {} flist = ("default", "required", "type", "limits") diff --git a/python/cm/migrations/0060_auto_20201201_1122.py b/python/cm/migrations/0060_auto_20201201_1122.py index 6e288d4757..f5df1e1ff9 100644 --- a/python/cm/migrations/0060_auto_20201201_1122.py +++ b/python/cm/migrations/0060_auto_20201201_1122.py @@ -12,10 +12,11 @@ # Generated by Django 3.1.2 on 2020-12-01 11:22 -import cm.models import django.db.models.deletion from django.db import migrations, models +def get_default_constraint(): + return [0, "+"] def create_component_prototype(apps, schema_editor): Prototype = apps.get_model("cm", "Prototype") @@ -77,7 +78,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name="prototype", name="constraint", - field=models.JSONField(default=cm.models.get_default_constraint), + field=models.JSONField(default=get_default_constraint), ), migrations.AddField( model_name="prototype", @@ -127,7 +128,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name="stageprototype", name="constraint", - field=models.JSONField(default=cm.models.get_default_constraint), + field=models.JSONField(default=get_default_constraint), ), migrations.AddField( model_name="stageprototype", diff --git a/python/cm/migrations/0067_tasklog_object_type.py b/python/cm/migrations/0067_tasklog_object_type.py index 4d4fc25b28..d9eb50e1ad 100644 --- a/python/cm/migrations/0067_tasklog_object_type.py +++ b/python/cm/migrations/0067_tasklog_object_type.py @@ -15,7 +15,17 @@ import django.db.models.deletion from django.db import migrations, models -from adcm.utils import OBJECT_TYPES_DICT +OBJECT_TYPES_DICT = { + "adcm": "adcm", + "cluster": "cluster", + "service": "clusterobject", + "cluster object": "service", + "component": "servicecomponent", + "service component": "servicecomponent", + "provider": "hostprovider", + "host provider": "hostprovider", + "host": "host", +} def fix_tasklog(apps, schema_editor): diff --git a/python/cm/migrations/0076_auto_20211013_1250.py b/python/cm/migrations/0076_auto_20211013_1250.py index 1ac1dbc971..6da5840b29 100644 --- a/python/cm/migrations/0076_auto_20211013_1250.py +++ b/python/cm/migrations/0076_auto_20211013_1250.py @@ -12,7 +12,6 @@ # Generated by Django 3.2 on 2021-10-13 12:50 -import cm.models from django.db import migrations, models @@ -25,11 +24,11 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="action", name="multi_state_available", - field=models.JSONField(default=cm.models.get_any), + field=models.JSONField(default="any"), ), migrations.AlterField( model_name="stageaction", name="multi_state_available", - field=models.JSONField(default=cm.models.get_any), + field=models.JSONField(default="any"), ), ] diff --git a/python/cm/migrations/0078_alter_groupconfig_name.py b/python/cm/migrations/0078_alter_groupconfig_name.py index b799681b41..d364c8850c 100644 --- a/python/cm/migrations/0078_alter_groupconfig_name.py +++ b/python/cm/migrations/0078_alter_groupconfig_name.py @@ -12,7 +12,7 @@ # Generated by Django 3.2.6 on 2021-10-25 07:31 -import cm.models +from django.core.exceptions import ValidationError from django.db import migrations, models @@ -24,6 +24,12 @@ def remove_line_break_character(apps, schema_editor): gc.save() +def validate_line_break_character(value: str) -> None: + """Check line break character in CharField""" + if len(value.splitlines()) > 1: + raise ValidationError("the string field contains a line break character") + + class Migration(migrations.Migration): dependencies = [ ("cm", "0077_job_lock_message_tpl"), @@ -33,7 +39,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="groupconfig", name="name", - field=models.CharField(max_length=30, validators=[cm.models.validate_line_break_character]), + field=models.CharField(max_length=30, validators=[validate_line_break_character]), ), migrations.RunPython(remove_line_break_character), ] diff --git a/python/cm/migrations/0081_auto_20220114_1246.py b/python/cm/migrations/0081_auto_20220114_1246.py index 78bfd340a4..d88a51c130 100644 --- a/python/cm/migrations/0081_auto_20220114_1246.py +++ b/python/cm/migrations/0081_auto_20220114_1246.py @@ -12,10 +12,13 @@ # Generated by Django 3.2.9 on 2022-01-14 12:46 -import cm.models from django.db import migrations, models +def get_default(): + return {"state": None} + + class Migration(migrations.Migration): dependencies = [ ("cm", "0080_subaction_multi_states"), @@ -25,11 +28,11 @@ class Migration(migrations.Migration): migrations.AddField( model_name="cluster", name="before_upgrade", - field=models.JSONField(default=cm.models.get_default_before_upgrade), + field=models.JSONField(default=get_default), ), migrations.AddField( model_name="hostprovider", name="before_upgrade", - field=models.JSONField(default=cm.models.get_default_before_upgrade), + field=models.JSONField(default=get_default), ), ] diff --git a/python/cm/migrations/0102_auto_20230119_0755.py b/python/cm/migrations/0102_auto_20230119_0755.py index d5d93a154b..d1a44ecf66 100644 --- a/python/cm/migrations/0102_auto_20230119_0755.py +++ b/python/cm/migrations/0102_auto_20230119_0755.py @@ -12,9 +12,14 @@ # Generated by Django 3.2.16 on 2023-01-19 07:55 -import cm.models +from django.core.exceptions import ValidationError + from django.db import migrations, models +def validate_line_break_character(value: str) -> None: + """Check line break character in CharField""" + if len(value.splitlines()) > 1: + raise ValidationError("the string field contains a line break character") class Migration(migrations.Migration): dependencies = [ @@ -141,7 +146,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="groupconfig", name="name", - field=models.CharField(max_length=1000, validators=[cm.models.validate_line_break_character]), + field=models.CharField(max_length=1000, validators=[validate_line_break_character]), ), migrations.AlterField( model_name="host", diff --git a/python/cm/migrations/0103_auto_20230131_0946.py b/python/cm/migrations/0103_auto_20230131_0946.py index f4cdfe8061..860f96f637 100644 --- a/python/cm/migrations/0103_auto_20230131_0946.py +++ b/python/cm/migrations/0103_auto_20230131_0946.py @@ -12,10 +12,13 @@ # Generated by Django 3.2.15 on 2023-01-31 09:46 -import cm.models from django.db import migrations, models +def get_default(): + return {"state": None} + + class Migration(migrations.Migration): dependencies = [ ("cm", "0102_auto_20230119_0755"), @@ -25,16 +28,16 @@ class Migration(migrations.Migration): migrations.AddField( model_name="clusterobject", name="before_upgrade", - field=models.JSONField(default=cm.models.get_default_before_upgrade), + field=models.JSONField(default=get_default), ), migrations.AddField( model_name="host", name="before_upgrade", - field=models.JSONField(default=cm.models.get_default_before_upgrade), + field=models.JSONField(default=get_default), ), migrations.AddField( model_name="servicecomponent", name="before_upgrade", - field=models.JSONField(default=cm.models.get_default_before_upgrade), + field=models.JSONField(default=get_default), ), ] diff --git a/python/rbac/migrations/0001_initial.py b/python/rbac/migrations/0001_initial.py index f0bf5fd568..3794c7f454 100644 --- a/python/rbac/migrations/0001_initial.py +++ b/python/rbac/migrations/0001_initial.py @@ -14,9 +14,19 @@ import django.contrib.auth.models import django.db.models.deletion -import rbac.models +from django.core.exceptions import ValidationError + from django.db import connection, migrations, models +types = {"cluster","service", "component", "provider", "host"} + + +def validate(value): + if not isinstance(value, list): + raise ValidationError("Not a valid list.") + + if not all(v in types for v in value): + raise ValidationError("Not a valid object type.") def upgrade_users(apps, schema_editor): query = """ @@ -133,7 +143,7 @@ class Migration(migrations.Migration): ("any_category", models.BooleanField(default=False)), ( "parametrized_by_type", - models.JSONField(default=list, validators=[rbac.models.validate_object_type]), + models.JSONField(default=list, validators=[validate]), ), ( "bundle", From c6d228a959600d69e1cda5da79986facf436225a Mon Sep 17 00:00:00 2001 From: astarovo Date: Mon, 18 Mar 2024 12:04:57 +0300 Subject: [PATCH 006/208] ADCM-5347: Check and add tests for status for APIv2 --- python/adcm/utils.py | 2 +- python/api_v2/tests/test_actions.py | 76 ++++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/python/adcm/utils.py b/python/adcm/utils.py index 5bd3e7090f..bb9ce48fdc 100644 --- a/python/adcm/utils.py +++ b/python/adcm/utils.py @@ -348,7 +348,7 @@ def delete_service_from_api(service: ClusterObject) -> Response: cluster = service.cluster - if cluster.state == "upgrading" and service.prototype.name in cluster.before_upgrade["services"]: + if cluster.state == "upgrading" and service.prototype.name in cluster.before_upgrade.get("services", ()): raise AdcmEx(code="SERVICE_CONFLICT", msg="Can't remove service when upgrading cluster") if ClusterBind.objects.filter(source_service=service).exists(): diff --git a/python/api_v2/tests/test_actions.py b/python/api_v2/tests/test_actions.py index c5dd4c3375..74363cff39 100644 --- a/python/api_v2/tests/test_actions.py +++ b/python/api_v2/tests/test_actions.py @@ -31,7 +31,7 @@ from rbac.services.group import create as create_group from rbac.services.policy import policy_create from rbac.services.role import role_create -from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT +from rest_framework.status import HTTP_200_OK, HTTP_204_NO_CONTENT, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT from api_v2.tests.base import BaseAPITestCase @@ -118,6 +118,80 @@ def setUp(self) -> None: self.flag_multi_state = "flag" self.bag_multi_state = "bag" + def test_upgrading_status_host_remove_fail(self) -> None: + self.add_host_to_cluster(self.cluster_1, self.host_1) + self.cluster_1.set_state("upgrading") + + response = self.client.delete( + path=reverse( + viewname="v2:host-cluster-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.host_1.pk} + ), + ) + + self.assertEqual(response.status_code, HTTP_409_CONFLICT) + self.assertDictEqual( + response.json(), + { + "code": "HOST_CONFLICT", + "desc": "It is forbidden to delete host from cluster in upgrade mode", + "level": "error", + }, + ) + + def test_upgrading_status_foreign_host_remove_fail(self) -> None: + self.cluster_1.set_state("upgrading") + + response = self.client.delete( + path=reverse( + viewname="v2:host-cluster-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.host_1.pk} + ), + ) + + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + + def test_upgrading_status_service_remove_fail(self) -> None: + service_1 = self.add_services_to_cluster(service_names=["service_1"], cluster=self.cluster_1).get() + self.cluster_1.set_state("upgrading") + self.cluster_1.before_upgrade["services"] = [ + service.prototype.name for service in ClusterObject.objects.filter(cluster=self.cluster_1) + ] + self.cluster_1.save() + + response = self.client.delete( + path=reverse(viewname="v2:service-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": service_1.pk}), + ) + self.assertEqual(response.status_code, HTTP_409_CONFLICT) + self.assertDictEqual( + response.json(), + { + "code": "SERVICE_CONFLICT", + "desc": "Can't remove service when upgrading cluster", + "level": "error", + }, + ) + + def test_upgrading_status_service_success(self) -> None: + service_1 = self.add_services_to_cluster(service_names=["service_1"], cluster=self.cluster_1).get() + self.cluster_1.set_state("upgrading") + + response = self.client.delete( + path=reverse(viewname="v2:service-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": service_1.pk}), + ) + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) + + def test_upgrading_status_foreign_service_remove_fail(self) -> None: + self.cluster_1.set_state("upgrading") + self.cluster_1.before_upgrade["services"] = [ + service.prototype.name for service in ClusterObject.objects.filter(cluster=self.cluster_1) + ] + + response = self.client.delete( + path=reverse( + viewname="v2:service-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_1.pk} + ), + ) + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + def test_filter_object_own_actions_success(self) -> None: for object_ in (self.cluster, self.service_1, self.component_1, self.hostprovider, self.host_1): viewname, object_kwargs = get_viewname_and_kwargs_for_object(object_=object_) From ec378629b5c5c999d6e029206e1d0707933cfe1d Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Wed, 20 Mar 2024 07:02:30 +0000 Subject: [PATCH 007/208] ADCM-5340: add tests for action's params --- python/adcm/tests/base.py | 34 ++-- .../cluster_with_action_params/config.yaml | 86 +++++++++ .../cluster_with_action_params/sleep.yaml | 12 ++ python/cm/tests/test_action.py | 174 +++++++++++++++++- 4 files changed, 289 insertions(+), 17 deletions(-) create mode 100755 python/cm/tests/bundles/cluster_with_action_params/config.yaml create mode 100755 python/cm/tests/bundles/cluster_with_action_params/sleep.yaml diff --git a/python/adcm/tests/base.py b/python/adcm/tests/base.py index f9667c6f08..3ce4582740 100644 --- a/python/adcm/tests/base.py +++ b/python/adcm/tests/base.py @@ -102,7 +102,23 @@ def _prepare_temporal_directories_for_adcm() -> dict: return temporary_directories -class BaseTestCase(TestCase, ParallelReadyTestCase): +class BundleLogicMixin: + @staticmethod + def prepare_bundle_file(source_dir: Path) -> str: + bundle_file = f"{source_dir.name}.tar" + with tarfile.open(settings.DOWNLOAD_DIR / bundle_file, "w") as tar: + for file in source_dir.iterdir(): + tar.add(name=file, arcname=file.name) + + return bundle_file + + def add_bundle(self, source_dir: Path) -> Bundle: + bundle_file = self.prepare_bundle_file(source_dir=source_dir) + bundle_hash, path = process_file(bundle_file=bundle_file) + return prepare_bundle(bundle_file=bundle_file, bundle_hash=bundle_hash, path=path) + + +class BaseTestCase(TestCase, ParallelReadyTestCase, BundleLogicMixin): def setUp(self) -> None: self.test_user_username = "test_user" self.test_user_password = "test_user_password" @@ -423,21 +439,7 @@ def get_random_str_num(length: int) -> str: return "".join(random.sample(f"{string.ascii_letters}{string.digits}", length)) -class BusinessLogicMixin: - @staticmethod - def prepare_bundle_file(source_dir: Path) -> str: - bundle_file = f"{source_dir.name}.tar" - with tarfile.open(settings.DOWNLOAD_DIR / bundle_file, "w") as tar: - for file in source_dir.iterdir(): - tar.add(name=file, arcname=file.name) - - return bundle_file - - def add_bundle(self, source_dir: Path) -> Bundle: - bundle_file = self.prepare_bundle_file(source_dir=source_dir) - bundle_hash, path = process_file(bundle_file=bundle_file) - return prepare_bundle(bundle_file=bundle_file, bundle_hash=bundle_hash, path=path) - +class BusinessLogicMixin(BundleLogicMixin): @staticmethod def add_cluster(bundle: Bundle, name: str, description: str = "") -> Cluster: prototype = Prototype.objects.filter(bundle=bundle, type=ObjectType.CLUSTER).first() diff --git a/python/cm/tests/bundles/cluster_with_action_params/config.yaml b/python/cm/tests/bundles/cluster_with_action_params/config.yaml new file mode 100755 index 0000000000..d083b8e3c0 --- /dev/null +++ b/python/cm/tests/bundles/cluster_with_action_params/config.yaml @@ -0,0 +1,86 @@ +--- +- type: cluster + name: cluster_with_action_params + display_name: cluster_with_action_params + version: &version '1.0' + edition: community + config_group_customization: true + actions: + action_full: &action + type: job + allow_to_terminate: true + script: ./sleep.yaml + script_type: ansible + params: + ansible_tags: ansible_tag1, ansible_tag2 + jinja2_native: yes + custom_str: custom_str_value + custom_list: [1, "two", 3.0] + custom_map: + 1: "two" + three: 4.0 + five: 6 + states: + available: any + action_jinja2Native_false: + type: job + allow_to_terminate: true + script: ./sleep.yaml + script_type: ansible + params: + ansible_tags: ansible_tag1, ansible_tag2 + jinja2_native: no + custom_str: custom_str_value + custom_list: [1, "two", 3.0] + custom_map: + 1: "two" + three: 4.0 + five: 6 + states: + available: any + action_jinja2Native_absent: + type: job + allow_to_terminate: true + script: ./sleep.yaml + script_type: ansible + params: + ansible_tags: ansible_tag1, ansible_tag2 + custom_str: custom_str_value + custom_list: [1, "two", 3.0] + custom_map: + 1: "two" + three: 4.0 + five: 6 + states: + available: any + action_ansibleTags_absent: + type: job + allow_to_terminate: true + script: ./sleep.yaml + script_type: ansible + params: + jinja2_native: yes + custom_str: custom_str_value + custom_list: [1, "two", 3.0] + custom_map: + 1: "two" + three: 4.0 + five: 6 + states: + available: any + action_customFields_absent: + type: job + allow_to_terminate: true + script: ./sleep.yaml + script_type: ansible + params: + ansible_tags: ansible_tag1, ansible_tag2 + jinja2_native: yes + states: + available: any + config: &config + - name: string + type: string + required: false + default: string + diff --git a/python/cm/tests/bundles/cluster_with_action_params/sleep.yaml b/python/cm/tests/bundles/cluster_with_action_params/sleep.yaml new file mode 100755 index 0000000000..f2a3cfadc4 --- /dev/null +++ b/python/cm/tests/bundles/cluster_with_action_params/sleep.yaml @@ -0,0 +1,12 @@ +--- +- name: sleep + hosts: all + connection: local + gather_facts: no + + tasks: + - name: Sleep + pause: + seconds: "1" + - debug: + msg: "{{ hostvars }}" diff --git a/python/cm/tests/test_action.py b/python/cm/tests/test_action.py index 41df864975..3239d829ed 100644 --- a/python/cm/tests/test_action.py +++ b/python/cm/tests/test_action.py @@ -9,7 +9,10 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + +from configparser import ConfigParser from pathlib import Path +import json from adcm.tests.base import BaseTestCase from django.urls import reverse @@ -17,7 +20,9 @@ from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_409_CONFLICT from cm.api import add_hc, add_service_to_cluster -from cm.models import Action, MaintenanceMode, Prototype, ServiceComponent +from cm.job import prepare_job +from cm.models import Action, MaintenanceMode, Prototype, ServiceComponent, TaskLog +from cm.services.job.utils import JobScope from cm.tests.utils import ( gen_action, gen_bundle, @@ -405,3 +410,170 @@ def test_service_mm_affects_cluster_actions_success(self): "start_impossible_reason" ] ) + + +class TestActionParams(BaseTestCase): + def setUp(self) -> None: + super().setUp() + + bundle = self.add_bundle( + source_dir=self.base_dir / "python" / "cm" / "tests" / "bundles" / "cluster_with_action_params" + ) + + self.cluster = self.create_cluster(bundle_pk=bundle.pk, name="test_cluster_with_action_params") + + self.action_full = Action.objects.get(prototype=self.cluster.prototype, name="action_full") + self.action_jinja_2_native_false = Action.objects.get( + prototype=self.cluster.prototype, name="action_jinja2Native_false" + ) + self.action_jinja_2_native_absent = Action.objects.get( + prototype=self.cluster.prototype, name="action_jinja2Native_absent" + ) + self.action_ansible_tags_absent = Action.objects.get( + prototype=self.cluster.prototype, name="action_ansibleTags_absent" + ) + self.action_custom_fields_absent = Action.objects.get( + prototype=self.cluster.prototype, name="action_customFields_absent" + ) + + def _generate_and_read_target_files(self, action_pk: int) -> tuple[ConfigParser, dict]: + response = self.client.post( + path=reverse( + viewname="v2:cluster-action-run", + kwargs={ + "cluster_pk": self.cluster.pk, + "pk": action_pk, + }, + ), + ) + self.assertEqual(response.status_code, HTTP_200_OK) + + task_id = response.json()["id"] + job = TaskLog.objects.get(pk=task_id).joblog_set.get() + + prepare_job(job_scope=JobScope(job_id=job.pk, object=self.cluster), delta={}) + + ansible_cfg_file: Path = self.directories["RUN_DIR"] / str(task_id) / "ansible.cfg" + config_json_file: Path = self.directories["RUN_DIR"] / str(task_id) / "config.json" + + if not ansible_cfg_file.is_file() or not config_json_file.is_file(): + raise ValueError("Not all files exist") + + config_parser = ConfigParser() + config_parser.read(ansible_cfg_file.absolute()) + + return config_parser, json.loads(config_json_file.read_text(encoding="utf-8")) + + def test_params_full(self): + expexted_ansible_cfg = { + "defaults": ( + ("stdout_callback", "yaml"), + ("callback_whitelist", "profile_tasks"), + ("forks", "5"), + ("jinja2_native", "True"), + ) + } + expected_job_params = { + "ansible_tags": "ansible_tag1, ansible_tag2", + "custom_list": [1, "two", 3.0], + "custom_map": {"1": "two", "five": 6, "three": 4.0}, + "custom_str": "custom_str_value", + "jinja2_native": True, + } + + ansible_cfg_content, config_json_content = self._generate_and_read_target_files(action_pk=self.action_full.pk) + + self.assertListEqual(ansible_cfg_content.sections(), list(expexted_ansible_cfg.keys())) + self.assertSetEqual(set(ansible_cfg_content.items("defaults")), set(expexted_ansible_cfg["defaults"])) + self.assertDictEqual(config_json_content["job"]["params"], expected_job_params) + + def test_params_jinja_2_native_false(self): + expexted_ansible_cfg = { + "defaults": ( + ("stdout_callback", "yaml"), + ("callback_whitelist", "profile_tasks"), + ("forks", "5"), + ("jinja2_native", "False"), + ) + } + expected_job_params = { + "ansible_tags": "ansible_tag1, ansible_tag2", + "custom_list": [1, "two", 3.0], + "custom_map": {"1": "two", "five": 6, "three": 4.0}, + "custom_str": "custom_str_value", + "jinja2_native": False, + } + + ansible_cfg_content, config_json_content = self._generate_and_read_target_files( + action_pk=self.action_jinja_2_native_false.pk + ) + + self.assertListEqual(ansible_cfg_content.sections(), list(expexted_ansible_cfg.keys())) + self.assertSetEqual(set(ansible_cfg_content.items("defaults")), set(expexted_ansible_cfg["defaults"])) + self.assertDictEqual(config_json_content["job"]["params"], expected_job_params) + + def test_params_jinja_2_native_absent(self): + expexted_ansible_cfg = { + "defaults": ( + ("stdout_callback", "yaml"), + ("callback_whitelist", "profile_tasks"), + ("forks", "5"), + ) + } + expected_job_params = { + "ansible_tags": "ansible_tag1, ansible_tag2", + "custom_list": [1, "two", 3.0], + "custom_map": {"1": "two", "five": 6, "three": 4.0}, + "custom_str": "custom_str_value", + } + + ansible_cfg_content, config_json_content = self._generate_and_read_target_files( + action_pk=self.action_jinja_2_native_absent.pk + ) + + self.assertListEqual(ansible_cfg_content.sections(), list(expexted_ansible_cfg.keys())) + self.assertSetEqual(set(ansible_cfg_content.items("defaults")), set(expexted_ansible_cfg["defaults"])) + self.assertDictEqual(config_json_content["job"]["params"], expected_job_params) + + def test_params_ansible_tags_absent(self): + expexted_ansible_cfg = { + "defaults": ( + ("stdout_callback", "yaml"), + ("callback_whitelist", "profile_tasks"), + ("forks", "5"), + ("jinja2_native", "True"), + ) + } + expected_job_params = { + "custom_list": [1, "two", 3.0], + "custom_map": {"1": "two", "five": 6, "three": 4.0}, + "custom_str": "custom_str_value", + "jinja2_native": True, + } + + ansible_cfg_content, config_json_content = self._generate_and_read_target_files( + action_pk=self.action_ansible_tags_absent.pk + ) + + self.assertListEqual(ansible_cfg_content.sections(), list(expexted_ansible_cfg.keys())) + self.assertSetEqual(set(ansible_cfg_content.items("defaults")), set(expexted_ansible_cfg["defaults"])) + self.assertDictEqual(config_json_content["job"]["params"], expected_job_params) + + def test_params_custom_fields_absent(self): + expexted_ansible_cfg = { + "defaults": ( + ("stdout_callback", "yaml"), + ("callback_whitelist", "profile_tasks"), + ("forks", "5"), + ("jinja2_native", "True"), + ) + } + expected_job_params = {"ansible_tags": "ansible_tag1, ansible_tag2", "jinja2_native": True} + + ansible_cfg_content, config_json_content = self._generate_and_read_target_files( + action_pk=self.action_custom_fields_absent.pk + ) + + self.assertListEqual(ansible_cfg_content.sections(), list(expexted_ansible_cfg.keys())) + self.assertSetEqual(set(ansible_cfg_content.items("defaults")), set(expexted_ansible_cfg["defaults"])) + self.assertDictEqual(config_json_content["job"]["params"], expected_job_params) From ebcbf5d632d3c06cd10e0a38476899bf4afb4a6f Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Wed, 20 Mar 2024 07:08:41 +0000 Subject: [PATCH 008/208] ADCM-5346 Mock task runner for tests and tests rework Changed: 1. Fixed running upgrade with duplicated hc entries provided 2. `run_action`/`do_upgrade` patches reworked to use mock task runner Added: 1. Mock task runner to use in tests (successful jobs only for now) --- python/api/tests/test_host.py | 86 +++++++--- python/api/tests/test_job.py | 19 ++- python/api_v2/action/utils.py | 9 + python/api_v2/action/views.py | 5 +- python/api_v2/tests/test_actions.py | 26 ++- python/api_v2/tests/test_cluster.py | 24 ++- python/api_v2/tests/test_component.py | 10 +- python/api_v2/tests/test_host.py | 25 ++- python/api_v2/tests/test_host_provider.py | 10 +- python/api_v2/tests/test_service.py | 9 +- python/api_v2/tests/test_upgrade.py | 154 +++++++++--------- python/api_v2/upgrade/views.py | 6 +- python/cm/services/job/action.py | 4 +- python/cm/services/job/run/_impl.py | 35 ++-- .../cm/services/job/run/_task_finalizers.py | 4 +- python/cm/tests/mocks/__init__.py | 12 ++ python/cm/tests/mocks/task_runner.py | 136 ++++++++++++++++ .../test_inventory/test_action_config.py | 89 +++++----- .../cm/tests/test_inventory/test_inventory.py | 8 +- python/core/job/runners.py | 4 +- python/init_db.py | 3 +- 21 files changed, 466 insertions(+), 212 deletions(-) create mode 100644 python/cm/tests/mocks/__init__.py create mode 100644 python/cm/tests/mocks/task_runner.py diff --git a/python/api/tests/test_host.py b/python/api/tests/test_host.py index 899c446390..429409b4de 100644 --- a/python/api/tests/test_host.py +++ b/python/api/tests/test_host.py @@ -11,7 +11,6 @@ # limitations under the License. from pathlib import Path -from unittest.mock import patch from adcm.tests.base import APPLICATION_JSON, BaseTestCase from cm.models import ( @@ -25,8 +24,10 @@ MaintenanceMode, Prototype, ServiceComponent, + SubAction, ) -from cm.services.job.action import ActionRunPayload +from cm.tests.mocks.task_runner import RunTaskMock +from core.types import ADCMCoreType from django.conf import settings from django.urls import reverse from rest_framework.response import Response @@ -86,14 +87,19 @@ def test_change_mm_on_no_action_success(self): self.assertEqual(self.host.maintenance_mode, MaintenanceMode.ON) def test_change_mm_on_with_action_success(self): - action = Action.objects.create( - prototype=self.host.cluster.prototype, - name=settings.ADCM_HOST_TURN_ON_MM_ACTION_NAME, - type=ActionType.JOB, - state_available="any", + SubAction.objects.create( + action=Action.objects.create( + prototype=self.host.cluster.prototype, + name=settings.ADCM_HOST_TURN_ON_MM_ACTION_NAME, + type=ActionType.JOB, + state_available="any", + host_action=True, + ), + script_type="ansible", + script="somethign.yaml", ) - with patch("adcm.utils.run_action") as start_task_mock: + with RunTaskMock() as run_task: response: Response = self.client.post( path=reverse(viewname="v1:host-maintenance-mode", kwargs={"host_id": self.host.pk}), data={"maintenance_mode": "ON"}, @@ -104,13 +110,23 @@ def test_change_mm_on_with_action_success(self): self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.data["maintenance_mode"], "CHANGING") self.assertEqual(self.host.maintenance_mode, MaintenanceMode.CHANGING) - start_task_mock.assert_called_once_with(action=action, obj=self.host, payload=ActionRunPayload()) + + self.assertIsNotNone(run_task.target_task) + self.assertEqual(run_task.target_task.task_object, self.host) + self.assertEqual(run_task.target_task.owner_id, self.host.cluster.pk) + self.assertEqual(run_task.target_task.owner_type, ADCMCoreType.CLUSTER.value) + + run_task.runner.run(run_task.target_task.pk) + run_task.target_task.refresh_from_db() + self.assertEqual(run_task.target_task.status, "success") + self.host.refresh_from_db() + self.assertEqual(self.host.maintenance_mode, MaintenanceMode.ON.value) def test_change_mm_on_from_on_with_action_fail(self): self.host.maintenance_mode = MaintenanceMode.ON self.host.save(update_fields=["maintenance_mode"]) - with patch("cm.services.job.action.run_action") as start_task_mock: + with RunTaskMock() as run_task: response: Response = self.client.post( path=reverse(viewname="v1:host-maintenance-mode", kwargs={"host_id": self.host.pk}), data={"maintenance_mode": "ON"}, @@ -120,7 +136,7 @@ def test_change_mm_on_from_on_with_action_fail(self): self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertEqual(self.host.maintenance_mode, MaintenanceMode.ON) - start_task_mock.assert_not_called() + self.assertIsNone(run_task.target_task) def test_change_mm_off_no_action_success(self): self.host.maintenance_mode = MaintenanceMode.ON @@ -140,12 +156,20 @@ def test_change_mm_off_no_action_success(self): def test_change_mm_off_with_action_success(self): self.host.maintenance_mode = MaintenanceMode.ON self.host.save(update_fields=["maintenance_mode"]) - action = Action.objects.create( - prototype=self.host.cluster.prototype, - name=settings.ADCM_HOST_TURN_OFF_MM_ACTION_NAME, + + SubAction.objects.create( + action=Action.objects.create( + prototype=self.host.cluster.prototype, + name=settings.ADCM_HOST_TURN_OFF_MM_ACTION_NAME, + host_action=True, + type=ActionType.JOB, + state_available="any", + ), + script_type="ansible", + script="something.yaml", ) - with patch("adcm.utils.run_action") as start_task_mock: + with RunTaskMock() as run_task: response: Response = self.client.post( path=reverse(viewname="v1:host-maintenance-mode", kwargs={"host_id": self.host.pk}), data={"maintenance_mode": "OFF"}, @@ -156,13 +180,23 @@ def test_change_mm_off_with_action_success(self): self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.data["maintenance_mode"], "CHANGING") self.assertEqual(self.host.maintenance_mode, MaintenanceMode.CHANGING) - start_task_mock.assert_called_once_with(action=action, obj=self.host, payload=ActionRunPayload()) + + self.assertIsNotNone(run_task.target_task) + self.assertEqual(run_task.target_task.task_object, self.host) + self.assertEqual(run_task.target_task.owner_id, self.host.cluster.pk) + self.assertEqual(run_task.target_task.owner_type, ADCMCoreType.CLUSTER.value) + + run_task.runner.run(run_task.target_task.pk) + run_task.target_task.refresh_from_db() + self.assertEqual(run_task.target_task.status, "success") + self.host.refresh_from_db() + self.assertEqual(self.host.maintenance_mode, MaintenanceMode.OFF.value) def test_change_mm_off_to_off_with_action_fail(self): self.host.maintenance_mode = MaintenanceMode.OFF self.host.save(update_fields=["maintenance_mode"]) - with patch("cm.services.job.action.run_action") as start_task_mock: + with RunTaskMock() as run_task: response: Response = self.client.post( path=reverse(viewname="v1:host-maintenance-mode", kwargs={"host_id": self.host.pk}), data={"maintenance_mode": "OFF"}, @@ -172,7 +206,7 @@ def test_change_mm_off_to_off_with_action_fail(self): self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertEqual(self.host.maintenance_mode, MaintenanceMode.OFF) - start_task_mock.assert_not_called() + self.assertIsNone(run_task.target_task) def test_change_mm_changing_now_fail(self): self.host.maintenance_mode = MaintenanceMode.CHANGING @@ -289,7 +323,7 @@ def test_change_maintenance_mode_on_with_action_via_bundle_success(self): "python/api/tests/files/cluster_using_plugin.tar", ), ) - action = Action.objects.get(name=settings.ADCM_HOST_TURN_ON_MM_ACTION_NAME) + Action.objects.get(name=settings.ADCM_HOST_TURN_ON_MM_ACTION_NAME) cluster_prototype = Prototype.objects.get(bundle_id=bundle.pk, type="cluster") cluster_response: Response = self.client.post( @@ -313,7 +347,7 @@ def test_change_maintenance_mode_on_with_action_via_bundle_success(self): data={"host_id": host.pk}, ) - with patch("adcm.utils.run_action") as start_task_mock: + with RunTaskMock() as run_task: response: Response = self.client.post( path=reverse(viewname="v1:host-maintenance-mode", kwargs={"host_id": host.pk}), data={"maintenance_mode": "ON"}, @@ -324,4 +358,14 @@ def test_change_maintenance_mode_on_with_action_via_bundle_success(self): self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.data["maintenance_mode"], "CHANGING") self.assertEqual(host.maintenance_mode, MaintenanceMode.CHANGING) - start_task_mock.assert_called_once_with(action=action, obj=host, payload=ActionRunPayload()) + + self.assertIsNotNone(run_task.target_task) + self.assertEqual(run_task.target_task.task_object, host) + self.assertEqual(run_task.target_task.owner_id, host.cluster.pk) + self.assertEqual(run_task.target_task.owner_type, ADCMCoreType.CLUSTER.value) + + run_task.runner.run(run_task.target_task.pk) + run_task.target_task.refresh_from_db() + self.assertEqual(run_task.target_task.status, "success") + host.refresh_from_db() + self.assertEqual(host.maintenance_mode, MaintenanceMode.ON.value) diff --git a/python/api/tests/test_job.py b/python/api/tests/test_job.py index 076ece9b6a..9af5ced4d7 100644 --- a/python/api/tests/test_job.py +++ b/python/api/tests/test_job.py @@ -12,10 +12,10 @@ from datetime import timedelta from pathlib import Path -from unittest.mock import patch from adcm.tests.base import BaseTestCase from cm.models import ADCM, Action, ActionType, Cluster, JobLog, Prototype, TaskLog +from cm.tests.mocks.task_runner import RunTaskMock from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.utils import timezone @@ -167,17 +167,17 @@ def test_log_files(self): cluster_prototype = Prototype.objects.get(bundle=bundle, type="cluster") cluster = Cluster.objects.create(name="test_cluster", prototype=cluster_prototype) - with patch("cm.services.job.run.run_task"): + with RunTaskMock() as run_task: response: Response = self.client.post( path=reverse(viewname="v1:run-task", kwargs={"cluster_id": cluster.pk, "action_id": action.pk}), ) self.assertEqual(response.status_code, HTTP_201_CREATED) - job = JobLog.objects.get(task__action=action) - response: Response = self.client.get( - path=reverse(viewname="v1:joblog-detail", kwargs={"job_pk": job.pk}), + path=reverse( + viewname="v1:joblog-detail", kwargs={"job_pk": JobLog.objects.get(task=run_task.target_task).pk} + ), ) self.assertEqual(len(response.data["log_files"]), 2) @@ -201,21 +201,22 @@ def test_task_permissions(self): policy.apply() with self.no_rights_user_logged_in: - with patch("cm.services.job.run.run_task"): - self.client.post( + with RunTaskMock() as run_task: + response = self.client.post( path=reverse(viewname="v1:run-task", kwargs={"cluster_id": cluster.pk, "action_id": action.pk}), ) + self.assertEqual(response.status_code, HTTP_201_CREATED) response: Response = self.client.get(path=reverse(viewname="v1:joblog-list")) self.assertIn( - JobLog.objects.get(task__action=action).pk, + JobLog.objects.get(task=run_task.target_task).pk, {job_data["id"] for job_data in response.data["results"]}, ) response: Response = self.client.get(path=reverse(viewname="v1:tasklog-list")) self.assertIn( - TaskLog.objects.get(action=action).pk, + run_task.target_task.pk, {job_data["id"] for job_data in response.data["results"]}, ) diff --git a/python/api_v2/action/utils.py b/python/api_v2/action/utils.py index 1c8ef1ff79..da6f607338 100644 --- a/python/api_v2/action/utils.py +++ b/python/api_v2/action/utils.py @@ -52,6 +52,15 @@ def check_run_perms(user: User, action: Action, obj: ADCMEntity) -> bool: return user.has_perm(perm=f"{RUN_ACTION_PERM_PREFIX}{get_str_hash(value=action.name)}", obj=obj) +def unique_hc_entries( + hc_create_data: list[dict[Literal["host_id", "component_id"], int]], +) -> list[dict[Literal["host_id", "component_id"], int]]: + return [ + {"host_id": host_id, "component_id": component_id} + for host_id, component_id in {(entry["host_id"], entry["component_id"]) for entry in hc_create_data} + ] + + def insert_service_ids( hc_create_data: List[dict[Literal["host_id", "component_id"], int]], ) -> List[dict[Literal["host_id", "component_id", "service_id"], int]]: diff --git a/python/api_v2/action/views.py b/python/api_v2/action/views.py index cd73c02182..af0c166d31 100644 --- a/python/api_v2/action/views.py +++ b/python/api_v2/action/views.py @@ -40,6 +40,7 @@ filter_actions_by_user_perm, get_action_configuration, insert_service_ids, + unique_hc_entries, ) from api_v2.config.utils import convert_adcm_meta_to_attr, represent_string_as_json_type from api_v2.task.serializers import TaskListSerializer @@ -191,7 +192,9 @@ def run(self, request: Request, *args, **kwargs) -> Response: # noqa: ARG001, A payload=ActionRunPayload( conf=config, attr=attr, - hostcomponent=insert_service_ids(hc_create_data=serializer.validated_data["host_component_map"]), + hostcomponent=insert_service_ids( + hc_create_data=unique_hc_entries(serializer.validated_data["host_component_map"]) + ), verbose=serializer.validated_data["is_verbose"], ), ) diff --git a/python/api_v2/tests/test_actions.py b/python/api_v2/tests/test_actions.py index 49c5f34ab2..96d46562eb 100644 --- a/python/api_v2/tests/test_actions.py +++ b/python/api_v2/tests/test_actions.py @@ -12,7 +12,6 @@ from functools import partial from operator import itemgetter from typing import TypeAlias -from unittest.mock import patch import json from cm.models import ( @@ -26,6 +25,7 @@ MaintenanceMode, ServiceComponent, ) +from cm.tests.mocks.task_runner import RunTaskMock from django.urls import reverse from rbac.models import Role from rbac.services.group import create as create_group @@ -252,7 +252,7 @@ def test_adcm_4516_disallowed_host_action_not_executable_success(self) -> None: self.host_1.maintenance_mode = MaintenanceMode.ON self.host_1.save(update_fields=["maintenance_mode"]) - with patch("cm.services.job.run.run_task", return_value=None): + with RunTaskMock() as run_task: response = self.client.post( path=reverse( viewname="v2:host-action-run", @@ -270,12 +270,14 @@ def test_adcm_4516_disallowed_host_action_not_executable_success(self) -> None: "level": "error", }, ) + # run task shouldn't be called + self.assertIsNone(run_task.target_task) def test_adcm_4535_job_cant_be_terminated_success(self) -> None: self.add_host_to_cluster(cluster=self.cluster, host=self.host_1) allowed_action = Action.objects.filter(display_name="cluster_host_action_allowed").first() - with patch("cm.services.job.run.run_task", return_value=None): + with RunTaskMock() as run_task: response = self.client.post( path=reverse( viewname="v2:host-action-run", @@ -285,7 +287,7 @@ def test_adcm_4535_job_cant_be_terminated_success(self) -> None: ) self.assertEqual(response.status_code, HTTP_200_OK) - job = JobLog.objects.filter(task__action__name="cluster_host_action_allowed").first() + job = JobLog.objects.filter(task=run_task.target_task).first() response = self.client.post(path=reverse(viewname="v2:joblog-terminate", kwargs={"pk": job.pk}), data={}) @@ -303,7 +305,7 @@ def test_adcm_4856_action_with_non_existing_component_fail(self) -> None: self.add_host_to_cluster(cluster=self.cluster, host=self.host_1) allowed_action = Action.objects.filter(display_name="cluster_host_action_allowed").first() - with patch("cm.services.job.run.run_task", return_value=None): + with RunTaskMock() as run_task: response = self.client.post( path=reverse( viewname="v2:host-action-run", @@ -319,12 +321,13 @@ def test_adcm_4856_action_with_non_existing_component_fail(self) -> None: self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.assertDictEqual(response.json(), {"detail": "Components with ids 1000 do not exist"}) + self.assertIsNone(run_task.target_task) def test_adcm_4856_action_with_non_existing_host_fail(self) -> None: self.add_host_to_cluster(cluster=self.cluster, host=self.host_1) allowed_action = Action.objects.filter(display_name="cluster_host_action_allowed").first() - with patch("cm.services.job.run.run_task", return_value=None): + with RunTaskMock() as run_task: response = self.client.post( path=reverse( viewname="v2:host-action-run", @@ -340,12 +343,13 @@ def test_adcm_4856_action_with_non_existing_host_fail(self) -> None: self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.assertDictEqual(response.json(), {"detail": "Hosts with ids 1000 do not exist"}) + self.assertIsNone(run_task.target_task) def test_adcm_4856_action_with_duplicated_hc_success(self) -> None: self.add_host_to_cluster(cluster=self.cluster, host=self.host_1) allowed_action = Action.objects.filter(display_name="cluster_host_action_allowed").first() - with patch("cm.services.job.run.run_task", return_value=None): + with RunTaskMock() as run_task: response = self.client.post( path=reverse( viewname="v2:host-action-run", @@ -363,12 +367,15 @@ def test_adcm_4856_action_with_duplicated_hc_success(self) -> None: ) self.assertEqual(response.status_code, HTTP_200_OK) + run_task.runner.run(run_task.target_task.pk) + run_task.target_task.refresh_from_db() + self.assertEqual(run_task.target_task.status, "success") def test_adcm_4856_action_with_several_entries_hc_success(self) -> None: self.add_host_to_cluster(cluster=self.cluster, host=self.host_1) allowed_action = Action.objects.filter(display_name="cluster_host_action_allowed").first() - with patch("cm.services.job.run.run_task", return_value=None): + with RunTaskMock() as run_task: response = self.client.post( path=reverse( viewname="v2:host-action-run", @@ -387,6 +394,9 @@ def test_adcm_4856_action_with_several_entries_hc_success(self) -> None: ) self.assertEqual(response.status_code, HTTP_200_OK) + run_task.runner.run(run_task.target_task.pk) + run_task.target_task.refresh_from_db() + self.assertEqual(run_task.target_task.status, "success") def test_adcm_5348_action_not_allowed_on_any_cluster_failed(self): test_user_credentials = {"username": "test_user_username", "password": "test_user_password"} diff --git a/python/api_v2/tests/test_cluster.py b/python/api_v2/tests/test_cluster.py index 2c19ada7c0..54a7b5b48c 100644 --- a/python/api_v2/tests/test_cluster.py +++ b/python/api_v2/tests/test_cluster.py @@ -24,6 +24,7 @@ ServiceComponent, ) from cm.services.status.client import FullStatusMap +from cm.tests.mocks.task_runner import RunTaskMock from cm.tests.utils import gen_component, gen_host, gen_service, generate_hierarchy from django.urls import reverse from guardian.models import GroupObjectPermission @@ -37,6 +38,7 @@ HTTP_409_CONFLICT, ) +from api_v2.config.utils import convert_adcm_meta_to_attr from api_v2.tests.base import BaseAPITestCase @@ -372,7 +374,7 @@ def test_retrieve_cluster_action_success(self): self.assertEqual(response.status_code, HTTP_200_OK) def test_run_cluster_action_success(self): - with patch("cm.services.job.run.run_task", return_value=None): + with RunTaskMock() as run_task: response = self.client.post( path=reverse( viewname="v2:cluster-action-run", @@ -382,6 +384,12 @@ def test_run_cluster_action_success(self): ) self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json()["id"], run_task.target_task.id) + self.assertEqual(run_task.target_task.status, "created") + + run_task.runner.run(run_task.target_task.id) + run_task.target_task.refresh_from_db() + self.assertEqual(run_task.target_task.status, "success") def test_run_action_with_config_success(self): config = { @@ -392,7 +400,7 @@ def test_run_action_with_config_success(self): } adcm_meta = {"/activatable_group": {"isActive": True}} - with patch("cm.services.job.run.run_task", return_value=None): + with RunTaskMock() as run_task: response = self.client.post( path=reverse( viewname="v2:cluster-action-run", @@ -402,9 +410,12 @@ def test_run_action_with_config_success(self): ) self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json()["id"], run_task.target_task.id) + self.assertEqual(run_task.target_task.config, config) + self.assertEqual(run_task.target_task.attr, convert_adcm_meta_to_attr(adcm_meta)) def test_run_action_with_config_wrong_configuration_fail(self): - with patch("cm.services.job.run.run_task", return_value=None): + with RunTaskMock() as run_task: response = self.client.post( path=reverse( viewname="v2:cluster-action-run", @@ -422,11 +433,12 @@ def test_run_action_with_config_wrong_configuration_fail(self): "level": "error", }, ) + self.assertIsNone(run_task.target_task) def test_run_action_with_config_required_adcm_meta_fail(self): config = {"simple": "kuku", "grouped": {"simple": 5, "second": 4.3}, "after": ["something"]} - with patch("cm.services.job.run.run_task", return_value=None): + with RunTaskMock() as run_task: response = self.client.post( path=reverse( viewname="v2:cluster-action-run", @@ -439,9 +451,10 @@ def test_run_action_with_config_required_adcm_meta_fail(self): self.assertDictEqual( response.json(), {"code": "BAD_REQUEST", "desc": "adcm_meta - This field is required.;", "level": "error"} ) + self.assertIsNone(run_task.target_task) def test_run_action_with_config_required_config_fail(self): - with patch("cm.services.job.run.run_task", return_value=None): + with RunTaskMock() as run_task: response = self.client.post( path=reverse( viewname="v2:cluster-action-run", @@ -454,6 +467,7 @@ def test_run_action_with_config_required_config_fail(self): self.assertDictEqual( response.json(), {"code": "BAD_REQUEST", "desc": "config - This field is required.;", "level": "error"} ) + self.assertIsNone(run_task.target_task) def test_retrieve_action_with_hc_success(self): response = self.client.get( diff --git a/python/api_v2/tests/test_component.py b/python/api_v2/tests/test_component.py index 0e2b414057..0ecd8ce3db 100644 --- a/python/api_v2/tests/test_component.py +++ b/python/api_v2/tests/test_component.py @@ -9,10 +9,10 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from unittest.mock import patch from cm.issue import add_concern_to_object from cm.models import Action, ConcernType, MaintenanceMode, ServiceComponent +from cm.tests.mocks.task_runner import RunTaskMock from cm.tests.utils import gen_concern_item from django.urls import reverse from rest_framework.status import HTTP_200_OK, HTTP_405_METHOD_NOT_ALLOWED, HTTP_409_CONFLICT @@ -127,7 +127,7 @@ def test_action_retrieve_success(self): self.assertTrue(response.json()) def test_action_run_success(self): - with patch("cm.services.job.run.run_task", return_value=None): + with RunTaskMock() as run_task: response = self.client.post( path=reverse( "v2:component-action-run", @@ -142,6 +142,12 @@ def test_action_run_success(self): ) self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json()["id"], run_task.target_task.id) + self.assertEqual(run_task.target_task.status, "created") + + run_task.runner.run(run_task.target_task.id) + run_task.target_task.refresh_from_db() + self.assertEqual(run_task.target_task.status, "success") class TestComponentMaintenanceMode(BaseAPITestCase): diff --git a/python/api_v2/tests/test_host.py b/python/api_v2/tests/test_host.py index 474305ca2f..123b292ebf 100644 --- a/python/api_v2/tests/test_host.py +++ b/python/api_v2/tests/test_host.py @@ -9,9 +9,10 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from unittest.mock import patch from cm.models import Action, Host, HostComponent, HostProvider, ServiceComponent +from cm.tests.mocks.task_runner import RunTaskMock +from core.types import ADCMCoreType from django.urls import reverse from rest_framework.status import ( HTTP_200_OK, @@ -580,7 +581,7 @@ def test_host_cluster_retrieve_success(self): self.assertTrue(response.json()) def test_host_cluster_run_success(self): - with patch("cm.services.job.run.run_task", return_value=None): + with RunTaskMock() as run_task: response = self.client.post( path=reverse( "v2:host-cluster-action-run", @@ -594,6 +595,15 @@ def test_host_cluster_run_success(self): ) self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json()["id"], run_task.target_task.id) + self.assertEqual(run_task.target_task.status, "created") + self.assertEqual(run_task.target_task.task_object, self.host) + self.assertEqual(run_task.target_task.owner_id, self.host.pk) + self.assertEqual(run_task.target_task.owner_type, ADCMCoreType.HOST.value) + + run_task.runner.run(run_task.target_task.id) + run_task.target_task.refresh_from_db() + self.assertEqual(run_task.target_task.status, "success") def test_host_list_success(self): response = self.client.get( @@ -612,13 +622,22 @@ def test_host_retrieve_success(self): self.assertTrue(response.json()) def test_host_run_success(self): - with patch("cm.services.job.run.run_task", return_value=None): + with RunTaskMock() as run_task: response = self.client.post( path=reverse("v2:host-action-run", kwargs={"host_pk": self.host.pk, "pk": self.action.pk}), data={"hostComponentMap": [], "config": {}, "adcmMeta": {}, "isVerbose": False}, ) self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json()["id"], run_task.target_task.id) + self.assertEqual(run_task.target_task.status, "created") + self.assertEqual(run_task.target_task.task_object, self.host) + self.assertEqual(run_task.target_task.owner_id, self.host.pk) + self.assertEqual(run_task.target_task.owner_type, ADCMCoreType.HOST.value) + + run_task.runner.run(run_task.target_task.id) + run_task.target_task.refresh_from_db() + self.assertEqual(run_task.target_task.status, "success") def test_host_mapped_list_success(self) -> None: HostComponent.objects.create( diff --git a/python/api_v2/tests/test_host_provider.py b/python/api_v2/tests/test_host_provider.py index d2b5b7b4e4..951811a90d 100644 --- a/python/api_v2/tests/test_host_provider.py +++ b/python/api_v2/tests/test_host_provider.py @@ -9,9 +9,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from unittest.mock import patch from cm.models import Action, HostProvider +from cm.tests.mocks.task_runner import RunTaskMock from rest_framework.reverse import reverse from rest_framework.status import ( HTTP_200_OK, @@ -136,7 +136,7 @@ def test_action_retrieve_success(self): self.assertTrue(response.json()) def test_action_run_success(self): - with patch("cm.services.job.run.run_task", return_value=None): + with RunTaskMock() as run_task: response = self.client.post( path=reverse( viewname="v2:provider-action-run", @@ -149,3 +149,9 @@ def test_action_run_success(self): ) self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json()["id"], run_task.target_task.id) + self.assertEqual(run_task.target_task.status, "created") + + run_task.runner.run(run_task.target_task.id) + run_task.target_task.refresh_from_db() + self.assertEqual(run_task.target_task.status, "success") diff --git a/python/api_v2/tests/test_service.py b/python/api_v2/tests/test_service.py index 7cac30d2fa..3ba94826a3 100644 --- a/python/api_v2/tests/test_service.py +++ b/python/api_v2/tests/test_service.py @@ -29,6 +29,7 @@ ) from cm.services.job.action import ActionRunPayload, run_action from cm.services.status.client import FullStatusMap +from cm.tests.mocks.task_runner import RunTaskMock from django.urls import reverse from rest_framework.status import ( HTTP_200_OK, @@ -252,7 +253,7 @@ def test_action_retrieve_success(self): self.assertTrue(response.json()) def test_action_run_success(self): - with patch("cm.services.job.run.run_task", return_value=None): + with RunTaskMock() as run_task: response = self.client.post( path=reverse( viewname="v2:service-action-run", @@ -266,6 +267,12 @@ def test_action_run_success(self): ) self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json()["id"], run_task.target_task.id) + self.assertEqual(run_task.target_task.status, "created") + + run_task.runner.run(run_task.target_task.id) + run_task.target_task.refresh_from_db() + self.assertEqual(run_task.target_task.status, "success") class TestServiceDeleteAction(BaseAPITestCase): diff --git a/python/api_v2/tests/test_upgrade.py b/python/api_v2/tests/test_upgrade.py index 51c543d08f..a119f803bf 100644 --- a/python/api_v2/tests/test_upgrade.py +++ b/python/api_v2/tests/test_upgrade.py @@ -11,7 +11,6 @@ # limitations under the License. from pathlib import Path -from unittest.mock import patch from cm.models import ( ADCM, @@ -20,12 +19,10 @@ ObjectType, Prototype, ServiceComponent, - TaskLog, Upgrade, ) -from django.contrib.contenttypes.models import ContentType +from cm.tests.mocks.task_runner import RunTaskMock from django.urls import reverse -from django.utils import timezone from init_db import init from rbac.upgrade.role import init_roles from rest_framework.response import Response @@ -180,15 +177,9 @@ def test_cluster_upgrade_retrieve_complex_success(self): ) def test_cluster_upgrade_run_success(self): - tasklog = TaskLog.objects.create( - object_id=self.cluster_1.pk, - object_type=ContentType.objects.get(app_label="cm", model="cluster"), - start_date=timezone.now(), - finish_date=timezone.now(), - action=self.upgrade_cluster_via_action_simple.action, - ) + Prototype.objects.update(license="accepted") - with patch("cm.upgrade.run_action", return_value=tasklog): + with RunTaskMock() as run_task: response: Response = self.client.post( path=reverse( viewname="v2:upgrade-run", @@ -199,24 +190,26 @@ def test_cluster_upgrade_run_success(self): self.assertEqual(response.status_code, HTTP_200_OK) data = response.json() self.assertTrue(set(data.keys()).issuperset({"id", "childJobs", "startTime"})) - self.assertEqual(data["id"], tasklog.id) + self.assertEqual(data["id"], run_task.target_task.id) - def test_cluster_upgrade_run_complex_success(self): - tasklog = TaskLog.objects.create( - object_id=self.cluster_1.pk, - object_type=ContentType.objects.get(app_label="cm", model="cluster"), - start_date=timezone.now(), - finish_date=timezone.now(), - action=self.upgrade_cluster_via_action_simple.action, + run_task.runner.run(run_task.target_task.id) + run_task.target_task.refresh_from_db() + self.assertEqual(run_task.target_task.status, "success") + self.cluster_1.refresh_from_db() + self.assertEqual( + self.cluster_1.prototype.version, self.upgrade_cluster_via_action_simple.action.prototype.version ) + def test_cluster_upgrade_run_complex_success(self): + Prototype.objects.update(license="accepted") + host = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="one_host") self.add_host_to_cluster(cluster=self.cluster_1, host=host) component_1 = ServiceComponent.objects.get(service=self.service_1, prototype__name="component_1") component_2 = ServiceComponent.objects.get(service=self.service_1, prototype__name="component_2") HostComponent.objects.create(cluster=self.cluster_1, service=self.service_1, component=component_2, host=host) - with patch("cm.upgrade.run_action", return_value=tasklog): + with RunTaskMock() as run_task: response: Response = self.client.post( path=reverse( viewname="v2:upgrade-run", @@ -235,17 +228,19 @@ def test_cluster_upgrade_run_complex_success(self): self.assertEqual(response.status_code, HTTP_200_OK) data = response.json() self.assertTrue(set(data.keys()).issuperset({"id", "childJobs", "startTime"})) - self.assertEqual(data["id"], tasklog.id) + self.assertEqual(data["id"], run_task.target_task.id) - def test_adcm_5246_cluster_upgrade_other_constraints_run_success(self): - tasklog = TaskLog.objects.create( - object_id=self.cluster_1.pk, - object_type=ContentType.objects.get(app_label="cm", model="cluster"), - start_date=timezone.now(), - finish_date=timezone.now(), - action=self.upgrade_cluster_via_action_simple.action, + run_task.runner.run(run_task.target_task.id) + run_task.target_task.refresh_from_db() + self.assertEqual(run_task.target_task.status, "success") + self.cluster_1.refresh_from_db() + self.assertEqual( + self.cluster_1.prototype.version, self.upgrade_cluster_via_action_simple.action.prototype.version ) + def test_adcm_5246_cluster_upgrade_other_constraints_run_success(self): + Prototype.objects.update(license="accepted") + host_1 = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="first_host") host_2 = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="second_host") self.add_host_to_cluster(cluster=self.cluster_1, host=host_1) @@ -256,7 +251,7 @@ def test_adcm_5246_cluster_upgrade_other_constraints_run_success(self): HostComponent.objects.create(cluster=self.cluster_1, service=self.service_1, component=component_1, host=host_1) HostComponent.objects.create(cluster=self.cluster_1, service=self.service_1, component=component_1, host=host_2) - with patch("cm.upgrade.run_action", return_value=tasklog): + with RunTaskMock() as run_task: response: Response = self.client.post( path=reverse( viewname="v2:upgrade-run", @@ -269,7 +264,7 @@ def test_adcm_5246_cluster_upgrade_other_constraints_run_success(self): {"hostId": host_1.pk, "componentId": component_2.pk}, ], "configuration": { - "config": {}, + "config": {"simple": "val", "grouped": {"simple": 5, "second": 4.3}, "after": ["x", "y"]}, "adcmMeta": {}, }, "isVerbose": True, @@ -279,21 +274,21 @@ def test_adcm_5246_cluster_upgrade_other_constraints_run_success(self): self.assertEqual(response.status_code, HTTP_200_OK) data = response.json() self.assertTrue(set(data.keys()).issuperset({"id", "childJobs", "startTime"})) - self.assertEqual(data["id"], tasklog.id) + self.assertEqual(data["id"], run_task.target_task.id) - def test_adcm_4856_cluster_upgrade_run_complex_no_component_fail(self): - tasklog = TaskLog.objects.create( - object_id=self.cluster_1.pk, - object_type=ContentType.objects.get(app_label="cm", model="cluster"), - start_date=timezone.now(), - finish_date=timezone.now(), - action=self.upgrade_cluster_via_action_simple.action, + run_task.runner.run(run_task.target_task.id) + run_task.target_task.refresh_from_db() + self.assertEqual(run_task.target_task.status, "success") + self.cluster_1.refresh_from_db() + self.assertEqual( + self.cluster_1.prototype.version, self.upgrade_cluster_via_action_simple.action.prototype.version ) + def test_adcm_4856_cluster_upgrade_run_complex_no_component_fail(self): host = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="one_host") self.add_host_to_cluster(cluster=self.cluster_1, host=host) - with patch("cm.upgrade.run_action", return_value=tasklog): + with RunTaskMock() as run_task: response: Response = self.client.post( path=reverse( viewname="v2:upgrade-run", @@ -302,7 +297,7 @@ def test_adcm_4856_cluster_upgrade_run_complex_no_component_fail(self): data={ "hostComponentMap": [{"hostId": host.pk, "componentId": 1000}], "configuration": { - "config": {}, + "config": {"simple": "val", "grouped": {"simple": 5, "second": 4.3}, "after": ["x", "y"]}, "adcmMeta": {}, }, "isVerbose": True, @@ -311,19 +306,12 @@ def test_adcm_4856_cluster_upgrade_run_complex_no_component_fail(self): self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.assertDictEqual(response.json(), {"detail": "Components with ids 1000 do not exist"}) + self.assertIsNone(run_task.target_task) def test_adcm_4856_cluster_upgrade_run_complex_no_host_fail(self): - tasklog = TaskLog.objects.create( - object_id=self.cluster_1.pk, - object_type=ContentType.objects.get(app_label="cm", model="cluster"), - start_date=timezone.now(), - finish_date=timezone.now(), - action=self.upgrade_cluster_via_action_simple.action, - ) - component_1 = ServiceComponent.objects.get(service=self.service_1, prototype__name="component_1") - with patch("cm.upgrade.run_action", return_value=tasklog): + with RunTaskMock() as run_task: response: Response = self.client.post( path=reverse( viewname="v2:upgrade-run", @@ -341,21 +329,18 @@ def test_adcm_4856_cluster_upgrade_run_complex_no_host_fail(self): self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.assertDictEqual(response.json(), {"detail": "Hosts with ids 1000 do not exist"}) + self.assertIsNone(run_task.target_task) def test_adcm_4856_cluster_upgrade_run_complex_duplicated_hc_success(self): - tasklog = TaskLog.objects.create( - object_id=self.cluster_1.pk, - object_type=ContentType.objects.get(app_label="cm", model="cluster"), - start_date=timezone.now(), - finish_date=timezone.now(), - action=self.upgrade_cluster_via_action_simple.action, - ) + # fixme was incorrect test, probably should fix a validation? see action run + Prototype.objects.update(license="accepted") + host = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="one_host") self.add_host_to_cluster(cluster=self.cluster_1, host=host) component_1 = ServiceComponent.objects.get(service=self.service_1, prototype__name="component_1") - with patch("cm.upgrade.run_action", return_value=tasklog): + with RunTaskMock() as run_task: response: Response = self.client.post( path=reverse( viewname="v2:upgrade-run", @@ -367,7 +352,7 @@ def test_adcm_4856_cluster_upgrade_run_complex_duplicated_hc_success(self): {"hostId": host.pk, "componentId": component_1.pk}, ], "configuration": { - "config": {}, + "config": {"simple": "val", "grouped": {"simple": 5, "second": 4.3}, "after": ["x", "y"]}, "adcmMeta": {}, }, "isVerbose": True, @@ -376,14 +361,17 @@ def test_adcm_4856_cluster_upgrade_run_complex_duplicated_hc_success(self): self.assertEqual(response.status_code, HTTP_200_OK) - def test_adcm_4856_cluster_upgrade_run_complex_several_entries_hc_success(self): - tasklog = TaskLog.objects.create( - object_id=self.cluster_1.pk, - object_type=ContentType.objects.get(app_label="cm", model="cluster"), - start_date=timezone.now(), - finish_date=timezone.now(), - action=self.upgrade_cluster_via_action_simple.action, + run_task.runner.run(run_task.target_task.id) + run_task.target_task.refresh_from_db() + self.assertEqual(run_task.target_task.status, "success") + self.cluster_1.refresh_from_db() + self.assertEqual( + self.cluster_1.prototype.version, self.upgrade_cluster_via_action_simple.action.prototype.version ) + + def test_adcm_4856_cluster_upgrade_run_complex_several_entries_hc_success(self): + Prototype.objects.update(license="accepted") + host_1 = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="one_host") self.add_host_to_cluster(cluster=self.cluster_1, host=host_1) @@ -391,9 +379,8 @@ def test_adcm_4856_cluster_upgrade_run_complex_several_entries_hc_success(self): self.add_host_to_cluster(cluster=self.cluster_1, host=host_2) component_1 = ServiceComponent.objects.get(service=self.service_1, prototype__name="component_1") - component_2 = ServiceComponent.objects.get(service=self.service_1, prototype__name="component_2") - with patch("cm.upgrade.run_action", return_value=tasklog): + with RunTaskMock() as run_task: response: Response = self.client.post( path=reverse( viewname="v2:upgrade-run", @@ -402,11 +389,10 @@ def test_adcm_4856_cluster_upgrade_run_complex_several_entries_hc_success(self): data={ "hostComponentMap": [ {"hostId": host_1.pk, "componentId": component_1.pk}, - {"hostId": host_1.pk, "componentId": component_2.pk}, - {"hostId": host_2.pk, "componentId": component_2.pk}, + {"hostId": host_2.pk, "componentId": component_1.pk}, ], "configuration": { - "config": {}, + "config": {"simple": "val", "grouped": {"simple": 5, "second": 4.3}, "after": ["x", "y"]}, "adcmMeta": {}, }, "isVerbose": True, @@ -415,6 +401,14 @@ def test_adcm_4856_cluster_upgrade_run_complex_several_entries_hc_success(self): self.assertEqual(response.status_code, HTTP_200_OK) + run_task.runner.run(run_task.target_task.id) + run_task.target_task.refresh_from_db() + self.assertEqual(run_task.target_task.status, "success") + self.cluster_1.refresh_from_db() + self.assertEqual( + self.cluster_1.prototype.version, self.upgrade_cluster_via_action_simple.action.prototype.version + ) + def test_provider_list_upgrades_success(self): response: Response = self.client.get( path=reverse(viewname="v2:upgrade-list", kwargs={"hostprovider_pk": self.provider.pk}), @@ -466,15 +460,7 @@ def test_provider_upgrade_retrieve_complex_success(self): self.assertEqual(len(upgrade_data["hostComponentMapRules"]), 0) def test_provider_upgrade_run_success(self): - tasklog = TaskLog.objects.create( - object_id=self.provider.pk, - object_type=ContentType.objects.get(app_label="cm", model="hostprovider"), - start_date=timezone.now(), - finish_date=timezone.now(), - action=self.upgrade_host_via_action_simple.action, - ) - - with patch("cm.upgrade.run_action", return_value=tasklog): + with RunTaskMock() as run_task: response: Response = self.client.post( path=reverse( viewname="v2:upgrade-run", @@ -485,7 +471,13 @@ def test_provider_upgrade_run_success(self): self.assertEqual(response.status_code, HTTP_200_OK) data = response.json() self.assertTrue(set(data.keys()).issuperset({"id", "childJobs", "startTime"})) - self.assertEqual(data["id"], tasklog.id) + self.assertEqual(data["id"], run_task.target_task.id) + + run_task.runner.run(run_task.target_task.id) + run_task.target_task.refresh_from_db() + self.assertEqual(run_task.target_task.status, "success") + self.provider.refresh_from_db() + self.assertEqual(self.provider.prototype.version, self.upgrade_host_via_action_simple.action.prototype.version) def test_provider_upgrade_run_violate_constraint_fail(self): response: Response = self.client.post( diff --git a/python/api_v2/upgrade/views.py b/python/api_v2/upgrade/views.py index 7ed5fabf13..753b2479af 100644 --- a/python/api_v2/upgrade/views.py +++ b/python/api_v2/upgrade/views.py @@ -33,7 +33,7 @@ from rest_framework.status import HTTP_200_OK, HTTP_204_NO_CONTENT from api_v2.action.serializers import ActionRunSerializer -from api_v2.action.utils import get_action_configuration, insert_service_ids +from api_v2.action.utils import get_action_configuration, insert_service_ids, unique_hc_entries from api_v2.config.utils import convert_adcm_meta_to_attr, represent_string_as_json_type from api_v2.task.serializers import TaskListSerializer from api_v2.upgrade.serializers import UpgradeListSerializer, UpgradeRetrieveSerializer @@ -176,7 +176,9 @@ def run(self, request: Request, *_, **__) -> Response: upgrade=upgrade, config=config, attr=attr, - hostcomponent=insert_service_ids(hc_create_data=serializer.validated_data["host_component_map"]), + hostcomponent=insert_service_ids( + hc_create_data=unique_hc_entries(serializer.validated_data["host_component_map"]) + ), verbose=verbose, ) diff --git a/python/cm/services/job/action.py b/python/cm/services/job/action.py index 8fd374abdb..760b7446db 100644 --- a/python/cm/services/job/action.py +++ b/python/cm/services/job/action.py @@ -41,7 +41,7 @@ TaskLog, get_object_cluster, ) -from cm.services.config.spec import retrieve_flat_spec_for_action +from cm.services.config.spec import convert_to_flat_spec_from_proto_flat_spec from cm.services.job.checks import check_constraints_for_upgrade, check_hostcomponentmap from cm.services.job.inventory._config import update_configuration_for_inventory_inplace from cm.services.job.prepare import prepare_task_for_action @@ -139,7 +139,7 @@ def run_action( new_conf = update_configuration_for_inventory_inplace( configuration=payload.conf, attributes=payload.attr, - specification=retrieve_flat_spec_for_action(owner_prototype=obj.prototype.pk, action=action.pk), + specification=convert_to_flat_spec_from_proto_flat_spec(prototypes_flat_spec=flat_spec), config_owner=CoreObjectDescriptor( id=obj.pk, type=model_name_to_core_type(model_name=obj._meta.model_name) ), diff --git a/python/cm/services/job/run/_impl.py b/python/cm/services/job/run/_impl.py index a3d86f0f52..079dfca8ba 100644 --- a/python/cm/services/job/run/_impl.py +++ b/python/cm/services/job/run/_impl.py @@ -11,10 +11,18 @@ # limitations under the License. from datetime import datetime +from typing import Callable import os import logging -from core.job.runners import ADCMSettings, AnsibleSettings, ExternalSettings, IntegrationsSettings, JobProcessor +from core.job.runners import ( + ADCMSettings, + AnsibleSettings, + ExternalSettings, + IntegrationsSettings, + JobProcessor, + TaskRunner, +) from core.job.types import ExecutionStatus from django.conf import settings from django.utils import timezone @@ -27,6 +35,8 @@ logger = logging.getLogger("task_runner_err") +_factory = ExecutionTargetFactory() + class SubprocessRunnerEnvironment: @property @@ -37,24 +47,17 @@ def now(self) -> datetime: return timezone.now() -def get_default_runner(): - return JobSequenceRunner( - job_processor=JobProcessor(convert=ExecutionTargetFactory()), - settings=_prepare_settings(), - repo=JobRepoImpl, - environment=SubprocessRunnerEnvironment(), - notifier=status_api, - status_server=notify, - logger=logger, - ) +def get_default_runner() -> TaskRunner: + return _get_runner() + + +def get_restart_runner() -> TaskRunner: + return _get_runner(filter_predicate=lambda job: job.status != ExecutionStatus.SUCCESS) -def get_restart_runner(): +def _get_runner(filter_predicate: Callable = id) -> TaskRunner: return JobSequenceRunner( - job_processor=JobProcessor( - convert=ExecutionTargetFactory(), - filter_predicate=lambda job: job.status != ExecutionStatus.SUCCESS, - ), + job_processor=JobProcessor(convert=_factory, filter_predicate=filter_predicate), settings=_prepare_settings(), repo=JobRepoImpl, environment=SubprocessRunnerEnvironment(), diff --git a/python/cm/services/job/run/_task_finalizers.py b/python/cm/services/job/run/_task_finalizers.py index 2a0f40a1eb..3565f8009f 100644 --- a/python/cm/services/job/run/_task_finalizers.py +++ b/python/cm/services/job/run/_task_finalizers.py @@ -81,12 +81,12 @@ def update_object_maintenance_mode(action_name: str, object_: CoreObjectDescript action_name in {settings.ADCM_TURN_ON_MM_ACTION_NAME, settings.ADCM_HOST_TURN_ON_MM_ACTION_NAME} and obj.maintenance_mode == MaintenanceMode.CHANGING ): - obj.maintenance_mode = MaintenanceMode.OFF + obj.maintenance_mode = MaintenanceMode.ON obj.save() if ( action_name in {settings.ADCM_TURN_OFF_MM_ACTION_NAME, settings.ADCM_HOST_TURN_OFF_MM_ACTION_NAME} and obj.maintenance_mode == MaintenanceMode.CHANGING ): - obj.maintenance_mode = MaintenanceMode.ON + obj.maintenance_mode = MaintenanceMode.OFF obj.save() diff --git a/python/cm/tests/mocks/__init__.py b/python/cm/tests/mocks/__init__.py new file mode 100644 index 0000000000..f3e59d81d8 --- /dev/null +++ b/python/cm/tests/mocks/__init__.py @@ -0,0 +1,12 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/python/cm/tests/mocks/task_runner.py b/python/cm/tests/mocks/task_runner.py new file mode 100644 index 0000000000..95d23e8a8e --- /dev/null +++ b/python/cm/tests/mocks/task_runner.py @@ -0,0 +1,136 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from abc import ABC +from datetime import datetime +from functools import partial +from typing import Callable, Generator, Iterable, NamedTuple +from unittest.mock import patch + +from core.job.executors import ExecutionResult, Executor, ExecutorConfig +from core.job.runners import ExecutionTarget, ExecutionTargetFactoryI, ExternalSettings, TaskRunner +from core.job.types import Job, ScriptType, Task +from django.utils import timezone +from typing_extensions import Self + +from cm.models import TaskLog +from cm.services.job.run import get_default_runner, run_task +from cm.services.job.run._target_factories import ExecutionTargetFactory + + +class FakePopen(NamedTuple): + pid: int + + +# ExecutionTarget Factories + + +class ExecutionTargetFactoryDummyMock(ExecutionTargetFactory): + def __call__( + self, task: Task, jobs: Iterable[Job], configuration: ExternalSettings + ) -> Generator[ExecutionTarget, None, None]: + _ = task + for job in jobs: + work_dir = configuration.adcm.run_dir / str(job.id) + + if job.type == ScriptType.INTERNAL: + internal_script_func = self._supported_internal_scripts[job.script] + script = partial(internal_script_func, task=task) + executor = InternalExecutorMock(config=ExecutorConfig(work_dir=work_dir), script=script) + + else: + executor = SuccessExecutorMock( + script_type=job.script, config=ExecutorConfig(work_dir=configuration.adcm.run_dir / str(job.id)) + ) + + yield ExecutionTarget( + job=job, + executor=executor, + environment_builders=(), + finalizers=(), + ) + + +DEFAULT_ETF_MOCK = ExecutionTargetFactoryDummyMock() + +# Executors + + +class MockExecutor(Executor, ABC): + def execute(self) -> Self: + return self + + def wait_finished(self) -> Self: + self._result = ExecutionResult(code=0) + return self + + +class InternalExecutorMock(MockExecutor): + script_type = "internal" + + def __init__(self, config: ExecutorConfig, script: Callable[[], int]): + super().__init__(config=config) + self._script = script + + def execute(self) -> Self: + return self._script() + + +class SuccessExecutorMock(MockExecutor): + def __init__(self, script_type: str, **kwargs): + super().__init__(**kwargs) + self._script_type = script_type + + @property + def script_type(self) -> str: + return self._script_type + + +# Custom Mocks + + +class SubprocessRunnerMockEnvironment: + @property + def pid(self) -> int: + return 5_000_000 + + def now(self) -> datetime: + return timezone.now() + + +class RunTaskMock: + def __init__(self, execution_target_factory: ExecutionTargetFactoryI = DEFAULT_ETF_MOCK): + self.target_task: TaskLog | None = None + self.runner: TaskRunner | None = None + self._execution_target_factory = execution_target_factory + self._run_patch = None + + def __call__(self, task: TaskLog) -> None: + self.target_task = task + with patch("cm.services.job.run._task.subprocess.Popen", return_value=FakePopen(pid=101)): + run_task(task) + + with patch("cm.services.job.run._impl._factory", new=self._execution_target_factory), patch( + "cm.services.job.run._impl.SubprocessRunnerEnvironment", new=SubprocessRunnerMockEnvironment + ): + self.runner = get_default_runner() + + def __enter__(self): + self._run_patch = patch("cm.services.job.action.run_task", new=self) + return self._run_patch.__enter__() + + def __exit__(self, exc_type, exc_val, exc_tb): + return_ = None + if self._run_patch: + return_ = self._run_patch.__exit__(exc_type, exc_val, exc_tb) + self._run_patch = None + + return return_ diff --git a/python/cm/tests/test_inventory/test_action_config.py b/python/cm/tests/test_inventory/test_action_config.py index dfae546467..e92d4e650f 100644 --- a/python/cm/tests/test_inventory/test_action_config.py +++ b/python/cm/tests/test_inventory/test_action_config.py @@ -10,7 +10,6 @@ # See the License for the specific language governing permissions and # limitations under the License. from copy import deepcopy -from unittest.mock import patch from core.job.dto import TaskPayloadDTO from core.job.runners import ADCMSettings, AnsibleSettings, ExternalSettings, IntegrationsSettings @@ -21,12 +20,10 @@ from cm.converters import model_name_to_core_type from cm.models import Action, ServiceComponent from cm.services.job.action import ActionRunPayload, run_action -from cm.job import ActionRunPayload, run_action -from cm.models import Action, JobLog, ServiceComponent, SubAction, TaskLog -from cm.services.job.config import get_job_config from cm.services.job.prepare import prepare_task_for_action from cm.services.job.run._target_factories import prepare_ansible_job_config from cm.services.job.run.repo import JobRepoImpl +from cm.tests.mocks.task_runner import RunTaskMock from cm.tests.test_inventory.base import BaseInventoryTestCase @@ -173,13 +170,14 @@ def test_action_config_with_secrets_bug_adcm_5305(self): """ raw_value = "12345ddd" action = Action.objects.filter(prototype=self.service.prototype, name="name_and_pass").first() - with patch("cm.services.job.action.run_task"): - task = run_action( + with RunTaskMock() as run_task: + run_action( action=action, obj=self.service, payload=ActionRunPayload(conf={"rolename": "test_user", "rolepass": raw_value}), ) + task = run_task.target_task self.assertIn("__ansible_vault", task.config["rolepass"]) self.assertEqual(ansible_decrypt(task.config["rolepass"]["__ansible_vault"]), raw_value) @@ -196,19 +194,22 @@ def test_action_jinja_config_with_secrets_bug_adcm_5314(self): """ raw_value = "12345ddd" action = Action.objects.filter(prototype=self.service.prototype, name="with_jinja").first() - with patch("cm.job.run_task"): - task = run_action( + with RunTaskMock() as run_task: + run_action( action=action, obj=self.service, payload=ActionRunPayload(conf={"rolename": "test_user", "rolepass": raw_value}), - hosts=[], ) + task = run_task.target_task + self.assertIn("__ansible_vault", task.config["rolepass"]) self.assertEqual(ansible_decrypt(task.config["rolepass"]["__ansible_vault"]), raw_value) - job = JobLog.objects.filter(task=task).first() - job_config = get_job_config(job_scope=JobScope(job_id=job.pk, object=self.service)) + job, *_ = JobRepoImpl.get_task_jobs(task_id=task.id) + job_config = prepare_ansible_job_config( + task=JobRepoImpl.get_task(task.id), job=job, configuration=self.configuration + ) self.assertIn("__ansible_vault", job_config["job"]["config"]["rolepass"]) self.assertEqual(ansible_decrypt(job_config["job"]["config"]["rolepass"]["__ansible_vault"]), raw_value) @@ -220,22 +221,25 @@ def test_action_jinja_config_with_secret_map_and_default_null_password_bug_adcm_ self.change_configuration(target=self.cluster, config_diff={"boolean": True}) raw_value = {"key": "val", "another": "one"} action = Action.objects.filter(prototype=self.service.prototype, name="with_jinja").first() - with patch("cm.job.run_task"): - task = run_action( + with RunTaskMock() as run_task: + run_action( action=action, obj=self.service, payload=ActionRunPayload(conf={"reqsec": deepcopy(raw_value), "secretval": None}), - hosts=[], ) + task = run_task.target_task + self.assertIn("__ansible_vault", task.config["reqsec"]["key"]) self.assertIn("__ansible_vault", task.config["reqsec"]["another"]) self.assertEqual(ansible_decrypt(task.config["reqsec"]["key"]["__ansible_vault"]), raw_value["key"]) self.assertEqual(ansible_decrypt(task.config["reqsec"]["another"]["__ansible_vault"]), raw_value["another"]) self.assertEqual(task.config["secretval"], None) - job = JobLog.objects.filter(task=task).first() - job_config = get_job_config(job_scope=JobScope(job_id=job.pk, object=self.service)) + job, *_ = JobRepoImpl.get_task_jobs(task_id=task.id) + job_config = prepare_ansible_job_config( + task=JobRepoImpl.get_task(task.id), job=job, configuration=self.configuration + ) self.assertIn("__ansible_vault", job_config["job"]["config"]["reqsec"]["key"]) self.assertEqual( ansible_decrypt(job_config["job"]["config"]["reqsec"]["key"]["__ansible_vault"]), raw_value["key"] @@ -259,50 +263,37 @@ def setUp(self) -> None: "token": settings.STATUS_SECRET_KEY, } + self.configuration = ExternalSettings( + adcm=ADCMSettings(code_root_dir=settings.CODE_DIR, run_dir=settings.RUN_DIR, log_dir=settings.LOG_DIR), + ansible=AnsibleSettings(ansible_secret_script=settings.CODE_DIR / "ansible_secret.py"), + integrations=IntegrationsSettings(status_server_token=settings.STATUS_SECRET_KEY), + ) + def test_scripts_in_action_config(self) -> None: for action_name in ("job_proto_relative", "job_bundle_relative", "task_mixed"): for object_, type_name in ((self.cluster, "cluster"), (self.service_1, "service")): action = Action.objects.filter(prototype=object_.prototype, name=action_name).first() - selector = get_selector(obj=object_, action=action) - task = TaskLog.objects.create( - task_object=object_, - action=action, - start_date=timezone.now(), - finish_date=timezone.now(), - selector=selector, + target = CoreObjectDescriptor( + id=object_.pk, type=model_name_to_core_type(object_.__class__.__name__.lower()) + ) + task = prepare_task_for_action( + target=target, + owner=target, + action=action.pk, + payload=TaskPayloadDTO(), ) - if action.name != "task_mixed": - jobs = [ - JobLog.objects.create( - task=task, - action=action, - start_date=timezone.now(), - finish_date=timezone.now(), - selector=selector, - ) - ] - else: - jobs = [ - JobLog.objects.create( - task=task, - action=action, - sub_action=sub_action, - start_date=timezone.now(), - finish_date=timezone.now(), - selector=selector, - ) - for sub_action in SubAction.objects.filter(action=action) - ] - for job in jobs: - prefix = f"{action_name}_{job.sub_action.name if job.sub_action else ''}".strip("_") + for job in JobRepoImpl.get_task_jobs(task_id=task.id): + prefix = f"{action_name}_{job.name if action_name == 'task_mixed' else ''}".strip("_") with self.subTest( f"Action {action_name} for {object_.__class__.__name__} {object_.name} [{prefix}]" ): expected_data = self.render_json_template( file=self.templates_dir / "action_configs" / f"{prefix}_{type_name}.json.j2", - context={**self.context, "job_id": job.pk}, + context={**self.context, "job_id": job.id}, + ) + job_config = prepare_ansible_job_config( + task=JobRepoImpl.get_task(task.id), job=job, configuration=self.configuration ) - job_config = get_job_config(job_scope=JobScope(job_id=job.pk, object=object_)) self.assertDictEqual(job_config, expected_data) diff --git a/python/cm/tests/test_inventory/test_inventory.py b/python/cm/tests/test_inventory/test_inventory.py index c3873af0cf..e3a8f82090 100644 --- a/python/cm/tests/test_inventory/test_inventory.py +++ b/python/cm/tests/test_inventory/test_inventory.py @@ -12,7 +12,6 @@ from pathlib import Path -from unittest.mock import patch from adcm.tests.base import BaseTestCase from core.types import CoreObjectDescriptor @@ -35,6 +34,7 @@ from cm.services.job.inventory import get_inventory_data from cm.services.job.inventory._constants import MAINTENANCE_MODE_GROUP_SUFFIX from cm.services.job.types import HcAclAction +from cm.tests.mocks.task_runner import RunTaskMock from cm.tests.utils import ( gen_bundle, gen_cluster, @@ -276,10 +276,10 @@ def get_children_from_inventory(self, action: Action, object_: ObjectWithAction, self.assertEqual(TaskLog.objects.count(), 0) self.assertEqual(JobLog.objects.count(), 0) - with patch("cm.services.job.action.run_task"): - task = JobRepoImpl.get_task(run_action(action=action, obj=object_, payload=payload).id) + with RunTaskMock() as run_task: + run_action(action=action, obj=object_, payload=payload) - inventory = prepare_ansible_inventory(task=task) + inventory = prepare_ansible_inventory(task=JobRepoImpl.get_task(run_task.target_task.id)) return inventory["all"]["children"] def test_groups_remove_host_not_in_mm_success(self): diff --git a/python/core/job/runners.py b/python/core/job/runners.py index 3a7a1f4e0e..4c16619ff3 100644 --- a/python/core/job/runners.py +++ b/python/core/job/runners.py @@ -59,13 +59,13 @@ class ExecutionTarget(NamedTuple): finalizers: Iterable[JobFinalizer] -class JobToExecutionTargetConverter(Protocol): +class ExecutionTargetFactoryI(Protocol): def __call__(self, task: Task, jobs: Iterable[Job], configuration: ExternalSettings) -> Iterable[ExecutionTarget]: ... class JobProcessor(NamedTuple): - convert: JobToExecutionTargetConverter + convert: ExecutionTargetFactoryI # id will always return True in bool cast filter_predicate: Callable[[Job], bool] = id diff --git a/python/init_db.py b/python/init_db.py index e332edf1fc..00b17dc42e 100755 --- a/python/init_db.py +++ b/python/init_db.py @@ -20,8 +20,7 @@ import adcm.init_django # noqa: F401, isort:skip from cm.bundle import load_adcm -from cm.issue import update_hierarchy_issues -from cm.job import abort_all +from cm.issue import unlock_affected_objects, update_hierarchy_issues from cm.models import ( ADCM, CheckLog, From bcd96330695a62fe4ce1d2e2621ca22e1fb80e07 Mon Sep 17 00:00:00 2001 From: Egor Araslanov Date: Thu, 21 Mar 2024 10:52:08 +0500 Subject: [PATCH 009/208] Post-merge updates --- poetry.lock | 9 +-------- pyproject.toml | 15 ++++++++------- python/cm/migrations/0067_tasklog_object_type.py | 2 -- 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/poetry.lock b/poetry.lock index 5ddaf874ae..1ed8602d88 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1447,13 +1447,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1902,4 +1895,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "e886a96d5c5879dc27e67ab5e8e2712f5384bc313a409fda13159906fe88500b" +content-hash = "f8a8305f0016b7668a634cf4ab703cdd0c9096a38e54cf46760eb9e4677e418f" diff --git a/pyproject.toml b/pyproject.toml index a154e4918f..c0ab5ccfd9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,23 +8,27 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.10" +Django = "3.2.23" +adcm-version = "1.0.3" ansible = { git = "https://github.com/arenadata/ansible.git", branch = "v2.8.8-p6" } apache-libcloud = "3.8.0" attr = "0.3.2" casestyle = "0.0.4" coreapi = "2.3.3" cryptography = "41.0.7" -Django = "3.2.23" django-auth-ldap = "4.6.0" +django-cors-headers = "4.3.1" django-csp = "3.7" django-filter = "23.5" django-guardian = "2.4.0" -# we've decided to keep it at 3.14.0, since 3.15.0 introduced unwanted behavior # concerning our way of defining serializers, so we'd have to adopt code to update to 3.15 +# we've decided to keep it at 3.14.0, since 3.15.0 introduced unwanted behavior djangorestframework = "3.14.0" +djangorestframework-camel-case = "1.4.2" drf-extensions = "0.7.1" drf-flex-fields = "1.0.2" drf-nested-routers = "0.93.5" +drf-spectacular = {version = "0.27.0", extras = ["sidecar"]} googleapis-common-protos = "1.62.0" grpcio = "1.60.0" jinja2 = "2.11.3" @@ -40,13 +44,9 @@ python-gnupg = "0.5.2" requests-toolbelt = "1.0.0" rstr = "3.2.2" ruyaml = "0.91.0" +six = "1.16.0" social-auth-app-django = "5.4.0" uwsgi = "2.0.23" -six = "1.16.0" -django-cors-headers = "4.3.1" -djangorestframework-camel-case = "1.4.2" -adcm-version = "1.0.3" -drf-spectacular = {version = "0.27.0", extras = ["sidecar"]} [tool.poetry.group.lint] optional = true @@ -59,6 +59,7 @@ optional = true [tool.poetry.group.unittests.dependencies] tblib = "^2.0.0" +django-test-migrations = "^1.3.0" [tool.poetry.group.dev] optional = true diff --git a/python/cm/migrations/0067_tasklog_object_type.py b/python/cm/migrations/0067_tasklog_object_type.py index 7510a2e378..12877d1bc9 100644 --- a/python/cm/migrations/0067_tasklog_object_type.py +++ b/python/cm/migrations/0067_tasklog_object_type.py @@ -15,7 +15,6 @@ import django.db.models.deletion from django.db import migrations, models -<<<<<<< python/cm/migrations/0067_tasklog_object_type.py content = { "adcm": "adcm", "cluster": "cluster", @@ -28,7 +27,6 @@ "host": "host", } ->>>>>>> python/cm/migrations/0067_tasklog_object_type.py def fix_tasklog(apps, schema_editor): TaskLog = apps.get_model("cm", "TaskLog") From 0cae110bdbf4e927377c025095c3a509f431a91d Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Thu, 21 Mar 2024 11:39:10 +0000 Subject: [PATCH 010/208] ADCM-5359 ADCM-5360 ADCM-5361 Add restrictions on user create/update ADCM-5359 Only ADCM Administrator can create another ADCM Administrators ADCM-5360 Only ADCM Administrator can change user's password via `rbac/user(s)` endpoint ADCM-5361 Only ADCM Administrator can grant/withdraw admin rights ADCM-5361 ADCM Administrator can't withdraw own admin rights --- python/api/rbac/user/serializers.py | 38 ++++- python/api/tests/test_rbac_superuser.py | 174 +++++++++++++++++++++ python/api_v2/rbac/user/views.py | 32 +++- python/api_v2/tests/test_rbac_superuser.py | 173 ++++++++++++++++++++ python/api_v2/tests/test_user.py | 10 +- python/audit/tests/test_user.py | 5 +- python/rbac/services/user.py | 6 +- 7 files changed, 418 insertions(+), 20 deletions(-) create mode 100644 python/api/tests/test_rbac_superuser.py create mode 100644 python/api_v2/tests/test_rbac_superuser.py diff --git a/python/api/rbac/user/serializers.py b/python/api/rbac/user/serializers.py index a50f5fc527..c38037ca81 100644 --- a/python/api/rbac/user/serializers.py +++ b/python/api/rbac/user/serializers.py @@ -9,11 +9,11 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - - from adcm.serializers import EmptySerializer +from cm.errors import AdcmEx from django.conf import settings from rbac.models import Group, User +from rbac.utils import Empty from rest_flex_fields.serializers import FlexFieldsSerializerMixin from rest_framework.fields import ( BooleanField, @@ -29,6 +29,7 @@ Serializer, SerializerMethodField, ) +from rest_framework.status import HTTP_403_FORBIDDEN, HTTP_409_CONFLICT from api.rbac.utils import create_user, update_user @@ -77,7 +78,7 @@ class UserSerializer(FlexFieldsSerializerMixin, Serializer): default="", ) is_superuser = BooleanField(default=False) - password = CharField(trim_whitespace=False, write_only=True) + password = CharField(trim_whitespace=False, write_only=True, required=False) current_password = CharField(trim_whitespace=False, required=False) url = HyperlinkedIdentityField(view_name="v1:rbac:user-detail") profile = JSONField(required=False, default="") @@ -93,6 +94,28 @@ class Meta: def update(self, instance, validated_data): context_user = self.context["request"].user + if context_user.is_superuser and context_user.pk == instance.pk and validated_data.get("is_superuser") is False: + raise AdcmEx( + code="USER_UPDATE_ERROR", + http_code=HTTP_409_CONFLICT, + msg="You can't withdraw ADCM Administrator's rights from yourself.", + ) + + new_password = validated_data.get("password", Empty) + if not context_user.is_superuser: + if new_password is not Empty: + raise AdcmEx( + code="USER_UPDATE_ERROR", http_code=HTTP_403_FORBIDDEN, msg="You can't change user's password." + ) + + is_superuser = validated_data.get("is_superuser", Empty) + if is_superuser is not Empty and instance.is_superuser != is_superuser: + raise AdcmEx( + code="USER_UPDATE_ERROR", + http_code=HTTP_403_FORBIDDEN, + msg=f"You can't {'grant' if is_superuser else 'withdraw'} ADCM Administrator's rights.", + ) + return update_user( user=instance, context_user=context_user, @@ -102,6 +125,15 @@ def update(self, instance, validated_data): ) def create(self, validated_data): + context_user = self.context["request"].user + + if not context_user.is_superuser and validated_data["is_superuser"]: + raise AdcmEx( + code="USER_CREATE_ERROR", + http_code=HTTP_403_FORBIDDEN, + msg="You can't create user with ADCM Administrator's rights.", + ) + return create_user(**validated_data) diff --git a/python/api/tests/test_rbac_superuser.py b/python/api/tests/test_rbac_superuser.py new file mode 100644 index 0000000000..82dda0e31f --- /dev/null +++ b/python/api/tests/test_rbac_superuser.py @@ -0,0 +1,174 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from adcm.tests.base import TestUserCreateDTO +from api_v2.tests.base import BaseAPITestCase +from django.contrib.auth.hashers import check_password +from rbac.models import Role, User +from rbac.services import group +from rbac.services.policy import policy_create +from rbac.services.role import role_create +from rbac.services.user import perform_user_creation +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_403_FORBIDDEN, HTTP_409_CONFLICT +from rest_framework.test import APIClient + + +class TestUserCreateEdit(BaseAPITestCase): + def setUp(self) -> None: + super().setUp() + + self.password = "yes" * 5 + create_user_role = role_create( + name="Create Users", + display_name="Create Users", + child=[Role.objects.get(name="Create user", built_in=True)], + ) + edit_user_role = role_create( + name="Edit Users", + display_name="Edit Users", + child=[Role.objects.get(name="Edit user", built_in=True)], + ) + creators_group = group.create(name_to_display="Creators") + editors_group = group.create(name_to_display="Editors") + + self.creator = User.objects.get( + id=perform_user_creation( + create_data=TestUserCreateDTO(username="icancreate", password=self.password, is_superuser=False), + groups=[creators_group.pk], + ) + ) + self.editor = User.objects.get( + id=perform_user_creation( + create_data=TestUserCreateDTO(username="icanedit", password=self.password, is_superuser=False), + groups=[editors_group.pk], + ) + ) + + self.creator_client = APIClient() + self.creator_client.login(username="icancreate", password=self.password) + self.editor_client = APIClient() + self.editor_client.login(username="icanedit", password=self.password) + + policy_create(name="Creators policy", role=create_user_role, group=[creators_group]) + policy_create(name="Editors policy", role=edit_user_role, group=[editors_group]) + + self.new_user_data = { + "username": "newcooluser", + "password": "bestpassever", + "first_name": "Awesome", + "last_name": "Tiger", + "email": "difficult@to.me", + } + + @staticmethod + def request_create_user(client: APIClient, data: dict) -> Response: + return client.post(path="/api/v1/rbac/user/", data=data) + + @staticmethod + def request_edit_user(client: APIClient, user_id: int, data: dict) -> Response: + return client.patch(path=f"/api/v1/rbac/user/{user_id}/", data=data) + + # Create Restrictions + + def test_create_user_perm_does_not_allow_superuser_creation(self) -> None: + response = self.request_create_user( + client=self.creator_client, data=self.new_user_data | {"is_superuser": True} + ) + + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + self.assertEqual(response.json()["desc"], "You can't create user with ADCM Administrator's rights.") + self.assertFalse(User.objects.filter(username=self.new_user_data["username"]).exists()) + + def test_superuser_can_create_superuser(self) -> None: + response = self.request_create_user(client=self.client, data=self.new_user_data | {"is_superuser": True}) + + self.assertEqual(response.status_code, HTTP_201_CREATED) + new_user = User.objects.filter(username=self.new_user_data["username"]).first() + self.assertIsNotNone(new_user) + self.assertTrue(new_user.is_superuser) + + # Edit Restrictions + + def test_edit_user_perm_does_not_allow_superuser_status_change(self) -> None: + with self.subTest("Edit Oneself"): + response = self.request_edit_user( + client=self.editor_client, user_id=self.editor.pk, data={"is_superuser": True} + ) + + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + self.assertEqual(response.json()["desc"], "You can't grant ADCM Administrator's rights.") + self.editor.refresh_from_db() + self.assertFalse(self.editor.is_superuser) + + with self.subTest("Edit Another"): + response = self.request_edit_user( + client=self.editor_client, user_id=self.creator.pk, data={"is_superuser": True} + ) + + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + self.assertEqual(response.json()["desc"], "You can't grant ADCM Administrator's rights.") + self.creator.refresh_from_db() + self.assertFalse(self.creator.is_superuser) + + with self.subTest("Withdraw"): + admin = User.objects.get(username="admin") + response = self.request_edit_user(client=self.editor_client, user_id=admin.pk, data={"is_superuser": False}) + + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + self.assertEqual(response.json()["desc"], "You can't withdraw ADCM Administrator's rights.") + admin.refresh_from_db() + self.assertTrue(admin.is_superuser) + + def test_edit_user_perm_does_not_allow_changing_password(self) -> None: + with self.subTest("Changed Password Provided"): + response = self.request_edit_user( + client=self.editor_client, user_id=self.creator.pk, data={"password": "newpassgoodandbetter"} + ) + + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + self.assertEqual(response.json()["desc"], "You can't change user's password.") + self.creator.refresh_from_db() + check_password(self.password, self.creator.password) + + with self.subTest("Same Password Provided"): + response = self.request_edit_user( + client=self.editor_client, user_id=self.creator.pk, data={"password": self.password} + ) + + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + self.assertEqual(response.json()["desc"], "You can't change user's password.") + self.creator.refresh_from_db() + check_password(self.password, self.creator.password) + + def test_superuser_can_change_superuser_status_of_another_user(self) -> None: + with self.subTest("Can Grant"): + response = self.request_edit_user(client=self.client, user_id=self.editor.pk, data={"is_superuser": True}) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.editor.refresh_from_db() + self.assertTrue(self.editor.is_superuser) + + with self.subTest("Can Withdraw"): + response = self.request_edit_user(client=self.client, user_id=self.editor.pk, data={"is_superuser": False}) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.editor.refresh_from_db() + self.assertFalse(self.editor.is_superuser) + + def test_superuser_cannot_withdraw_own_superuser_status(self) -> None: + admin = User.objects.get(username="admin") + response = self.request_edit_user(client=self.client, user_id=admin.pk, data={"is_superuser": False}) + + self.assertEqual(response.status_code, HTTP_409_CONFLICT) + self.assertEqual(response.json()["desc"], "You can't withdraw ADCM Administrator's rights from yourself.") + admin.refresh_from_db() + self.assertTrue(admin.is_superuser) diff --git a/python/api_v2/rbac/user/views.py b/python/api_v2/rbac/user/views.py index 0d206ae6a4..de9b75bafb 100644 --- a/python/api_v2/rbac/user/views.py +++ b/python/api_v2/rbac/user/views.py @@ -77,6 +77,13 @@ def create(self, request: Request, *_, **__) -> Response: serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) + if not request.user.is_superuser and serializer.validated_data["is_superuser"]: + raise AdcmEx( + code="USER_CREATE_ERROR", + http_code=HTTP_403_FORBIDDEN, + msg="You can't create user with ADCM Administrator's rights.", + ) + try: user_id = perform_user_creation( create_data=UserCreateDTO(**serializer.validated_data), @@ -101,8 +108,16 @@ def partial_update(self, request: Request, *args, **kwargs) -> Response: # noqa validated_data = serializer.validated_data user_id = int(kwargs["pk"]) new_password = validated_data.get("password", None) + try: if request.user.is_superuser: + if request.user.pk == user_id and validated_data.get("is_superuser") is False: + raise AdcmEx( + code="USER_UPDATE_ERROR", + http_code=HTTP_409_CONFLICT, + msg="You can't withdraw ADCM Administrator's rights from yourself.", + ) + perform_user_update_as_superuser( user_id=user_id, update_data=UserUpdateDTO(**validated_data), @@ -110,9 +125,20 @@ def partial_update(self, request: Request, *args, **kwargs) -> Response: # noqa new_user_groups=set(validated_data["groups"]) if "groups" in validated_data else None, ) else: - perform_regular_user_update( - user_id=user_id, update_data=UserUpdateDTO(**validated_data), new_password=new_password - ) + if new_password is not None and not request.user.is_superuser: + raise AdcmEx( + code="USER_UPDATE_ERROR", http_code=HTTP_403_FORBIDDEN, msg="You can't change user's password." + ) + + if "is_superuser" in validated_data: + raise AdcmEx( + code="USER_UPDATE_ERROR", + http_code=HTTP_403_FORBIDDEN, + msg=f"You can't {'grant' if validated_data['is_superuser'] else 'withdraw'} " + "ADCM Administrator's rights.", + ) + + perform_regular_user_update(user_id=user_id, update_data=UserUpdateDTO(**validated_data)) except EmailTakenError: raise AdcmEx(code="USER_CONFLICT", msg="User with the same email already exist") from None except PasswordError as err: diff --git a/python/api_v2/tests/test_rbac_superuser.py b/python/api_v2/tests/test_rbac_superuser.py new file mode 100644 index 0000000000..9fbfc6e303 --- /dev/null +++ b/python/api_v2/tests/test_rbac_superuser.py @@ -0,0 +1,173 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from adcm.tests.base import TestUserCreateDTO +from django.contrib.auth.hashers import check_password +from rbac.models import Role, User +from rbac.services import group +from rbac.services.policy import policy_create +from rbac.services.role import role_create +from rbac.services.user import perform_user_creation +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_403_FORBIDDEN, HTTP_409_CONFLICT +from rest_framework.test import APIClient + +from api_v2.tests.base import BaseAPITestCase + + +class TestUserCreateEdit(BaseAPITestCase): + def setUp(self) -> None: + super().setUp() + + self.password = "yes" * 5 + create_user_role = role_create( + name="Create Users", + display_name="Create Users", + child=[Role.objects.get(name="Create user", built_in=True)], + ) + edit_user_role = role_create( + name="Edit Users", + display_name="Edit Users", + child=[Role.objects.get(name="Edit user", built_in=True)], + ) + creators_group = group.create(name_to_display="Creators") + editors_group = group.create(name_to_display="Editors") + + self.creator = User.objects.get( + id=perform_user_creation( + create_data=TestUserCreateDTO(username="icancreate", password=self.password, is_superuser=False), + groups=[creators_group.pk], + ) + ) + self.editor = User.objects.get( + id=perform_user_creation( + create_data=TestUserCreateDTO(username="icanedit", password=self.password, is_superuser=False), + groups=[editors_group.pk], + ) + ) + + self.creator_client = APIClient() + self.creator_client.login(username="icancreate", password=self.password) + self.editor_client = APIClient() + self.editor_client.login(username="icanedit", password=self.password) + + policy_create(name="Creators policy", role=create_user_role, group=[creators_group]) + policy_create(name="Editors policy", role=edit_user_role, group=[editors_group]) + + self.new_user_data = { + "username": "newcooluser", + "password": "bestpassever", + "firstName": "Awesome", + "lastName": "Tiger", + "email": "difficult@to.me", + } + + @staticmethod + def request_create_user(client: APIClient, data: dict) -> Response: + return client.post(path="/api/v2/rbac/users/", data=data) + + @staticmethod + def request_edit_user(client: APIClient, user_id: int, data: dict) -> Response: + return client.patch(path=f"/api/v2/rbac/users/{user_id}/", data=data) + + # Create Restrictions + + def test_create_user_perm_does_not_allow_superuser_creation(self) -> None: + response = self.request_create_user(client=self.creator_client, data=self.new_user_data | {"isSuperUser": True}) + + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + self.assertEqual(response.json()["desc"], "You can't create user with ADCM Administrator's rights.") + self.assertFalse(User.objects.filter(username=self.new_user_data["username"]).exists()) + + def test_superuser_can_create_superuser(self) -> None: + response = self.request_create_user(client=self.client, data=self.new_user_data | {"isSuperUser": True}) + + self.assertEqual(response.status_code, HTTP_201_CREATED) + new_user = User.objects.filter(username=self.new_user_data["username"]).first() + self.assertIsNotNone(new_user) + self.assertTrue(new_user.is_superuser) + + # Edit Restrictions + + def test_edit_user_perm_does_not_allow_superuser_status_change(self) -> None: + with self.subTest("Edit Oneself"): + response = self.request_edit_user( + client=self.editor_client, user_id=self.editor.pk, data={"isSuperUser": True} + ) + + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + self.assertEqual(response.json()["desc"], "You can't grant ADCM Administrator's rights.") + self.editor.refresh_from_db() + self.assertFalse(self.editor.is_superuser) + + with self.subTest("Edit Another"): + response = self.request_edit_user( + client=self.editor_client, user_id=self.creator.pk, data={"isSuperUser": True} + ) + + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + self.assertEqual(response.json()["desc"], "You can't grant ADCM Administrator's rights.") + self.creator.refresh_from_db() + self.assertFalse(self.creator.is_superuser) + + with self.subTest("Withdraw"): + admin = User.objects.get(username="admin") + response = self.request_edit_user(client=self.editor_client, user_id=admin.pk, data={"isSuperUser": False}) + + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + self.assertEqual(response.json()["desc"], "You can't withdraw ADCM Administrator's rights.") + admin.refresh_from_db() + self.assertTrue(admin.is_superuser) + + def test_edit_user_perm_does_not_allow_changing_password(self) -> None: + with self.subTest("Changed Password Provided"): + response = self.request_edit_user( + client=self.editor_client, user_id=self.creator.pk, data={"password": "newpassgoodandbetter"} + ) + + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + self.assertEqual(response.json()["desc"], "You can't change user's password.") + self.creator.refresh_from_db() + check_password(self.password, self.creator.password) + + with self.subTest("Same Password Provided"): + response = self.request_edit_user( + client=self.editor_client, user_id=self.creator.pk, data={"password": self.password} + ) + + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + self.assertEqual(response.json()["desc"], "You can't change user's password.") + self.creator.refresh_from_db() + check_password(self.password, self.creator.password) + + def test_superuser_can_change_superuser_status_of_another_user(self) -> None: + with self.subTest("Can Grant"): + response = self.request_edit_user(client=self.client, user_id=self.editor.pk, data={"isSuperUser": True}) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.editor.refresh_from_db() + self.assertTrue(self.editor.is_superuser) + + with self.subTest("Can Withdraw"): + response = self.request_edit_user(client=self.client, user_id=self.editor.pk, data={"isSuperUser": False}) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.editor.refresh_from_db() + self.assertFalse(self.editor.is_superuser) + + def test_superuser_cannot_withdraw_own_superuser_status(self) -> None: + admin = User.objects.get(username="admin") + response = self.request_edit_user(client=self.client, user_id=admin.pk, data={"isSuperUser": False}) + + self.assertEqual(response.status_code, HTTP_409_CONFLICT) + self.assertEqual(response.json()["desc"], "You can't withdraw ADCM Administrator's rights from yourself.") + admin.refresh_from_db() + self.assertTrue(admin.is_superuser) diff --git a/python/api_v2/tests/test_user.py b/python/api_v2/tests/test_user.py index d2240304ef..955a522544 100644 --- a/python/api_v2/tests/test_user.py +++ b/python/api_v2/tests/test_user.py @@ -324,11 +324,9 @@ def test_update_self_by_regular_user_success(self): response = self.client.patch( path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk}), data={ - "password": "newtestpassword", "email": "test_user@mail.ru", "firstName": "test_user_first_name", "lastName": "test_user_last_name", - "isSuperUser": True, "groups": [group.pk], }, ) @@ -337,8 +335,7 @@ def test_update_self_by_regular_user_success(self): data = response.json() self.assertEqual(response.status_code, HTTP_200_OK) - self.assertFalse(user.check_password(raw_password="test_user_password")) - self.assertTrue(user.check_password(raw_password="newtestpassword")) + self.assertTrue(user.check_password(raw_password="test_user_password")) self.assertEqual(data["email"], "test_user@mail.ru") self.assertEqual(data["firstName"], "test_user_first_name") self.assertEqual(data["lastName"], "test_user_last_name") @@ -361,11 +358,9 @@ def test_update_not_self_by_regular_user_success(self): self.client.login(username="test_user", password="test_user_password") new_data = { - "password": "newtestuser2password", "email": "new_test_user2@mail.ru", "firstName": "new_test_user2_first_name", "lastName": "new_test_user2_last_name", - "isSuperUser": True, "groups": [group.pk], } response = self.client.patch( @@ -375,8 +370,7 @@ def test_update_not_self_by_regular_user_success(self): second_user.refresh_from_db() self.assertEqual(response.status_code, HTTP_200_OK) - self.assertTrue(second_user.check_password(raw_password=new_data["password"])) - self.assertFalse(second_user.check_password(raw_password="test_user2_password")) + self.assertTrue(second_user.check_password(raw_password="test_user2_password")) self.assertEqual(second_user.email, new_data["email"]) self.assertEqual(second_user.first_name, new_data["firstName"]) self.assertEqual(second_user.last_name, new_data["lastName"]) diff --git a/python/audit/tests/test_user.py b/python/audit/tests/test_user.py index 732b38d083..100eec05eb 100644 --- a/python/audit/tests/test_user.py +++ b/python/audit/tests/test_user.py @@ -199,11 +199,12 @@ def test_update_put(self): prev_first_name = self.test_user.first_name prev_is_superuser = self.test_user.is_superuser new_test_first_name = "test_first_name" + admin = User.objects.get(username="admin") + self.client.login(username="admin", password="admin") self.client.put( path=reverse(viewname=self.detail_name, kwargs={"pk": self.test_user.pk}), data={ "username": self.test_user_username, - "password": self.test_user_password, "first_name": new_test_first_name, }, content_type=APPLICATION_JSON, @@ -214,7 +215,7 @@ def test_update_put(self): self.check_log( log=log, operation_result=AuditLogOperationResult.SUCCESS, - user=self.test_user, + user=admin, object_changes={ "current": { "first_name": new_test_first_name, diff --git a/python/rbac/services/user.py b/python/rbac/services/user.py index a16bcd9dcf..8878542b6d 100644 --- a/python/rbac/services/user.py +++ b/python/rbac/services/user.py @@ -154,14 +154,12 @@ def perform_user_update_as_superuser( ) -def perform_regular_user_update(user_id: UserID, update_data: UserUpdateDTO, new_password: str | None) -> UserID: +def perform_regular_user_update(user_id: UserID, update_data: UserUpdateDTO) -> UserID: # users can't change `is_superuser` flag if is not superuser themselves, # so we need to nullify it here update_data.is_superuser = None - return _perform_user_update( - user_id=user_id, update_data=update_data, new_password=new_password, new_user_groups=None - ) + return _perform_user_update(user_id=user_id, update_data=update_data, new_password=None, new_user_groups=None) def perform_users_block(users: Iterable[UserID]) -> Iterable[UserID]: From 9458b8b426222e154c0fab034bafa7865acc0961 Mon Sep 17 00:00:00 2001 From: Dmitriy Bardin Date: Thu, 21 Mar 2024 16:33:04 +0000 Subject: [PATCH 011/208] ADCM-5405 - [UI] Change cluster name validation https://tracker.yandex.ru/ADCM-5405 --- adcm-web/app/src/utils/validationsUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adcm-web/app/src/utils/validationsUtils.ts b/adcm-web/app/src/utils/validationsUtils.ts index a127e8247f..8d30c16c3e 100644 --- a/adcm-web/app/src/utils/validationsUtils.ts +++ b/adcm-web/app/src/utils/validationsUtils.ts @@ -24,7 +24,7 @@ export const isEmailValid = (email: string) => { }; export const isClusterNameValid = (clusterName: string) => { - return /(?=.{2,150}$)^[a-z].*[^\s]$/i.test(clusterName); + return /^[a-z0-9][a-z0-9._-]{0,148}[a-z0-9]$/i.test(clusterName); }; export const isHostNameValid = (hostName: string) => { From 940a0aa58f80bb9cda8feedd8ecf238cdd16edd4 Mon Sep 17 00:00:00 2001 From: Dmitriy Bardin Date: Thu, 21 Mar 2024 16:35:01 +0000 Subject: [PATCH 012/208] ADCM-5029 - Strange behavior of nullable pick on "reset to default" when default not specified https://tracker.yandex.ru/ADCM-5029 --- .../Dialogs/FieldControls/ConfigurationField.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/ConfigurationField.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/ConfigurationField.tsx index 3e4e23b7b7..e5f69624b7 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/ConfigurationField.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/ConfigurationField.tsx @@ -22,7 +22,7 @@ const ConfigurationField = ({ onResetToDefault, }: ConfigurationFieldProps) => { const handleResetToDefaultClick = () => { - onResetToDefault((fieldSchema.default ?? '') as JSONPrimitive); + onResetToDefault(fieldSchema.default as JSONPrimitive); }; return ( From 87b1575b42ad42ccd5b2d9cf022c38b266e4722c Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Fri, 22 Mar 2024 09:29:14 +0000 Subject: [PATCH 013/208] ADCM-5396 Improve `bundles` and `prototypes` auto-generated schema --- python/adcm/api_schema.py | 105 +++++++++++++++++++++++++ python/adcm/settings.py | 9 ++- python/api_v2/api_schema.py | 20 +++++ python/api_v2/bundle/serializers.py | 18 +++-- python/api_v2/bundle/views.py | 35 ++++++++- python/api_v2/prototype/serializers.py | 21 +++-- python/api_v2/prototype/views.py | 29 +++++-- 7 files changed, 214 insertions(+), 23 deletions(-) create mode 100644 python/adcm/api_schema.py create mode 100644 python/api_v2/api_schema.py diff --git a/python/adcm/api_schema.py b/python/adcm/api_schema.py new file mode 100644 index 0000000000..9bc76087a0 --- /dev/null +++ b/python/adcm/api_schema.py @@ -0,0 +1,105 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from itertools import chain +from typing import Iterable + +from drf_spectacular.generators import SchemaGenerator +from rest_framework.request import Request + +_REF_PREFIX = "#/components/schemas/" + + +def make_all_fields_required_in_response(generator: SchemaGenerator, request: Request, public: bool, result: dict): + _ = generator, request, public + + schemas = result.get("components", {}).get("schemas", {}) + if not schemas: + return result + + references_to_response_models = { + response_dict.get("content", {}).get("application/json", {}).get("schema", {}).get("$ref", None) + for response_dict in chain.from_iterable( + chain.from_iterable(m.get("responses", {}).values() for m in methods_dict.values()) + for methods_dict in result.get("paths", {}).values() + ) + } - {None} + + nested_models = ( + _make_all_properties_required( + components=( + _deref_component(schemas=schemas, component_ref=ref) + for ref in references_to_response_models + if ref.startswith(_REF_PREFIX) + ) + ) + - references_to_response_models + ) + + if not nested_models: + return result + + while ( + nested_models := _make_all_properties_required( + components=( + _deref_component(schemas=schemas, component_ref=ref) + for ref in nested_models + if ref.startswith(_REF_PREFIX) + ) + ) + - references_to_response_models + - nested_models + ): + continue + + return result + + +def _deref_component(schemas: dict, component_ref: str) -> dict | None: + return schemas.get(component_ref.rsplit("/", maxsplit=1)[-1]) + + +def _make_all_properties_required(components: Iterable[dict | None]) -> set[str]: + nested_models = set() + + for response_component in components: + if not (response_component and response_component.get("type") in ("object", "array")): + continue + + if response_component["type"] == "array": + _find_inner_component_links(node=response_component["items"], acc=nested_models) + continue + + properties = response_component.get("properties") + if not properties: + continue + + if properties.get("results", {}).get("type") == "array": + _find_inner_component_links(node=properties["results"]["items"], acc=nested_models) + continue + + response_component["required"] = list(properties.keys()) + + _find_inner_component_links(node=properties, acc=nested_models) + + return nested_models + + +def _find_inner_component_links(node: dict, acc: set[str]) -> set[str]: + if "$ref" in node: + acc.add(node["$ref"]) + return acc + + for inner in node.values(): + if isinstance(inner, dict): + _find_inner_component_links(inner, acc=acc) + + return acc diff --git a/python/adcm/settings.py b/python/adcm/settings.py index 682183a9c5..c0f628b6b8 100644 --- a/python/adcm/settings.py +++ b/python/adcm/settings.py @@ -150,7 +150,7 @@ ], "EXCEPTION_HANDLER": "cm.errors.custom_drf_exception_handler", "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning", - "DEFAULT_VERSION": "v1", + "DEFAULT_VERSION": "v2", "TEST_REQUEST_DEFAULT_FORMAT": "json", "JSON_UNDERSCOREIZE": { "ignore_fields": ("config", "configSchema", "adcmMeta", "properties"), @@ -341,12 +341,17 @@ SPECTACULAR_SETTINGS = { "TITLE": "ADCM API", "DESCRIPTION": "Arenadata Cluster Manager", - "VERSION": "2.0.0", + "VERSION": "0.1.0", "SERVE_INCLUDE_SCHEMA": False, "SCHEMA_PATH_PREFIX": r"/api/v[0-9]", "SWAGGER_UI_DIST": "SIDECAR", "SWAGGER_UI_FAVICON_HREF": "SIDECAR", "REDOC_DIST": "SIDECAR", + "POSTPROCESSING_HOOKS": [ + "drf_spectacular.hooks.postprocess_schema_enums", + "drf_spectacular.contrib.djangorestframework_camel_case.camelize_serializer_fields", + "adcm.api_schema.make_all_fields_required_in_response", + ], } USERNAME_MAX_LENGTH = 150 diff --git a/python/api_v2/api_schema.py b/python/api_v2/api_schema.py new file mode 100644 index 0000000000..3880525062 --- /dev/null +++ b/python/api_v2/api_schema.py @@ -0,0 +1,20 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from adcm.serializers import EmptySerializer +from rest_framework.fields import CharField + + +class ErrorSerializer(EmptySerializer): + code = CharField() + level = CharField() + desc = CharField() diff --git a/python/api_v2/bundle/serializers.py b/python/api_v2/bundle/serializers.py index 87698eecc3..fe1c3e05ee 100644 --- a/python/api_v2/bundle/serializers.py +++ b/python/api_v2/bundle/serializers.py @@ -10,9 +10,11 @@ # See the License for the specific language governing permissions and # limitations under the License. from adcm.serializers import EmptySerializer -from cm.models import Bundle, HostProvider, ObjectType, Prototype +from cm.models import LICENSE_STATE, Bundle, HostProvider, ObjectType, Prototype +from drf_spectacular.utils import extend_schema_field from rest_framework.fields import ( CharField, + ChoiceField, DateTimeField, FileField, IntegerField, @@ -24,14 +26,16 @@ class BundleRelatedSerializer(ModelSerializer): + edition = CharField() + class Meta: model = Bundle fields = ["id", "edition"] class MainPrototypeLicenseSerializer(EmptySerializer): - status = CharField(source="main_prototype_license") - text = SerializerMethodField() + status = ChoiceField(choices=LICENSE_STATE, source="main_prototype_license") + text = SerializerMethodField(allow_null=True) @staticmethod def get_text(bundle: Bundle): @@ -47,16 +51,18 @@ class MainPrototypeSerializer(EmptySerializer): name = CharField(source="main_prototype_name") display_name = CharField() description = CharField(source="main_prototype_description") - type = CharField() + type = ChoiceField(choices=(ObjectType.CLUSTER.value, ObjectType.PROVIDER.value)) license = SerializerMethodField() version = CharField() @staticmethod + @extend_schema_field(field=MainPrototypeLicenseSerializer) def get_license(bundle: Bundle) -> dict: return MainPrototypeLicenseSerializer(instance=bundle).data -class BundleListSerializer(ModelSerializer): +class BundleSerializer(ModelSerializer): + edition = CharField() upload_time = DateTimeField(read_only=True, source="date") display_name = CharField(read_only=True) main_prototype = SerializerMethodField() @@ -71,11 +77,11 @@ class Meta: "edition", "main_prototype", "upload_time", - "category", "signature_status", ) @staticmethod + @extend_schema_field(field=MainPrototypeSerializer) def get_main_prototype(bundle: Bundle) -> dict: return MainPrototypeSerializer(instance=bundle).data diff --git a/python/api_v2/bundle/views.py b/python/api_v2/bundle/views.py index 42cab54fa3..b10bcbdcce 100644 --- a/python/api_v2/bundle/views.py +++ b/python/api_v2/bundle/views.py @@ -15,6 +15,7 @@ from cm.models import Bundle, ObjectType from django.db.models import F from django_filters.rest_framework.backends import DjangoFilterBackend +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework.mixins import ( CreateModelMixin, DestroyModelMixin, @@ -24,11 +25,23 @@ from rest_framework.response import Response from rest_framework.status import HTTP_201_CREATED, HTTP_204_NO_CONTENT +from api_v2.api_schema import ErrorSerializer from api_v2.bundle.filters import BundleFilter -from api_v2.bundle.serializers import BundleListSerializer, UploadBundleSerializer +from api_v2.bundle.serializers import BundleSerializer, UploadBundleSerializer from api_v2.views import CamelCaseGenericViewSet +@extend_schema_view( + list=extend_schema( + operation_id="getBundles", + description="Get a list of ADCM bundles with information on them.", + ), + retrieve=extend_schema( + operation_id="getBundle", + description="Get detail information about a specific bundle.", + responses={200: BundleSerializer, 404: ErrorSerializer}, + ), +) class BundleViewSet(ListModelMixin, RetrieveModelMixin, DestroyModelMixin, CreateModelMixin, CamelCaseGenericViewSet): queryset = ( Bundle.objects.exclude(name="ADCM") @@ -53,8 +66,19 @@ def get_serializer_class(self): if self.action == "create": return UploadBundleSerializer - return BundleListSerializer + return BundleSerializer + @extend_schema( + operation_id="postBundles", + description="Upload new bundle.", + request={"multipart/form-data": UploadBundleSerializer}, + responses={ + 201: BundleSerializer, + 400: ErrorSerializer, + 403: ErrorSerializer, + 409: ErrorSerializer, + }, + ) @audit def create(self, request, *args, **kwargs) -> Response: # noqa: ARG002 serializer = self.get_serializer(data=request.data) @@ -63,9 +87,14 @@ def create(self, request, *args, **kwargs) -> Response: # noqa: ARG002 bundle = load_bundle(bundle_file=str(file_path)) return Response( - status=HTTP_201_CREATED, data=BundleListSerializer(instance=self.get_queryset().get(id=bundle.pk)).data + status=HTTP_201_CREATED, data=BundleSerializer(instance=self.get_queryset().get(id=bundle.pk)).data ) + @extend_schema( + operation_id="deleteBundle", + description="Delete a specific ADCM bundle.", + responses={204: None, 403: ErrorSerializer, 404: ErrorSerializer, 409: ErrorSerializer}, + ) @audit def destroy(self, request, *args, **kwargs) -> Response: # noqa: ARG002 bundle = self.get_object() diff --git a/python/api_v2/prototype/serializers.py b/python/api_v2/prototype/serializers.py index 468b984b34..b6978f8300 100644 --- a/python/api_v2/prototype/serializers.py +++ b/python/api_v2/prototype/serializers.py @@ -9,19 +9,24 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - from adcm.serializers import EmptySerializer -from cm.models import Prototype -from rest_framework.fields import CharField, IntegerField, SerializerMethodField +from cm.models import LICENSE_STATE, Prototype +from drf_spectacular.utils import extend_schema_field +from rest_framework.fields import CharField, ChoiceField, IntegerField, SerializerMethodField from rest_framework.serializers import ModelSerializer from api_v2.bundle.serializers import BundleRelatedSerializer from api_v2.prototype.utils import get_license_text -class PrototypeListSerializer(ModelSerializer): +class LicenseSerializer(EmptySerializer): + status = ChoiceField(choices=LICENSE_STATE) + text = SerializerMethodField(allow_null=True) + + +class PrototypeSerializer(ModelSerializer): license = SerializerMethodField() - bundle = BundleRelatedSerializer(read_only=True) + bundle = BundleRelatedSerializer() class Meta: model = Prototype @@ -37,6 +42,7 @@ class Meta: ) @staticmethod + @extend_schema_field(field=LicenseSerializer) def get_license(prototype: Prototype) -> dict: return { "status": prototype.license, @@ -52,19 +58,20 @@ class PrototypeVersionSerializer(ModelSerializer): id = IntegerField(source="pk") version = CharField() bundle = BundleRelatedSerializer(read_only=True) - license_status = CharField(source="license") + license_status = ChoiceField(source="license", choices=LICENSE_STATE) class Meta: model = Prototype fields = ("id", "bundle", "version", "license_status") -class PrototypeTypeSerializer(EmptySerializer): +class PrototypeVersionsSerializer(EmptySerializer): name = CharField() display_name = CharField() versions = SerializerMethodField() @staticmethod + @extend_schema_field(field=PrototypeVersionSerializer(many=True)) def get_versions(obj: Prototype) -> str | None: queryset = ( Prototype.objects.select_related("bundle") diff --git a/python/api_v2/prototype/views.py b/python/api_v2/prototype/views.py index 1522f50017..13e955dafe 100644 --- a/python/api_v2/prototype/views.py +++ b/python/api_v2/prototype/views.py @@ -9,26 +9,35 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - from adcm.permissions import VIEW_CLUSTER_PERM, DjangoModelPermissionsAudit from adcm.serializers import EmptySerializer from audit.utils import audit from cm.models import ObjectType, Prototype from django.db.models import QuerySet +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework.decorators import action from rest_framework.request import Request from rest_framework.response import Response from rest_framework.status import HTTP_200_OK +from api_v2.api_schema import ErrorSerializer from api_v2.prototype.filters import PrototypeFilter, PrototypeVersionFilter from api_v2.prototype.serializers import ( - PrototypeListSerializer, - PrototypeTypeSerializer, + PrototypeSerializer, + PrototypeVersionsSerializer, ) from api_v2.prototype.utils import accept_license from api_v2.views import CamelCaseReadOnlyModelViewSet +@extend_schema_view( + list=extend_schema(operation_id="getPrototypes", description="Get a list of all prototypes."), + retrieve=extend_schema( + operation_id="getPrototype", + description="Get detail information about a specific prototype.", + responses={200: PrototypeSerializer, 404: ErrorSerializer}, + ), +) class PrototypeViewSet(CamelCaseReadOnlyModelViewSet): queryset = Prototype.objects.exclude(type="adcm").select_related("bundle").order_by("name") permission_classes = [DjangoModelPermissionsAudit] @@ -37,18 +46,28 @@ class PrototypeViewSet(CamelCaseReadOnlyModelViewSet): def get_serializer_class(self): if self.action == "versions": - return PrototypeTypeSerializer + return PrototypeVersionsSerializer if self.action == "accept": return EmptySerializer - return PrototypeListSerializer + return PrototypeSerializer + @extend_schema( + operation_id="getPrototypeVersions", + description="Get a list of ADCM bundles when creating an object (cluster or provider).", + responses={200: PrototypeVersionsSerializer(many=True)}, + ) @action(methods=["get"], detail=False, filterset_class=PrototypeVersionFilter) def versions(self, request): # noqa: ARG001, ARG002 queryset = self.get_filtered_prototypes_unique_by_display_name() return Response(data=self.get_serializer(queryset, many=True).data) + @extend_schema( + operation_id="postLicense", + description="Accept prototype license.", + responses={200: None, 404: ErrorSerializer, 409: ErrorSerializer}, + ) @audit @action(methods=["post"], detail=True, url_path="license/accept", url_name="accept-license") def accept(self, request: Request, *args, **kwargs) -> Response: # noqa: ARG001, ARG002 From df2223f0918e0311ca058fa9a5b7cdc5058f85e6 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Fri, 22 Mar 2024 10:01:10 +0000 Subject: [PATCH 014/208] ADCM-5395 Improve auto-generated schema for `/audit` --- python/api_v2/audit/serializers.py | 32 ++++++++++++++++++++---------- python/api_v2/audit/views.py | 29 ++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/python/api_v2/audit/serializers.py b/python/api_v2/audit/serializers.py index c40a92b7bc..88dd0358d2 100644 --- a/python/api_v2/audit/serializers.py +++ b/python/api_v2/audit/serializers.py @@ -10,15 +10,24 @@ # See the License for the specific language governing permissions and # limitations under the License. -from audit.models import AuditLog, AuditObject, AuditSession, AuditUser -from rest_framework.fields import CharField, DateTimeField, DictField, IntegerField +from audit.models import ( + AuditLog, + AuditLogOperationResult, + AuditLogOperationType, + AuditObject, + AuditObjectType, + AuditSession, + AuditSessionLoginResult, + AuditUser, +) +from rest_framework.fields import CharField, ChoiceField, DateTimeField, DictField, IntegerField from rest_framework.serializers import ModelSerializer class AuditObjectSerializer(ModelSerializer): - id = IntegerField(read_only=True, source="object_id") - type = CharField(read_only=True, source="object_type") - name = CharField(read_only=True, source="object_name") + id = IntegerField(source="object_id") + type = ChoiceField(choices=AuditObjectType, source="object_type") + name = CharField(source="object_name") class Meta: model = AuditObject @@ -35,11 +44,12 @@ class Meta: class AuditLogSerializer(ModelSerializer): time = DateTimeField(source="operation_time") - name = CharField(read_only=True, source="operation_name") - type = CharField(read_only=True, source="operation_type") - result = CharField(read_only=True, source="operation_result") - object = AuditObjectSerializer(source="audit_object", read_only=True, allow_null=True) - user = AuditUserShortSerializer(read_only=True, allow_null=True) + name = CharField(source="operation_name") + type = ChoiceField(choices=AuditLogOperationType, source="operation_type") + result = ChoiceField(choices=AuditLogOperationResult, source="operation_result") + object = AuditObjectSerializer(source="audit_object", allow_null=True) + user = AuditUserShortSerializer(allow_null=True) + object_changes = DictField() class Meta: model = AuditLog @@ -57,7 +67,7 @@ class Meta: class AuditSessionSerializer(ModelSerializer): user = AuditUserShortSerializer(read_only=True, allow_null=True) - result = CharField(source="login_result") + result = ChoiceField(choices=AuditSessionLoginResult, source="login_result") time = DateTimeField(source="login_time") details = DictField(source="login_details") diff --git a/python/api_v2/audit/views.py b/python/api_v2/audit/views.py index 5ef5af284b..34269a314f 100644 --- a/python/api_v2/audit/views.py +++ b/python/api_v2/audit/views.py @@ -9,17 +9,31 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - from audit.models import AuditLog, AuditSession from django_filters.rest_framework.backends import DjangoFilterBackend +from drf_spectacular.utils import extend_schema, extend_schema_view from guardian.mixins import PermissionListMixin from rest_framework.permissions import DjangoObjectPermissions +from api_v2.api_schema import ErrorSerializer from api_v2.audit.filters import AuditLogFilterSet, AuditSessionFilterSet from api_v2.audit.serializers import AuditLogSerializer, AuditSessionSerializer from api_v2.views import CamelCaseReadOnlyModelViewSet +@extend_schema_view( + list=extend_schema( + operation_id="getAuditLogins", + summary="GET audit logins", + description="Get information about auditing user authorizations in ADCM.", + ), + retrieve=extend_schema( + operation_id="getAuditLogin", + summary="GET audit login", + description="Get information about a specific user authorization in ADCM.", + responses={200: AuditSessionSerializer, 404: ErrorSerializer}, + ), +) class AuditSessionViewSet(PermissionListMixin, CamelCaseReadOnlyModelViewSet): queryset = AuditSession.objects.select_related("user").order_by("-login_time") serializer_class = AuditSessionSerializer @@ -29,6 +43,19 @@ class AuditSessionViewSet(PermissionListMixin, CamelCaseReadOnlyModelViewSet): filter_backends = (DjangoFilterBackend,) +@extend_schema_view( + list=extend_schema( + operation_id="getAuditOperations", + summary="GET audit operations", + description="Get a list of audited ADCM operations.", + ), + retrieve=extend_schema( + operation_id="getAuditOperation", + summary="GET audit operation", + description="Get information about a specific ADCM operation being audited.", + responses={200: AuditLogSerializer, 404: ErrorSerializer}, + ), +) class AuditLogViewSet(PermissionListMixin, CamelCaseReadOnlyModelViewSet): queryset = AuditLog.objects.select_related("audit_object", "user").order_by("-operation_time") serializer_class = AuditLogSerializer From 232ed2758ee5ebd6ff5ce3471f45802599a06de9 Mon Sep 17 00:00:00 2001 From: Artem Starovoitov Date: Fri, 22 Mar 2024 11:08:29 +0000 Subject: [PATCH 015/208] ADCM-5371: [Backend] Different errors on one mistake --- python/api_v2/cluster/serializers.py | 7 +++- python/api_v2/tests/test_cluster.py | 58 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/python/api_v2/cluster/serializers.py b/python/api_v2/cluster/serializers.py index cd319b673f..68167d9b60 100644 --- a/python/api_v2/cluster/serializers.py +++ b/python/api_v2/cluster/serializers.py @@ -78,6 +78,9 @@ class Meta: class ClusterCreateSerializer(EmptySerializer): prototype_id = IntegerField() name = CharField( + min_length=2, + max_length=150, + trim_whitespace=False, validators=[ ClusterUniqueValidator(queryset=Cluster.objects.all()), StartMidEndValidator( @@ -94,7 +97,9 @@ class ClusterCreateSerializer(EmptySerializer): class ClusterUpdateSerializer(ModelSerializer): name = CharField( - max_length=80, + min_length=2, + max_length=150, + trim_whitespace=False, validators=[ ClusterUniqueValidator(queryset=Cluster.objects.all()), StartMidEndValidator( diff --git a/python/api_v2/tests/test_cluster.py b/python/api_v2/tests/test_cluster.py index cb0bd02f19..2ceb2676c8 100644 --- a/python/api_v2/tests/test_cluster.py +++ b/python/api_v2/tests/test_cluster.py @@ -201,6 +201,64 @@ def test_create_without_not_required_field_success(self): self.assertEqual(response.status_code, HTTP_201_CREATED) self.assertEqual(cluster.description, "") + def test_create_adcm_5371_start_digits_success(self): + response = self.client.post( + path=reverse(viewname="v2:cluster-list"), + data={"prototype_id": self.cluster_1.prototype.pk, "name": "1new_test_cluster"}, + ) + + cluster = Cluster.objects.get(name="1new_test_cluster") + self.assertEqual(response.status_code, HTTP_201_CREATED) + self.assertEqual(cluster.description, "") + + def test_create_adcm_5371_dot_fail(self): + response = self.client.post( + path=reverse(viewname="v2:cluster-list"), + data={"prototype_id": self.cluster_1.prototype.pk, "name": "new_test_cluster."}, + ) + + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + + def test_create_adcm_5371_space_prohibited_end_start_fail(self): + response = self.client.post( + path=reverse(viewname="v2:cluster-list"), + data={"prototype_id": self.cluster_1.prototype.pk, "name": " new_test_cluster "}, + ) + + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + + def test_create_adcm_5371_min_name_2_chars_success(self): + response = self.client.post( + path=reverse(viewname="v2:cluster-list"), + data={"prototype_id": self.cluster_1.prototype.pk, "name": "a"}, + ) + + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + + response = self.client.post( + path=reverse(viewname="v2:cluster-list"), + data={"prototype_id": self.cluster_1.prototype.pk, "name": "aa"}, + ) + + self.assertIsNotNone(Cluster.objects.filter(name="aa").first()) + self.assertEqual(response.status_code, HTTP_201_CREATED) + + def test_create_adcm_5371_max_name_150_chars_success(self): + response = self.client.post( + path=reverse(viewname="v2:cluster-list"), + data={"prototype_id": self.cluster_1.prototype.pk, "name": "a" * 151}, + ) + + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + + response = self.client.post( + path=reverse(viewname="v2:cluster-list"), + data={"prototype_id": self.cluster_1.prototype.pk, "name": "a" * 150}, + ) + + self.assertIsNotNone(Cluster.objects.filter(name="a" * 150).first()) + self.assertEqual(response.status_code, HTTP_201_CREATED) + def test_create_same_name_fail(self): response = self.client.post( path=reverse(viewname="v2:cluster-list"), From fdefce892946f4b745d7b65ff38855df536eaf04 Mon Sep 17 00:00:00 2001 From: Artem Starovoitov Date: Fri, 22 Mar 2024 11:08:37 +0000 Subject: [PATCH 016/208] ADCM-5394: `/adcm` endpoints --- python/api_v2/action/views.py | 37 ++++++++++++++++++++++++++++ python/api_v2/adcm/views.py | 46 ++++++++++++++++++++++++++++++++++- python/api_v2/api_schema.py | 20 +++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 python/api_v2/api_schema.py diff --git a/python/api_v2/action/views.py b/python/api_v2/action/views.py index e2bd91a364..9b83f29108 100644 --- a/python/api_v2/action/views.py +++ b/python/api_v2/action/views.py @@ -21,6 +21,7 @@ from django.conf import settings from django.db.models import Q from django_filters.rest_framework.backends import DjangoFilterBackend +from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from jinja_config import get_jinja_config from rest_framework.decorators import action from rest_framework.exceptions import NotFound @@ -41,6 +42,7 @@ get_action_configuration, insert_service_ids, ) +from api_v2.api_schema import ErrorSerializer from api_v2.config.utils import convert_adcm_meta_to_attr, represent_string_as_json_type from api_v2.task.serializers import TaskListSerializer from api_v2.views import CamelCaseGenericViewSet @@ -200,6 +202,41 @@ def run(self, request: Request, *args, **kwargs) -> Response: # noqa: ARG001, A return Response(status=HTTP_200_OK, data=TaskListSerializer(instance=task).data) +@extend_schema_view( + run=extend_schema( + operation_id="postADCMaction", + summary="POST adcm action", + description="Run ADCM action.", + responses={ + 200: TaskListSerializer, + 400: ErrorSerializer, + 403: ErrorSerializer, + 404: ErrorSerializer, + 409: ErrorSerializer, + }, + ), + list=extend_schema( + operation_id="getADCMactions", + summary="GET adcm actions", + description="Get a list of ADCM actions.", + parameters=[ + OpenApiParameter( + name="ordering", + required=False, + location=OpenApiParameter.QUERY, + description="Field to sort by. To sort in descending order, precede the attribute name with a '-'.", + type=str, + ) + ], + responses={200: ActionListSerializer, 404: ErrorSerializer}, + ), + retrieve=extend_schema( + operation_id="getADCMaction", + summary="GET adcm action", + description="Get information about a specific ADCM action.", + responses={200: ActionRetrieveSerializer, 404: ErrorSerializer}, + ), +) class AdcmActionViewSet(ActionViewSet): def get_parent_object(self): return ADCM.objects.first() diff --git a/python/api_v2/adcm/views.py b/python/api_v2/adcm/views.py index 9bbe754d49..fcfa64c4a3 100644 --- a/python/api_v2/adcm/views.py +++ b/python/api_v2/adcm/views.py @@ -9,9 +9,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - from adcm.permissions import check_config_perm from cm.models import ADCM, ConfigLog, PrototypeConfig +from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from rest_framework.decorators import action from rest_framework.exceptions import NotFound from rest_framework.mixins import RetrieveModelMixin @@ -20,11 +20,21 @@ from rest_framework.status import HTTP_200_OK from api_v2.adcm.serializers import AdcmSerializer +from api_v2.api_schema import ErrorSerializer +from api_v2.config.serializers import ConfigLogListSerializer, ConfigLogSerializer from api_v2.config.utils import get_config_schema from api_v2.config.views import ConfigLogViewSet from api_v2.views import CamelCaseGenericViewSet +@extend_schema_view( + retrieve=extend_schema( + operation_id="getADCMObject", + summary="GET ADCM object", + description="GET ADCM object.", + responses={200: AdcmSerializer}, + ), +) class ADCMViewSet(RetrieveModelMixin, CamelCaseGenericViewSet): queryset = ADCM.objects.prefetch_related("concerns").all() serializer_class = AdcmSerializer @@ -33,6 +43,35 @@ def get_object(self, *args, **kwargs): # noqa: ARG001, ARG002 return super().get_queryset().first() +@extend_schema_view( + retrieve=extend_schema( + operation_id="getADCMConfig", + summary="GET ADCM config", + description="Get ADCM configuration information.", + responses={200: ConfigLogSerializer, 404: ErrorSerializer}, + ), + list=extend_schema( + operation_id="getADCMConfigs", + summary="GET ADCM config vesions", + description="Get information about ADCM config versions.", + parameters=[ + OpenApiParameter( + name="isCurrent", + required=False, + location=OpenApiParameter.QUERY, + description="Sign of the current configuration.", + type=bool, + ) + ], + responses={200: ConfigLogListSerializer, 404: ErrorSerializer}, + ), + create=extend_schema( + operation_id="postADCMConfigs", + summary="POST ADCM configs", + description="Create a new version of the ADCM configuration.", + responses={201: ConfigLogSerializer, 400: ErrorSerializer, 403: ErrorSerializer, 404: ErrorSerializer}, + ), +) class ADCMConfigView(ConfigLogViewSet): def get_queryset(self, *args, **kwargs): # noqa: ARG002 return ( @@ -44,6 +83,11 @@ def get_queryset(self, *args, **kwargs): # noqa: ARG002 def get_parent_object(self) -> ADCM | None: return ADCM.objects.first() + @extend_schema( + summary="Get ADCM config schema", + description="Full representation of ADCM config.", + responses={200: AdcmSerializer}, + ) @action(methods=["get"], detail=True, url_path="config-schema", url_name="config-schema") def config_schema(self, request, *args, **kwargs) -> Response: # noqa: ARG001, ARG002 instance = self.get_parent_object() diff --git a/python/api_v2/api_schema.py b/python/api_v2/api_schema.py new file mode 100644 index 0000000000..3880525062 --- /dev/null +++ b/python/api_v2/api_schema.py @@ -0,0 +1,20 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from adcm.serializers import EmptySerializer +from rest_framework.fields import CharField + + +class ErrorSerializer(EmptySerializer): + code = CharField() + level = CharField() + desc = CharField() From be8b4e00b8bf02a027c25ae6b07558b0fd49c363 Mon Sep 17 00:00:00 2001 From: Daniil S Date: Mon, 25 Mar 2024 18:49:10 +0300 Subject: [PATCH 017/208] ADCM-5439: fix ldap auth bug --- python/rbac/ldap.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/rbac/ldap.py b/python/rbac/ldap.py index df117e775a..592432ddb7 100644 --- a/python/rbac/ldap.py +++ b/python/rbac/ldap.py @@ -108,12 +108,12 @@ def get_groups_by_user_dn( ) if len(users) != 1: err_msg = f"Not one user found by `{search_expr}` search" - return None, err_msg + return [], [], err_msg user_dn_, user_attrs = users[0] if user_dn_.strip().lower() != user_dn.strip().lower(): err_msg = f"Got different user dn: {(user_dn_, user_dn)}. Tune search" - return None, err_msg + return [], [], err_msg group_cns = [] group_dns_lower = [] From 926e3c42f9c584e65656e7434b6ca5ae2923c9fc Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Tue, 26 Mar 2024 07:45:22 +0000 Subject: [PATCH 018/208] ADCM-5408 Fix changing MM via action Fixed: 1. Inherit `params` to scripts (sub actions) from `type: task` action's definition if `params` aren't specified for object 2. Change MM value to opposite to one in action name when MM is `changing` after task is finished 3. Fix `jinja2_native` parameter detection for `ansible.cfg` --- dev/linters/migrations_checker.py | 1 + python/api/tests/test_host.py | 9 ++- .../cm/migrations/0116_autonomous_joblogs.py | 3 +- .../cm/services/job/run/_target_factories.py | 2 +- .../cm/services/job/run/_task_finalizers.py | 10 ++- python/cm/stack.py | 4 + .../config.yaml | 74 +++++++++++++++++++ python/cm/tests/test_action.py | 26 +++++-- python/cm/tests/test_bundle.py | 54 +++++++++++++- .../test_inventory/test_before_upgrade.py | 6 +- .../test_migrations/test_0116_and_0117.py | 3 + 11 files changed, 174 insertions(+), 18 deletions(-) create mode 100644 python/cm/tests/bundles/cluster_various_params_in_actions/config.yaml diff --git a/dev/linters/migrations_checker.py b/dev/linters/migrations_checker.py index 0be2a1b4ce..2eddecdef7 100644 --- a/dev/linters/migrations_checker.py +++ b/dev/linters/migrations_checker.py @@ -24,6 +24,7 @@ "django", "hashlib", "json", + "operator", "os", "typing", "uuid", diff --git a/python/api/tests/test_host.py b/python/api/tests/test_host.py index 429409b4de..329f7acc33 100644 --- a/python/api/tests/test_host.py +++ b/python/api/tests/test_host.py @@ -120,7 +120,8 @@ def test_change_mm_on_with_action_success(self): run_task.target_task.refresh_from_db() self.assertEqual(run_task.target_task.status, "success") self.host.refresh_from_db() - self.assertEqual(self.host.maintenance_mode, MaintenanceMode.ON.value) + # since MM wasn't changed with plugin, rollback will be preformed + self.assertEqual(self.host.maintenance_mode, MaintenanceMode.OFF.value) def test_change_mm_on_from_on_with_action_fail(self): self.host.maintenance_mode = MaintenanceMode.ON @@ -190,7 +191,8 @@ def test_change_mm_off_with_action_success(self): run_task.target_task.refresh_from_db() self.assertEqual(run_task.target_task.status, "success") self.host.refresh_from_db() - self.assertEqual(self.host.maintenance_mode, MaintenanceMode.OFF.value) + # since MM wasn't changed with plugin, rollback will be preformed + self.assertEqual(self.host.maintenance_mode, MaintenanceMode.ON.value) def test_change_mm_off_to_off_with_action_fail(self): self.host.maintenance_mode = MaintenanceMode.OFF @@ -368,4 +370,5 @@ def test_change_maintenance_mode_on_with_action_via_bundle_success(self): run_task.target_task.refresh_from_db() self.assertEqual(run_task.target_task.status, "success") host.refresh_from_db() - self.assertEqual(host.maintenance_mode, MaintenanceMode.ON.value) + # since MM wasn't changed with plugin, rollback will be preformed + self.assertEqual(host.maintenance_mode, MaintenanceMode.OFF.value) diff --git a/python/cm/migrations/0116_autonomous_joblogs.py b/python/cm/migrations/0116_autonomous_joblogs.py index ea5815b191..e3ffac823b 100644 --- a/python/cm/migrations/0116_autonomous_joblogs.py +++ b/python/cm/migrations/0116_autonomous_joblogs.py @@ -11,7 +11,7 @@ # limitations under the License. # Generated by Django 3.2.23 on 2024-02-22 10:06 -from operator import attrgetter, itemgetter +from operator import itemgetter from django.db import migrations, models @@ -36,6 +36,7 @@ def extract_sub_action_data_to_joblogs(apps, schema_editor): "state_on_fail", "multi_state_on_fail_set", "multi_state_on_fail_unset", + "params", ) for sub_action_update_dict in SubAction.objects.values("id", *fields_to_move).filter( id__in=set(map(itemgetter("sub_action_id"), requested_for_update)) diff --git a/python/cm/services/job/run/_target_factories.py b/python/cm/services/job/run/_target_factories.py index 82790754d2..5b2e8835f8 100644 --- a/python/cm/services/job/run/_target_factories.py +++ b/python/cm/services/job/run/_target_factories.py @@ -213,7 +213,7 @@ def prepare_ansible_environment(task: Task, job: Job, configuration: ExternalSet forks = adcm_config(get_adcm_config_id()).config["ansible_settings"]["forks"] config_parser["defaults"]["forks"] = str(forks) - jinja_2_native = getattr(job.params, "jinja_2_native", None) + jinja_2_native = getattr(job.params, "jinja2_native", None) if jinja_2_native is not None: config_parser["defaults"]["jinja2_native"] = str(jinja_2_native) diff --git a/python/cm/services/job/run/_task_finalizers.py b/python/cm/services/job/run/_task_finalizers.py index 3565f8009f..db68f792d0 100644 --- a/python/cm/services/job/run/_task_finalizers.py +++ b/python/cm/services/job/run/_task_finalizers.py @@ -21,6 +21,7 @@ from cm.converters import core_type_to_model from cm.issue import unlock_affected_objects, update_hierarchy_issues from cm.models import ClusterObject, Host, JobLog, MaintenanceMode, ServiceComponent, TaskLog, get_object_cluster +from cm.status_api import send_object_update_event # todo "unwrap" these functions to use repo without directly calling ORM, # try to rework functions like `save_hc` also, because they rely on API v1 input @@ -75,18 +76,23 @@ def update_issues(object_: CoreObjectDescriptor): def update_object_maintenance_mode(action_name: str, object_: CoreObjectDescriptor): + """ + If maintenance mode wasn't changed during action execution, set "opposite" (to action's name) MM + """ obj = core_type_to_model(core_type=object_.type).objects.get(id=object_.id) if ( action_name in {settings.ADCM_TURN_ON_MM_ACTION_NAME, settings.ADCM_HOST_TURN_ON_MM_ACTION_NAME} and obj.maintenance_mode == MaintenanceMode.CHANGING ): - obj.maintenance_mode = MaintenanceMode.ON + obj.maintenance_mode = MaintenanceMode.OFF obj.save() + send_object_update_event(object_=obj, changes={"maintenanceMode": obj.maintenance_mode}) if ( action_name in {settings.ADCM_TURN_OFF_MM_ACTION_NAME, settings.ADCM_HOST_TURN_OFF_MM_ACTION_NAME} and obj.maintenance_mode == MaintenanceMode.CHANGING ): - obj.maintenance_mode = MaintenanceMode.OFF + obj.maintenance_mode = MaintenanceMode.ON obj.save() + send_object_update_event(object_=obj, changes={"maintenanceMode": obj.maintenance_mode}) diff --git a/python/cm/stack.py b/python/cm/stack.py index 87f12f7c37..9e9a16ce8a 100644 --- a/python/cm/stack.py +++ b/python/cm/stack.py @@ -610,6 +610,7 @@ def save_sub_actions(conf, action): sub_action.save() return + action_wide_params = conf.get("params", {}) for sub in conf["scripts"]: sub_action = StageSubAction( action=action, @@ -624,6 +625,9 @@ def save_sub_actions(conf, action): sub_action.display_name = sub["display_name"] dict_to_obj(sub, "params", sub_action) + if not sub_action.params and action_wide_params: + sub_action.params = action_wide_params + on_fail = sub.get(ON_FAIL, "") if isinstance(on_fail, str): sub_action.state_on_fail = on_fail diff --git a/python/cm/tests/bundles/cluster_various_params_in_actions/config.yaml b/python/cm/tests/bundles/cluster_various_params_in_actions/config.yaml new file mode 100644 index 0000000000..cfb132d14e --- /dev/null +++ b/python/cm/tests/bundles/cluster_various_params_in_actions/config.yaml @@ -0,0 +1,74 @@ +- type: cluster + version: 2.3 + name: cluster_with_various_params_in_action + + actions: + job_no_params: &job + type: job + script: ./somewhere.yaml + script_type: ansible + + job_params: + <<: *job + params: + ansible_tags: hello, there + custom: [4, 3] + + task_no_params: + type: task + scripts: + - &script + name: first + script: ./somewhere.yaml + script_type: ansible + - <<: *script + name: second + + task_params_in_action: + type: task + scripts: + - <<: *script + - <<: *script + name: second + params: + jinja2_native: yes + custom: {"key": "value"} + + task_params_in_action_and_scripts: + type: task + scripts: + - <<: *script + params: + ansible_tags: one, two + jinja2_native: "hello" + - <<: *script + name: second + params: + jinja2_native: yes + custom: { "key": "value" } + + task_params_in_action_and_all_scripts: + type: task + scripts: + - <<: *script + params: + ansible_tags: one, two + jinja2_native: "hello" + - <<: *script + name: second + params: + perfect: "thing" + params: + ansible_tags: some + custom: { "key": "value" } + + task_params_in_scripts: + type: task + scripts: + - <<: *script + params: + ansible_tags: one + - <<: *script + name: second + params: + perfect: "thing" diff --git a/python/cm/tests/test_action.py b/python/cm/tests/test_action.py index 3239d829ed..cd7de2a70c 100644 --- a/python/cm/tests/test_action.py +++ b/python/cm/tests/test_action.py @@ -15,14 +15,16 @@ import json from adcm.tests.base import BaseTestCase +from core.job.runners import ADCMSettings, AnsibleSettings, ExternalSettings, IntegrationsSettings +from django.conf import settings from django.urls import reverse from rest_framework.response import Response from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_409_CONFLICT from cm.api import add_hc, add_service_to_cluster -from cm.job import prepare_job -from cm.models import Action, MaintenanceMode, Prototype, ServiceComponent, TaskLog -from cm.services.job.utils import JobScope +from cm.models import Action, MaintenanceMode, Prototype, ServiceComponent +from cm.services.job.run._target_factories import prepare_ansible_environment +from cm.services.job.run.repo import JobRepoImpl from cm.tests.utils import ( gen_action, gen_bundle, @@ -436,6 +438,12 @@ def setUp(self) -> None: prototype=self.cluster.prototype, name="action_customFields_absent" ) + self.configuration = ExternalSettings( + adcm=ADCMSettings(code_root_dir=settings.CODE_DIR, run_dir=settings.RUN_DIR, log_dir=settings.LOG_DIR), + ansible=AnsibleSettings(ansible_secret_script=settings.CODE_DIR / "ansible_secret.py"), + integrations=IntegrationsSettings(status_server_token=settings.STATUS_SECRET_KEY), + ) + def _generate_and_read_target_files(self, action_pk: int) -> tuple[ConfigParser, dict]: response = self.client.post( path=reverse( @@ -448,13 +456,15 @@ def _generate_and_read_target_files(self, action_pk: int) -> tuple[ConfigParser, ) self.assertEqual(response.status_code, HTTP_200_OK) - task_id = response.json()["id"] - job = TaskLog.objects.get(pk=task_id).joblog_set.get() + task = JobRepoImpl.get_task(id=response.json()["id"]) + job, *_ = JobRepoImpl.get_task_jobs(task_id=task.id) - prepare_job(job_scope=JobScope(job_id=job.pk, object=self.cluster), delta={}) + job_dir: Path = self.directories["RUN_DIR"] / str(job.id) + job_dir.mkdir(parents=True) + prepare_ansible_environment(task=task, job=job, configuration=self.configuration) - ansible_cfg_file: Path = self.directories["RUN_DIR"] / str(task_id) / "ansible.cfg" - config_json_file: Path = self.directories["RUN_DIR"] / str(task_id) / "config.json" + ansible_cfg_file: Path = job_dir / "ansible.cfg" + config_json_file: Path = job_dir / "config.json" if not ansible_cfg_file.is_file() or not config_json_file.is_file(): raise ValueError("Not all files exist") diff --git a/python/cm/tests/test_bundle.py b/python/cm/tests/test_bundle.py index 25d7173438..e372a5354a 100644 --- a/python/cm/tests/test_bundle.py +++ b/python/cm/tests/test_bundle.py @@ -13,7 +13,7 @@ from pathlib import Path import json -from adcm.tests.base import APPLICATION_JSON, BaseTestCase +from adcm.tests.base import APPLICATION_JSON, BaseTestCase, BundleLogicMixin from django.conf import settings from django.db import IntegrityError from django.urls import reverse @@ -29,7 +29,7 @@ from cm.api import delete_host_provider from cm.bundle import delete_bundle from cm.errors import AdcmEx -from cm.models import ConfigLog +from cm.models import Bundle, ConfigLog, SubAction from cm.tests.test_upgrade import ( cook_cluster, cook_cluster_bundle, @@ -266,3 +266,53 @@ def test_upload_hc_acl_component_action_without_service_fail(self): response.data["desc"], '"service" filed is required in hc_acl of action "sleep" of component "component" 1.0', ) + + +class TestBundleParsing(BaseTestCase, BundleLogicMixin): + def get_ordered_subs(self, bundle: Bundle, action_name: str): + return SubAction.objects.filter(action__name=action_name, action__prototype__bundle=bundle).order_by("id") + + def test_params_in_action_processing_during_upload(self) -> None: + bundle = self.add_bundle( + source_dir=self.base_dir / "python" / "cm" / "tests" / "bundles" / "cluster_various_params_in_actions" + ) + fields = ("name", "params") + + subs = self.get_ordered_subs(action_name="job_no_params", bundle=bundle) + self.assertEqual(subs.count(), 1) + self.assertEqual(list(subs.values_list(*fields)), [("job_no_params", {})]) + + subs = self.get_ordered_subs(action_name="job_params", bundle=bundle) + self.assertEqual(subs.count(), 1) + self.assertEqual( + list(subs.values_list(*fields)), [("job_params", {"ansible_tags": "hello, there", "custom": [4, 3]})] + ) + + subs = self.get_ordered_subs(action_name="task_no_params", bundle=bundle) + self.assertEqual(subs.count(), 2) + self.assertEqual(list(subs.values_list(*fields)), [("first", {}), ("second", {})]) + + subs = self.get_ordered_subs(action_name="task_params_in_action", bundle=bundle) + self.assertEqual(subs.count(), 2) + action_params = {"jinja2_native": True, "custom": {"key": "value"}} + self.assertEqual(list(subs.values_list(*fields)), [("first", action_params), ("second", action_params)]) + + subs = self.get_ordered_subs(action_name="task_params_in_action_and_scripts", bundle=bundle) + self.assertEqual(subs.count(), 2) + self.assertEqual( + list(subs.values_list(*fields)), + [("first", {"ansible_tags": "one, two", "jinja2_native": "hello"}), ("second", action_params)], + ) + + subs = self.get_ordered_subs(action_name="task_params_in_action_and_all_scripts", bundle=bundle) + self.assertEqual(subs.count(), 2) + self.assertEqual( + list(subs.values_list(*fields)), + [("first", {"ansible_tags": "one, two", "jinja2_native": "hello"}), ("second", {"perfect": "thing"})], + ) + + subs = self.get_ordered_subs(action_name="task_params_in_scripts", bundle=bundle) + self.assertEqual(subs.count(), 2) + self.assertEqual( + list(subs.values_list(*fields)), [("first", {"ansible_tags": "one"}), ("second", {"perfect": "thing"})] + ) diff --git a/python/cm/tests/test_inventory/test_before_upgrade.py b/python/cm/tests/test_inventory/test_before_upgrade.py index 663989ee59..18d9db4893 100644 --- a/python/cm/tests/test_inventory/test_before_upgrade.py +++ b/python/cm/tests/test_inventory/test_before_upgrade.py @@ -12,6 +12,7 @@ from pathlib import Path from api_v2.service.utils import bulk_add_services_to_cluster +from core.types import ADCMCoreType, CoreObjectDescriptor from django.conf import settings from cm.models import Action, ClusterObject, ObjectType, Prototype, ServiceComponent, Upgrade @@ -462,7 +463,10 @@ def test_bug_adcm_5367(self) -> None: problem_component.refresh_from_db() action = Action.objects.get(name="action_on_component_1", prototype=problem_component.prototype) - inventory = get_inventory_data(obj=problem_component, action=action) + inventory = get_inventory_data( + target=CoreObjectDescriptor(id=problem_component.id, type=ADCMCoreType.COMPONENT), + is_host_action=action.host_action, + ) services = inventory["all"]["vars"]["services"] component_prefix = f"{settings.FILE_DIR}/component.{problem_component.id}" diff --git a/python/cm/tests/test_migrations/test_0116_and_0117.py b/python/cm/tests/test_migrations/test_0116_and_0117.py index 4ee6efe7ee..54a1eccdec 100644 --- a/python/cm/tests/test_migrations/test_0116_and_0117.py +++ b/python/cm/tests/test_migrations/test_0116_and_0117.py @@ -54,6 +54,7 @@ def prepare(self): state_on_fail="onfail", multi_state_on_fail_set=["one", "two"], multi_state_on_fail_unset=["old"], + params={"ansible_tags": "hello"}, ) self.sub_2 = SubAction.objects.create( action=action, @@ -65,6 +66,7 @@ def prepare(self): state_on_fail="onfailtwo", multi_state_on_fail_set=[], multi_state_on_fail_unset=["hello"], + params={"jinja2_native": True, "custom": [1, 4], "boo": {"heh": 23}}, ) self.sub_3 = SubAction.objects.create( action=action, name="sub_3", allow_to_terminate=True, script="script1", script_type=ScriptType.INTERNAL @@ -94,6 +96,7 @@ def test_migration_0116_0117_move_data(self): "state_on_fail", "multi_state_on_fail_set", "multi_state_on_fail_unset", + "params", ): self.assertEqual(getattr(job, field), getattr(sub_, field)) From f1846e52bf0877e4a0bd1054949b9e5a0eafdf60 Mon Sep 17 00:00:00 2001 From: Dmitriy Bardin Date: Tue, 26 Mar 2024 08:56:42 +0000 Subject: [PATCH 019/208] ADCM-5411 - [UI] Rework the logic of grant/withdraw ADCM Administrator's rights https://tracker.yandex.ru/ADCM-5411 --- .../RbacUserCreateDialog.tsx | 14 +++++++++- .../useRbacUserCreateDialog.ts | 20 ++++++++----- .../RbacUserUpdateDialog.tsx | 2 ++ .../useRbacUserUpdateDialog.ts | 18 ++++++++---- .../RbacUserForm/RbacUserForm.module.scss | 5 ++++ .../RbacUserForm/RbacUserForm.tsx | 28 ++++++++++++++----- adcm-web/app/src/models/adcm/users.ts | 2 +- 7 files changed, 67 insertions(+), 22 deletions(-) diff --git a/adcm-web/app/src/components/pages/AccessManagerPage/AccessManagerUsersPage/Dialogs/RbacUserCreateDialog/RbacUserCreateDialog.tsx b/adcm-web/app/src/components/pages/AccessManagerPage/AccessManagerUsersPage/Dialogs/RbacUserCreateDialog/RbacUserCreateDialog.tsx index 5901554e8e..3b67db1eb1 100644 --- a/adcm-web/app/src/components/pages/AccessManagerPage/AccessManagerUsersPage/Dialogs/RbacUserCreateDialog/RbacUserCreateDialog.tsx +++ b/adcm-web/app/src/components/pages/AccessManagerPage/AccessManagerUsersPage/Dialogs/RbacUserCreateDialog/RbacUserCreateDialog.tsx @@ -4,7 +4,18 @@ import { useRbacUserCreateDialog } from './useRbacUserCreateDialog'; import RbacUserForm from '@pages/AccessManagerPage/AccessManagerUsersPage/RbacUserForm/RbacUserForm'; const RbacUserCreateDialog: React.FC = () => { - const { isOpen, isValid, onClose, formData, onChangeFormData, onSubmit, groups, errors } = useRbacUserCreateDialog(); + const { + // + isOpen, + isValid, + onClose, + formData, + onChangeFormData, + onSubmit, + groups, + errors, + isCurrentUserSuperUser, + } = useRbacUserCreateDialog(); return ( {
{ const isCreating = useStore((s) => s.adcm.usersActions.createDialog.isCreating); const groups = useStore((s) => s.adcm.usersActions.relatedData.groups); const authSettings = useStore((s) => s.auth.profile.authSettings); + const isCurrentUserSuperUser = useStore((s) => s.auth.profile.isSuperUser); const { formData, handleChangeFormData, setFormData, errors, setErrors, isValid } = useForm(initialFormData); @@ -62,15 +63,19 @@ export const useRbacUserCreateDialog = () => { }; const handleCreate = () => { + const userData = { + username: formData.username, + firstName: formData.firstName, + lastName: formData.lastName, + email: formData.email, + groups: formData.groups, + password: formData.password, + }; + dispatch( createUser({ - username: formData.username, - firstName: formData.firstName, - lastName: formData.lastName, - email: formData.email, - groups: formData.groups, - password: formData.password, - isSuperUser: formData.isSuperUser, + ...userData, + ...(isCurrentUserSuperUser && { isSuperUser: formData.isSuperUser }), }), ); }; @@ -84,5 +89,6 @@ export const useRbacUserCreateDialog = () => { onClose: handleClose, onSubmit: handleCreate, errors, + isCurrentUserSuperUser, }; }; diff --git a/adcm-web/app/src/components/pages/AccessManagerPage/AccessManagerUsersPage/Dialogs/RbacUserUpdateDialog/RbacUserUpdateDialog.tsx b/adcm-web/app/src/components/pages/AccessManagerPage/AccessManagerUsersPage/Dialogs/RbacUserUpdateDialog/RbacUserUpdateDialog.tsx index 9bc9bd241b..cd76a3c932 100644 --- a/adcm-web/app/src/components/pages/AccessManagerPage/AccessManagerUsersPage/Dialogs/RbacUserUpdateDialog/RbacUserUpdateDialog.tsx +++ b/adcm-web/app/src/components/pages/AccessManagerPage/AccessManagerUsersPage/Dialogs/RbacUserUpdateDialog/RbacUserUpdateDialog.tsx @@ -14,6 +14,7 @@ const RbacUserUpdateDialog: React.FC = () => { groups, errors, isPersonalDataEditForbidden, + isCurrentUserSuperUser, } = useRbacUserUpdateDialog(); return ( @@ -28,6 +29,7 @@ const RbacUserUpdateDialog: React.FC = () => { > { const user = useStore((s) => s.adcm.usersActions.updateDialog.user); const isUpdating = useStore((s) => s.adcm.usersActions.updateDialog.isUpdating); const groups = useStore((s) => s.adcm.usersActions.relatedData.groups); + const isCurrentUserSuperUser = useStore((s) => s.auth.profile.isSuperUser); const authSettings = useStore((s) => s.auth.profile.authSettings); const isOpen = !!user; const isPersonalDataEditForbidden = user?.type === 'ldap'; @@ -72,16 +73,20 @@ export const useRbacUserUpdateDialog = () => { const handleCreate = () => { if (user) { + const userData = { + firstName: formData.firstName, + lastName: formData.lastName, + email: formData.email, + groups: formData.groups, + password: formData.password === '' ? undefined : formData.password, + }; + dispatch( updateUser({ id: user.id, userData: { - firstName: formData.firstName, - lastName: formData.lastName, - email: formData.email, - groups: formData.groups, - password: formData.password === '' ? undefined : formData.password, - isSuperUser: formData.isSuperUser, + ...userData, + ...(isCurrentUserSuperUser && { isSuperUser: formData.isSuperUser }), }, }), ); @@ -98,5 +103,6 @@ export const useRbacUserUpdateDialog = () => { onSubmit: handleCreate, errors, isPersonalDataEditForbidden, + isCurrentUserSuperUser, }; }; diff --git a/adcm-web/app/src/components/pages/AccessManagerPage/AccessManagerUsersPage/RbacUserForm/RbacUserForm.module.scss b/adcm-web/app/src/components/pages/AccessManagerPage/AccessManagerUsersPage/RbacUserForm/RbacUserForm.module.scss index 935cb775f0..dbabb45562 100644 --- a/adcm-web/app/src/components/pages/AccessManagerPage/AccessManagerUsersPage/RbacUserForm/RbacUserForm.module.scss +++ b/adcm-web/app/src/components/pages/AccessManagerPage/AccessManagerUsersPage/RbacUserForm/RbacUserForm.module.scss @@ -19,3 +19,8 @@ &__email { grid-area: email; } &__groups { grid-area: groups; } } + +.markerIcon { + position: absolute; + margin: 3px 10px 0; +} diff --git a/adcm-web/app/src/components/pages/AccessManagerPage/AccessManagerUsersPage/RbacUserForm/RbacUserForm.tsx b/adcm-web/app/src/components/pages/AccessManagerPage/AccessManagerUsersPage/RbacUserForm/RbacUserForm.tsx index c0f8cd5e84..e857d6e0d6 100644 --- a/adcm-web/app/src/components/pages/AccessManagerPage/AccessManagerUsersPage/RbacUserForm/RbacUserForm.tsx +++ b/adcm-web/app/src/components/pages/AccessManagerPage/AccessManagerUsersPage/RbacUserForm/RbacUserForm.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react'; import s from './RbacUserForm.module.scss'; -import { Checkbox, FormField, FormFieldsContainer, Input } from '@uikit'; +import { Checkbox, FormField, FormFieldsContainer, Input, MarkerIcon, Tooltip } from '@uikit'; import InputPassword from '@uikit/InputPassword/InputPassword'; import MultiSelect from '@uikit/Select/MultiSelect/MultiSelect'; import { RbacUserFormData } from './RbacUserForm.types'; @@ -14,6 +14,7 @@ interface RbacUserFormProps { errors: Partial>; isPersonalDataEditForbidden?: boolean; isCreate?: boolean; + isCurrentUserSuperUser: boolean; } const RbacUserForm: React.FC = ({ @@ -23,6 +24,7 @@ const RbacUserForm: React.FC = ({ errors, isCreate = false, isPersonalDataEditForbidden = false, + isCurrentUserSuperUser = false, }) => { const groupsOptions = useMemo(() => { return groups.map(({ displayName, id }) => ({ value: id, label: displayName })); @@ -118,12 +120,24 @@ const RbacUserForm: React.FC = ({ /> - + <> + + {!isCurrentUserSuperUser && ( + <> + + + + + )} + & { From 86abd8441ea37cda7f2070b03a0271e48ad5df13 Mon Sep 17 00:00:00 2001 From: Daniil S Date: Mon, 25 Mar 2024 17:47:17 +0300 Subject: [PATCH 020/208] ADCM-5344: remove adcm/utils.py file --- python/adcm/auth_backend.py | 3 +- python/adcm/settings.py | 3 +- python/adcm/settings_utils.py | 41 +++ python/adcm/utils.py | 403 ------------------------- python/api/cluster/serializers.py | 3 +- python/api/component/serializers.py | 3 +- python/api/component/views.py | 2 +- python/api/concern/views.py | 2 +- python/api/host/serializers.py | 3 +- python/api/host/views.py | 2 +- python/api/job/views.py | 2 +- python/api/provider/serializers.py | 3 +- python/api/rbac/policy/serializers.py | 2 +- python/api/service/serializers.py | 3 +- python/api/service/views.py | 3 +- python/api/stack/serializers.py | 2 +- python/api/tests/test_component.py | 8 +- python/api/tests/test_host.py | 10 +- python/api/tests/test_service.py | 20 +- python/api/utils.py | 98 ++++++ python/api/views.py | 2 +- python/api_v2/component/views.py | 2 +- python/api_v2/host/utils.py | 2 +- python/api_v2/log_storage/utils.py | 2 +- python/api_v2/service/views.py | 3 +- python/audit/cases/config.py | 2 +- python/cm/services/authorization.py | 55 ++++ python/cm/services/maintenance_mode.py | 166 ++++++++++ python/cm/services/service.py | 77 +++++ python/cm/utils.py | 47 ++- 30 files changed, 498 insertions(+), 476 deletions(-) create mode 100644 python/adcm/settings_utils.py delete mode 100644 python/adcm/utils.py create mode 100644 python/cm/services/authorization.py create mode 100644 python/cm/services/maintenance_mode.py create mode 100644 python/cm/services/service.py diff --git a/python/adcm/auth_backend.py b/python/adcm/auth_backend.py index 13d77c1b39..8c2a04dbf4 100644 --- a/python/adcm/auth_backend.py +++ b/python/adcm/auth_backend.py @@ -10,11 +10,10 @@ # See the License for the specific language governing permissions and # limitations under the License. +from cm.services.authorization import get_google_oauth, get_yandex_oauth from social_core.backends.google import GoogleOAuth2 from social_core.backends.yandex import YandexOAuth2 -from adcm.utils import get_google_oauth, get_yandex_oauth - class CustomYandexOAuth2(YandexOAuth2): def auth_html(self): diff --git a/python/adcm/settings.py b/python/adcm/settings.py index 682183a9c5..ab608deab0 100644 --- a/python/adcm/settings.py +++ b/python/adcm/settings.py @@ -17,9 +17,10 @@ import string import logging -from cm.utils import dict_json_get_or_create, get_adcm_token from django.core.management.utils import get_random_secret_key +from adcm.settings_utils import dict_json_get_or_create, get_adcm_token + ENCODING_UTF_8 = "utf-8" API_URL = "http://localhost:8020/api/v1/" diff --git a/python/adcm/settings_utils.py b/python/adcm/settings_utils.py new file mode 100644 index 0000000000..2480953f79 --- /dev/null +++ b/python/adcm/settings_utils.py @@ -0,0 +1,41 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path +from secrets import token_hex +from typing import Any +import json + + +def dict_json_get_or_create(path: str | Path, field: str, value: Any = None) -> Any: + with open(path, encoding="utf-8") as f: + data = json.load(f) + + if field not in data: + data[field] = value + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f) + + return data[field] + + +def get_adcm_token(token_path: Path) -> str: + if not token_path.is_file(): + token_path.parent.mkdir(parents=True, exist_ok=True) + with token_path.open(mode="w", encoding="utf-8") as f: + f.write(token_hex(20)) + + with token_path.open(encoding="utf-8") as f: + adcm_token = f.read().strip() + adcm_token.encode(encoding="idna").decode(encoding="utf-8") + + return adcm_token diff --git a/python/adcm/utils.py b/python/adcm/utils.py deleted file mode 100644 index bb9ce48fdc..0000000000 --- a/python/adcm/utils.py +++ /dev/null @@ -1,403 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from typing import Any, Iterable - -from cm.adcm_config.ansible import ansible_decrypt -from cm.api import cancel_locking_tasks, delete_service -from cm.errors import AdcmEx -from cm.flag import update_flags -from cm.issue import update_hierarchy_issues, update_issue_after_deleting -from cm.job import ActionRunPayload, run_action -from cm.models import ( - ADCM, - Action, - ADCMEntity, - ClusterBind, - ClusterObject, - ConcernType, - ConfigLog, - Host, - HostComponent, - JobStatus, - MaintenanceMode, - Prototype, - PrototypeConfig, - ServiceComponent, - TaskLog, -) -from cm.services.status.notify import reset_objects_in_mm -from cm.status_api import send_object_update_event -from django.conf import settings -from rest_framework.response import Response -from rest_framework.serializers import Serializer -from rest_framework.status import ( - HTTP_204_NO_CONTENT, - HTTP_400_BAD_REQUEST, - HTTP_409_CONFLICT, -) - -OBJECT_TYPES_DICT = { - "adcm": "adcm", - "cluster": "cluster", - "service": "clusterobject", - "cluster object": "service", - "component": "servicecomponent", - "service component": "servicecomponent", - "provider": "hostprovider", - "host provider": "hostprovider", - "host": "host", -} - - -def _change_mm_via_action( - prototype: Prototype, - action_name: str, - obj: Host | ClusterObject | ServiceComponent, - serializer: Serializer, -) -> Serializer: - action = Action.objects.filter(prototype=prototype, name=action_name).first() - if action: - run_action( - action=action, - obj=obj, - payload=ActionRunPayload(conf={}, attr={}, hostcomponent=[], verbose=False), - hosts=[], - ) - serializer.validated_data["maintenance_mode"] = MaintenanceMode.CHANGING - - return serializer - - -def _update_mm_hierarchy_issues(obj: Host | ClusterObject | ServiceComponent) -> None: - if isinstance(obj, Host): - update_hierarchy_issues(obj.provider) - - providers = {host_component.host.provider for host_component in HostComponent.objects.filter(cluster=obj.cluster)} - for provider in providers: - update_hierarchy_issues(provider) - - update_hierarchy_issues(obj.cluster) - update_issue_after_deleting() - update_flags() - reset_objects_in_mm() - - -def process_requires( - proto: Prototype, comp_dict: dict, checked_object: list | None = None, adding_service: bool = False -) -> dict: - if checked_object is None: - checked_object = [] - checked_object.append(proto) - - for require in proto.requires: - req_service = Prototype.obj.get(type="service", name=require["service"], bundle=proto.bundle) - - if req_service.name not in comp_dict: - comp_dict[req_service.name] = {"components": {}, "service": req_service} - - req_comp = None - if require.get("component"): - req_comp = Prototype.obj.get( - type="component", - name=require["component"], - parent=req_service, - ) - comp_dict[req_service.name]["components"][req_comp.name] = req_comp - - if req_service.requires and req_service not in checked_object: - process_requires( - proto=req_service, comp_dict=comp_dict, checked_object=checked_object, adding_service=adding_service - ) - - if req_comp and req_comp.requires and req_comp not in checked_object and not adding_service: - process_requires(proto=req_comp, comp_dict=comp_dict, checked_object=checked_object) - - return comp_dict - - -def get_obj_type(obj_type: str) -> str: - object_names_to_object_types = { - "adcm": "adcm", - "cluster": "cluster", - "cluster object": "service", - "service component": "component", - "host provider": "provider", - "host": "host", - } - return object_names_to_object_types[obj_type] - - -def str_remove_non_alnum(value: str) -> str: - result = "".join(ch.lower().replace(" ", "-") for ch in value if (ch.isalnum() or ch == " ")) - while result.find("--") != -1: - result = result.replace("--", "-") - return result - - -def get_oauth(oauth_key: str) -> tuple[str | None, str | None]: - adcm = ADCM.objects.filter().first() - if not adcm: - return None, None - - config_log = ConfigLog.objects.get(obj_ref=adcm.config, id=adcm.config.current) - if not config_log: - return None, None - - if not config_log.config.get(oauth_key): - return None, None - - if "client_id" not in config_log.config[oauth_key] or "secret" not in config_log.config[oauth_key]: - return None, None - - secret = config_log.config[oauth_key]["secret"] - if not secret: - return None, None - - return ( - config_log.config[oauth_key]["client_id"], - ansible_decrypt(secret), - ) - - -def get_yandex_oauth() -> tuple[str, str]: - return get_oauth(oauth_key="yandex_oauth") - - -def get_google_oauth() -> tuple[str, str]: - return get_oauth(oauth_key="google_oauth") - - -def has_yandex_oauth() -> bool: - return all(get_yandex_oauth()) - - -def has_google_oauth() -> bool: - return all(get_google_oauth()) - - -def get_requires( - prototype: Prototype, - adding_service: bool = False, -) -> list[dict[str, list[dict[str, Any]] | Any]] | None: - if not prototype.requires: - return None - - proto_dict = {} - proto_dict = process_requires(proto=prototype, comp_dict=proto_dict, adding_service=adding_service) - - out = [] - - for service_name, params in proto_dict.items(): - comp_out = [] - service = params["service"] - for comp_name in params["components"]: - comp = params["components"][comp_name] - comp_out.append( - { - "prototype_id": comp.id, - "name": comp_name, - "display_name": comp.display_name, - }, - ) - - out.append( - { - "prototype_id": service.id, - "name": service_name, - "display_name": service.display_name, - "components": comp_out, - }, - ) - - return out - - -def get_maintenance_mode_response( - obj: Host | ClusterObject | ServiceComponent, - serializer: Serializer, -) -> Response: - turn_on_action_name = settings.ADCM_TURN_ON_MM_ACTION_NAME - turn_off_action_name = settings.ADCM_TURN_OFF_MM_ACTION_NAME - prototype = obj.prototype - if isinstance(obj, Host): - obj_name = "host" - turn_on_action_name = settings.ADCM_HOST_TURN_ON_MM_ACTION_NAME - turn_off_action_name = settings.ADCM_HOST_TURN_OFF_MM_ACTION_NAME - - if not obj.cluster: - return Response( - data={ - "code": "MAINTENANCE_MODE_NOT_AVAILABLE", - "level": "error", - "desc": "Maintenance mode is not available", - }, - status=HTTP_409_CONFLICT, - ) - - prototype = obj.cluster.prototype - elif isinstance(obj, ClusterObject): - obj_name = "service" - elif isinstance(obj, ServiceComponent): - obj_name = "component" - else: - obj_name = "obj" - - service_has_hc = None - if obj_name == "service": - service_has_hc = HostComponent.objects.filter(service=obj).exists() - - component_has_hc = None - if obj_name == "component": - component_has_hc = HostComponent.objects.filter(component=obj).exists() - - if obj.maintenance_mode_attr == MaintenanceMode.CHANGING: - return Response( - data={ - "code": "MAINTENANCE_MODE", - "level": "error", - "desc": "Maintenance mode is changing now", - }, - status=HTTP_409_CONFLICT, - ) - - if obj.maintenance_mode_attr == MaintenanceMode.OFF: - if serializer.validated_data["maintenance_mode"] == MaintenanceMode.OFF: - return Response( - data={ - "code": "MAINTENANCE_MODE", - "level": "error", - "desc": "Maintenance mode already off", - }, - status=HTTP_409_CONFLICT, - ) - - if obj_name == "host" or service_has_hc or component_has_hc: - serializer = _change_mm_via_action( - prototype=prototype, - action_name=turn_on_action_name, - obj=obj, - serializer=serializer, - ) - else: - obj.maintenance_mode = MaintenanceMode.ON - serializer.validated_data["maintenance_mode"] = MaintenanceMode.ON - - serializer.save() - _update_mm_hierarchy_issues(obj=obj) - send_object_update_event(object_=obj, changes={"maintenanceMode": obj.maintenance_mode}) - - return Response() - - if obj.maintenance_mode_attr == MaintenanceMode.ON: - if serializer.validated_data["maintenance_mode"] == MaintenanceMode.ON: - return Response( - data={ - "code": "MAINTENANCE_MODE", - "level": "error", - "desc": "Maintenance mode already on", - }, - status=HTTP_409_CONFLICT, - ) - - if obj_name == "host" or service_has_hc or component_has_hc: - serializer = _change_mm_via_action( - prototype=prototype, - action_name=turn_off_action_name, - obj=obj, - serializer=serializer, - ) - else: - obj.maintenance_mode = MaintenanceMode.OFF - serializer.validated_data["maintenance_mode"] = MaintenanceMode.OFF - - serializer.save() - _update_mm_hierarchy_issues(obj=obj) - send_object_update_event(object_=obj, changes={"maintenanceMode": obj.maintenance_mode}) - - return Response() - - return Response( - data={"error": f'Unknown {obj_name} maintenance mode "{obj.maintenance_mode}"'}, - status=HTTP_400_BAD_REQUEST, - ) - - -def delete_service_from_api(service: ClusterObject) -> Response: - delete_action = Action.objects.filter( - prototype=service.prototype, - name=settings.ADCM_DELETE_SERVICE_ACTION_NAME, - ).first() - host_components_exists = HostComponent.objects.filter(cluster=service.cluster, service=service).exists() - - if not delete_action: - if service.state != "created": - raise AdcmEx(code="SERVICE_DELETE_ERROR") - - if host_components_exists: - raise AdcmEx(code="SERVICE_CONFLICT", msg=f'Service "{service.display_name}" has component(s) on host(s)') - - cluster = service.cluster - - if cluster.state == "upgrading" and service.prototype.name in cluster.before_upgrade.get("services", ()): - raise AdcmEx(code="SERVICE_CONFLICT", msg="Can't remove service when upgrading cluster") - - if ClusterBind.objects.filter(source_service=service).exists(): - raise AdcmEx(code="SERVICE_CONFLICT", msg=f'Service "{service.display_name}" has exports(s)') - - if service.prototype.required: - raise AdcmEx(code="SERVICE_CONFLICT", msg=f'Service "{service.display_name}" is required') - - if TaskLog.objects.filter(action=delete_action, status__in={JobStatus.CREATED, JobStatus.RUNNING}).exists(): - raise AdcmEx(code="SERVICE_DELETE_ERROR", msg="Service is deleting now") - - for component in ServiceComponent.objects.filter(cluster=service.cluster).exclude(service=service): - if component.requires_service_name(service_name=service.name): - raise AdcmEx( - code="SERVICE_CONFLICT", - msg=f'Component "{component.name}" of service "{component.service.display_name}' - f" requires this service or its component", - ) - - for another_service in ClusterObject.objects.filter(cluster=service.cluster): - if another_service.requires_service_name(service_name=service.name): - raise AdcmEx( - code="SERVICE_CONFLICT", - msg=f'Service "{another_service.display_name}" requires this service or its component', - ) - - cancel_locking_tasks(obj=service, obj_deletion=True) - if delete_action and (host_components_exists or service.state != "created"): - run_action( - action=delete_action, - obj=service, - payload=ActionRunPayload(conf={}, attr={}, hostcomponent=[], verbose=False), - hosts=[], - ) - else: - delete_service(service=service) - - return Response(status=HTTP_204_NO_CONTENT) - - -def filter_actions(obj: ADCMEntity, actions: Iterable[Action]): - """Filter out actions that are not allowed to run on object at that moment""" - if obj.concerns.filter(type=ConcernType.LOCK).exists(): - return [] - - allowed = [] - for action in actions: - if action.allowed(obj): - allowed.append(action) - action.config = PrototypeConfig.objects.filter(prototype=action.prototype, action=action).order_by("id") - - return allowed diff --git a/python/api/cluster/serializers.py b/python/api/cluster/serializers.py index 668b1e991f..d830d7b2d2 100644 --- a/python/api/cluster/serializers.py +++ b/python/api/cluster/serializers.py @@ -11,7 +11,6 @@ # limitations under the License. from adcm.serializers import EmptySerializer -from adcm.utils import filter_actions, get_requires from cm.adcm_config.config import get_main_info from cm.api import add_cluster, add_hc, bind, multi_bind from cm.errors import AdcmEx @@ -41,7 +40,7 @@ from api.group_config.serializers import GroupConfigsHyperlinkedIdentityField from api.host.serializers import HostSerializer from api.serializers import DoUpgradeSerializer, StringListSerializer -from api.utils import CommonAPIURL, ObjectURL, UrlField, check_obj +from api.utils import CommonAPIURL, ObjectURL, UrlField, check_obj, filter_actions, get_requires def get_cluster_id(obj): diff --git a/python/api/component/serializers.py b/python/api/component/serializers.py index f04d8eae92..6e4d7de58b 100644 --- a/python/api/component/serializers.py +++ b/python/api/component/serializers.py @@ -11,7 +11,6 @@ # limitations under the License. from adcm.serializers import EmptySerializer -from adcm.utils import filter_actions from cm.adcm_config.config import get_main_info from cm.models import MAINTENANCE_MODE_BOTH_CASES_CHOICES, Action, ServiceComponent from cm.status_api import get_component_status @@ -30,7 +29,7 @@ from api.concern.serializers import ConcernItemSerializer, ConcernItemUISerializer from api.group_config.serializers import GroupConfigsHyperlinkedIdentityField from api.serializers import StringListSerializer -from api.utils import CommonAPIURL, ObjectURL +from api.utils import CommonAPIURL, ObjectURL, filter_actions class ComponentSerializer(EmptySerializer): diff --git a/python/api/component/views.py b/python/api/component/views.py index b9b6b4fc7d..f679d46ee6 100644 --- a/python/api/component/views.py +++ b/python/api/component/views.py @@ -11,9 +11,9 @@ # limitations under the License. from adcm.permissions import check_custom_perm, get_object_for_user -from adcm.utils import get_maintenance_mode_response from audit.utils import audit from cm.models import Cluster, ClusterObject, HostComponent, ServiceComponent +from cm.services.maintenance_mode import get_maintenance_mode_response from cm.services.status.notify import update_mm_objects from cm.status_api import make_ui_component_status from guardian.mixins import PermissionListMixin diff --git a/python/api/concern/views.py b/python/api/concern/views.py index b1432af895..1cfe097095 100644 --- a/python/api/concern/views.py +++ b/python/api/concern/views.py @@ -9,7 +9,6 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from adcm.utils import OBJECT_TYPES_DICT from cm import models from cm.errors import AdcmEx from django.contrib.contenttypes.models import ContentType @@ -22,6 +21,7 @@ ConcernItemSerializer, ConcernItemUISerializer, ) +from api.utils import OBJECT_TYPES_DICT CHOICES = list(zip(OBJECT_TYPES_DICT, OBJECT_TYPES_DICT)) diff --git a/python/api/host/serializers.py b/python/api/host/serializers.py index 4c6ddd7b29..91c127eb0d 100644 --- a/python/api/host/serializers.py +++ b/python/api/host/serializers.py @@ -11,7 +11,6 @@ # limitations under the License. from adcm.serializers import EmptySerializer -from adcm.utils import filter_actions from cm.adcm_config.config import get_main_info from cm.api import add_host from cm.issue import update_hierarchy_issues, update_issue_after_deleting @@ -39,7 +38,7 @@ from api.action.serializers import ActionShort from api.concern.serializers import ConcernItemSerializer, ConcernItemUISerializer from api.serializers import StringListSerializer -from api.utils import CommonAPIURL, ObjectURL, check_obj +from api.utils import CommonAPIURL, ObjectURL, check_obj, filter_actions class HostSerializer(EmptySerializer): diff --git a/python/api/host/views.py b/python/api/host/views.py index 90bb2e115b..05867f8b2e 100644 --- a/python/api/host/views.py +++ b/python/api/host/views.py @@ -17,7 +17,6 @@ check_custom_perm, get_object_for_user, ) -from adcm.utils import get_maintenance_mode_response from audit.utils import audit from cm.api import add_host_to_cluster, delete_host, remove_host_from_cluster from cm.errors import AdcmEx @@ -30,6 +29,7 @@ HostProvider, ServiceComponent, ) +from cm.services.maintenance_mode import get_maintenance_mode_response from cm.services.status.notify import ( reset_hc_map, reset_objects_in_mm, diff --git a/python/api/job/views.py b/python/api/job/views.py index 96352f78fe..74f6c8660c 100644 --- a/python/api/job/views.py +++ b/python/api/job/views.py @@ -16,10 +16,10 @@ import tarfile from adcm.permissions import check_custom_perm, get_object_for_user -from adcm.utils import str_remove_non_alnum from audit.utils import audit from cm.job import restart_task from cm.models import ActionType, JobLog, LogStorage, TaskLog +from cm.utils import str_remove_non_alnum from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.http import HttpResponse diff --git a/python/api/provider/serializers.py b/python/api/provider/serializers.py index d41cbefbe6..f56e75a6e1 100644 --- a/python/api/provider/serializers.py +++ b/python/api/provider/serializers.py @@ -11,7 +11,6 @@ # limitations under the License. from adcm.serializers import EmptySerializer -from adcm.utils import filter_actions from cm.adcm_config.config import get_main_info from cm.api import add_host_provider from cm.errors import AdcmEx @@ -31,7 +30,7 @@ from api.concern.serializers import ConcernItemSerializer, ConcernItemUISerializer from api.group_config.serializers import GroupConfigsHyperlinkedIdentityField from api.serializers import DoUpgradeSerializer, StringListSerializer -from api.utils import CommonAPIURL, ObjectURL, check_obj +from api.utils import CommonAPIURL, ObjectURL, check_obj, filter_actions class ProviderSerializer(EmptySerializer): diff --git a/python/api/rbac/policy/serializers.py b/python/api/rbac/policy/serializers.py index f23b1cce06..eb1f7843c4 100644 --- a/python/api/rbac/policy/serializers.py +++ b/python/api/rbac/policy/serializers.py @@ -10,8 +10,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -from adcm.utils import get_obj_type from cm.models import Cluster, ClusterObject, Host, HostProvider, ServiceComponent +from cm.utils import get_obj_type from rbac.models import Group, Policy, Role, RoleTypes from rest_flex_fields.serializers import FlexFieldsSerializerMixin from rest_framework.exceptions import ValidationError diff --git a/python/api/service/serializers.py b/python/api/service/serializers.py index 2fbc2b91c3..6f54ca68b4 100644 --- a/python/api/service/serializers.py +++ b/python/api/service/serializers.py @@ -11,7 +11,6 @@ # limitations under the License. from adcm.serializers import EmptySerializer -from adcm.utils import filter_actions from cm.adcm_config.config import get_main_info from cm.api import add_service_to_cluster, bind, multi_bind from cm.errors import AdcmEx @@ -44,7 +43,7 @@ from api.concern.serializers import ConcernItemSerializer, ConcernItemUISerializer from api.group_config.serializers import GroupConfigsHyperlinkedIdentityField from api.serializers import StringListSerializer -from api.utils import CommonAPIURL, ObjectURL, check_obj +from api.utils import CommonAPIURL, ObjectURL, check_obj, filter_actions class ServiceSerializer(EmptySerializer): diff --git a/python/api/service/views.py b/python/api/service/views.py index 2de4a57b3f..3de5414eaf 100644 --- a/python/api/service/views.py +++ b/python/api/service/views.py @@ -11,10 +11,11 @@ # limitations under the License. from adcm.permissions import check_custom_perm, get_object_for_user -from adcm.utils import delete_service_from_api, get_maintenance_mode_response from audit.utils import audit from cm.api import get_import, unbind from cm.models import Cluster, ClusterBind, ClusterObject, HostComponent, Prototype +from cm.services.maintenance_mode import get_maintenance_mode_response +from cm.services.service import delete_service_from_api from cm.services.status.notify import update_mm_objects from cm.status_api import make_ui_service_status from guardian.mixins import PermissionListMixin diff --git a/python/api/stack/serializers.py b/python/api/stack/serializers.py index 8b17733207..bfbb8411ba 100644 --- a/python/api/stack/serializers.py +++ b/python/api/stack/serializers.py @@ -11,7 +11,6 @@ # limitations under the License. from adcm.serializers import EmptySerializer -from adcm.utils import get_requires from cm.models import Bundle, ClusterObject, Prototype from cm.schemas import RequiresUISchema from rest_flex_fields.serializers import FlexFieldsSerializerMixin @@ -30,6 +29,7 @@ from api.action.serializers import StackActionDetailSerializer from api.config.serializers import ConfigSerializer from api.serializers import UpgradeSerializer +from api.utils import get_requires class UploadBundleSerializer(EmptySerializer): diff --git a/python/api/tests/test_component.py b/python/api/tests/test_component.py index 93a602494f..5f5a89609a 100644 --- a/python/api/tests/test_component.py +++ b/python/api/tests/test_component.py @@ -131,7 +131,7 @@ def test_change_maintenance_mode_on_with_action_success(self): ) action = Action.objects.create(prototype=self.component.prototype, name=settings.ADCM_TURN_ON_MM_ACTION_NAME) - with patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.maintenance_mode.run_action") as start_task_mock: response: Response = self.client.post( path=reverse(viewname="v1:component-maintenance-mode", kwargs={"component_id": self.component.pk}), data={"maintenance_mode": "ON"}, @@ -153,7 +153,7 @@ def test_change_maintenance_mode_on_from_on_with_action_fail(self): self.component.maintenance_mode = MaintenanceMode.ON self.component.save() - with patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.maintenance_mode.run_action") as start_task_mock: response: Response = self.client.post( path=reverse(viewname="v1:component-maintenance-mode", kwargs={"component_id": self.component.pk}), data={"maintenance_mode": "ON"}, @@ -191,7 +191,7 @@ def test_change_maintenance_mode_off_with_action_success(self): ) action = Action.objects.create(prototype=self.component.prototype, name=settings.ADCM_TURN_OFF_MM_ACTION_NAME) - with patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.maintenance_mode.run_action") as start_task_mock: response: Response = self.client.post( path=reverse(viewname="v1:component-maintenance-mode", kwargs={"component_id": self.component.pk}), data={"maintenance_mode": "OFF"}, @@ -213,7 +213,7 @@ def test_change_maintenance_mode_off_to_off_with_action_fail(self): self.component.maintenance_mode = MaintenanceMode.OFF self.component.save() - with patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.maintenance_mode.run_action") as start_task_mock: response: Response = self.client.post( path=reverse(viewname="v1:component-maintenance-mode", kwargs={"component_id": self.component.pk}), data={"maintenance_mode": "OFF"}, diff --git a/python/api/tests/test_host.py b/python/api/tests/test_host.py index 05f9b5ab35..7b04f5bf17 100644 --- a/python/api/tests/test_host.py +++ b/python/api/tests/test_host.py @@ -93,7 +93,7 @@ def test_change_mm_on_with_action_success(self): state_available="any", ) - with patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.maintenance_mode.run_action") as start_task_mock: response: Response = self.client.post( path=reverse(viewname="v1:host-maintenance-mode", kwargs={"host_id": self.host.pk}), data={"maintenance_mode": "ON"}, @@ -115,7 +115,7 @@ def test_change_mm_on_from_on_with_action_fail(self): self.host.maintenance_mode = MaintenanceMode.ON self.host.save(update_fields=["maintenance_mode"]) - with patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.maintenance_mode.run_action") as start_task_mock: response: Response = self.client.post( path=reverse(viewname="v1:host-maintenance-mode", kwargs={"host_id": self.host.pk}), data={"maintenance_mode": "ON"}, @@ -150,7 +150,7 @@ def test_change_mm_off_with_action_success(self): name=settings.ADCM_HOST_TURN_OFF_MM_ACTION_NAME, ) - with patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.maintenance_mode.run_action") as start_task_mock: response: Response = self.client.post( path=reverse(viewname="v1:host-maintenance-mode", kwargs={"host_id": self.host.pk}), data={"maintenance_mode": "OFF"}, @@ -172,7 +172,7 @@ def test_change_mm_off_to_off_with_action_fail(self): self.host.maintenance_mode = MaintenanceMode.OFF self.host.save(update_fields=["maintenance_mode"]) - with patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.maintenance_mode.run_action") as start_task_mock: response: Response = self.client.post( path=reverse(viewname="v1:host-maintenance-mode", kwargs={"host_id": self.host.pk}), data={"maintenance_mode": "OFF"}, @@ -323,7 +323,7 @@ def test_change_maintenance_mode_on_with_action_via_bundle_success(self): data={"host_id": host.pk}, ) - with patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.maintenance_mode.run_action") as start_task_mock: response: Response = self.client.post( path=reverse(viewname="v1:host-maintenance-mode", kwargs={"host_id": host.pk}), data={"maintenance_mode": "ON"}, diff --git a/python/api/tests/test_service.py b/python/api/tests/test_service.py index 4f24c9f06a..f7b2cea4e6 100644 --- a/python/api/tests/test_service.py +++ b/python/api/tests/test_service.py @@ -120,7 +120,7 @@ def test_change_maintenance_mode_on_with_action_success(self): ) action = Action.objects.create(prototype=self.service.prototype, name=settings.ADCM_TURN_ON_MM_ACTION_NAME) - with patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.maintenance_mode.run_action") as start_task_mock: response: Response = self.client.post( path=reverse(viewname="v1:service-maintenance-mode", kwargs={"service_id": self.service.pk}), data={"maintenance_mode": "ON"}, @@ -142,7 +142,7 @@ def test_change_maintenance_mode_on_from_on_with_action_fail(self): self.service.maintenance_mode = MaintenanceMode.ON self.service.save() - with patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.maintenance_mode.run_action") as start_task_mock: response: Response = self.client.post( path=reverse(viewname="v1:service-maintenance-mode", kwargs={"service_id": self.service.pk}), data={"maintenance_mode": "ON"}, @@ -180,7 +180,7 @@ def test_change_maintenance_mode_off_with_action_success(self): ) action = Action.objects.create(prototype=self.service.prototype, name=settings.ADCM_TURN_OFF_MM_ACTION_NAME) - with patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.maintenance_mode.run_action") as start_task_mock: response: Response = self.client.post( path=reverse(viewname="v1:service-maintenance-mode", kwargs={"service_id": self.service.pk}), data={"maintenance_mode": "OFF"}, @@ -202,7 +202,7 @@ def test_change_maintenance_mode_off_to_off_with_action_fail(self): self.service.maintenance_mode = MaintenanceMode.OFF self.service.save() - with patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.maintenance_mode.run_action") as start_task_mock: response: Response = self.client.post( path=reverse(viewname="v1:service-maintenance-mode", kwargs={"service_id": self.service.pk}), data={"maintenance_mode": "OFF"}, @@ -242,7 +242,7 @@ def test_delete_without_action(self): def test_delete_with_action(self): action = Action.objects.create(prototype=self.service.prototype, name=settings.ADCM_DELETE_SERVICE_ACTION_NAME) - with patch("adcm.utils.delete_service"), patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.service.delete_service"), patch("cm.services.service.run_action") as start_task_mock: response: Response = self.client.delete( path=reverse(viewname="v1:service-details", kwargs={"service_id": self.service.pk}), ) @@ -273,7 +273,7 @@ def test_delete_with_action(self): component=service_component, ) - with patch("adcm.utils.delete_service"), patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.service.delete_service"), patch("cm.services.service.run_action") as start_task_mock: response: Response = self.client.delete( path=reverse(viewname="v1:service-details", kwargs={"service_id": self.service.pk}), ) @@ -291,7 +291,7 @@ def test_delete_with_action_not_created_state(self): self.service.state = "not created" self.service.save(update_fields=["state"]) - with patch("adcm.utils.delete_service"), patch("adcm.utils.run_action") as start_task_mock: + with patch("cm.services.service.delete_service"), patch("cm.services.service.run_action") as start_task_mock: response: Response = self.client.delete( path=reverse(viewname="v1:service-details", kwargs={"service_id": self.service.pk}), ) @@ -353,7 +353,7 @@ def test_delete_required_fail(self): self.service.prototype.required = True self.service.prototype.save(update_fields=["required"]) - with patch("adcm.utils.delete_service"): + with patch("cm.services.service.delete_service"): response: Response = self.client.delete( path=reverse(viewname="v1:service-details", kwargs={"service_id": self.service.pk}), ) @@ -370,7 +370,7 @@ def test_delete_export_bind_fail(self): source_service=self.service, ) - with patch("adcm.utils.delete_service"): + with patch("cm.services.service.delete_service"): response: Response = self.client.delete( path=reverse(viewname="v1:service-details", kwargs={"service_id": self.service.pk}), ) @@ -387,7 +387,7 @@ def test_delete_import_bind_success(self): source_service=service_2, ) - with patch("adcm.utils.delete_service"): + with patch("cm.services.service.delete_service"): response: Response = self.client.delete( path=reverse(viewname="v1:service-details", kwargs={"service_id": self.service.pk}), ) diff --git a/python/api/utils.py b/python/api/utils.py index 274e62d809..490d803428 100644 --- a/python/api/utils.py +++ b/python/api/utils.py @@ -9,7 +9,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +from typing import Any, Iterable +from cm.models import Action, ADCMEntity, ConcernType, Prototype, PrototypeConfig from django.http.request import QueryDict from django_filters import rest_framework as drf_filters from rest_framework.filters import OrderingFilter @@ -18,6 +20,18 @@ from rest_framework.serializers import HyperlinkedIdentityField from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_400_BAD_REQUEST +OBJECT_TYPES_DICT = { + "adcm": "adcm", + "cluster": "cluster", + "service": "clusterobject", + "cluster object": "service", + "component": "servicecomponent", + "service component": "servicecomponent", + "provider": "hostprovider", + "host provider": "hostprovider", + "host": "host", +} + def check_obj(model, req, error=None): # noqa: ARG001 kwargs = req if isinstance(req, dict) else {"id": req} @@ -138,3 +152,87 @@ def get_filterset_kwargs(self, request, queryset, view): "queryset": queryset, "request": request, } + + +def process_requires( + proto: Prototype, comp_dict: dict, checked_object: list | None = None, adding_service: bool = False +) -> dict: + if checked_object is None: + checked_object = [] + checked_object.append(proto) + + for require in proto.requires: + req_service = Prototype.obj.get(type="service", name=require["service"], bundle=proto.bundle) + + if req_service.name not in comp_dict: + comp_dict[req_service.name] = {"components": {}, "service": req_service} + + req_comp = None + if require.get("component"): + req_comp = Prototype.obj.get( + type="component", + name=require["component"], + parent=req_service, + ) + comp_dict[req_service.name]["components"][req_comp.name] = req_comp + + if req_service.requires and req_service not in checked_object: + process_requires( + proto=req_service, comp_dict=comp_dict, checked_object=checked_object, adding_service=adding_service + ) + + if req_comp and req_comp.requires and req_comp not in checked_object and not adding_service: + process_requires(proto=req_comp, comp_dict=comp_dict, checked_object=checked_object) + + return comp_dict + + +def get_requires( + prototype: Prototype, + adding_service: bool = False, +) -> list[dict[str, list[dict[str, Any]] | Any]] | None: + if not prototype.requires: + return None + + proto_dict = {} + proto_dict = process_requires(proto=prototype, comp_dict=proto_dict, adding_service=adding_service) + + out = [] + + for service_name, params in proto_dict.items(): + comp_out = [] + service = params["service"] + for comp_name in params["components"]: + comp = params["components"][comp_name] + comp_out.append( + { + "prototype_id": comp.id, + "name": comp_name, + "display_name": comp.display_name, + }, + ) + + out.append( + { + "prototype_id": service.id, + "name": service_name, + "display_name": service.display_name, + "components": comp_out, + }, + ) + + return out + + +def filter_actions(obj: ADCMEntity, actions: Iterable[Action]): + """Filter out actions that are not allowed to run on object at that moment""" + if obj.concerns.filter(type=ConcernType.LOCK).exists(): + return [] + + allowed = [] + for action in actions: + if action.allowed(obj): + allowed.append(action) + action.config = PrototypeConfig.objects.filter(prototype=action.prototype, action=action).order_by("id") + + return allowed diff --git a/python/api/views.py b/python/api/views.py index d5c189b157..84d0bcb614 100644 --- a/python/api/views.py +++ b/python/api/views.py @@ -10,7 +10,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from adcm.utils import has_google_oauth, has_yandex_oauth +from cm.services.authorization import has_google_oauth, has_yandex_oauth from cm.stack import NAME_REGEX from django.conf import settings from rest_framework.permissions import AllowAny diff --git a/python/api_v2/component/views.py b/python/api_v2/component/views.py index 9ca6723069..57659045d5 100644 --- a/python/api_v2/component/views.py +++ b/python/api_v2/component/views.py @@ -21,10 +21,10 @@ check_custom_perm, get_object_for_user, ) -from adcm.utils import get_maintenance_mode_response from audit.utils import audit from cm.errors import AdcmEx from cm.models import Cluster, ClusterObject, Host, ServiceComponent +from cm.services.maintenance_mode import get_maintenance_mode_response from cm.services.status.notify import update_mm_objects from guardian.mixins import PermissionListMixin from rest_framework.decorators import action diff --git a/python/api_v2/host/utils.py b/python/api_v2/host/utils.py index 576fe520b5..357f16863f 100644 --- a/python/api_v2/host/utils.py +++ b/python/api_v2/host/utils.py @@ -11,13 +11,13 @@ # limitations under the License. from adcm.permissions import check_custom_perm -from adcm.utils import get_maintenance_mode_response from cm.adcm_config.config import init_object_config from cm.api import check_license from cm.api_context import CTX from cm.issue import add_concern_to_object, update_hierarchy_issues from cm.logger import logger from cm.models import Cluster, Host, HostProvider, Prototype +from cm.services.maintenance_mode import get_maintenance_mode_response from cm.services.status.notify import reset_hc_map from django.db.transaction import atomic from rbac.models import re_apply_object_policy diff --git a/python/api_v2/log_storage/utils.py b/python/api_v2/log_storage/utils.py index ee0e9cd931..5945797634 100644 --- a/python/api_v2/log_storage/utils.py +++ b/python/api_v2/log_storage/utils.py @@ -16,7 +16,6 @@ import tarfile from adcm import settings -from adcm.utils import str_remove_non_alnum from cm.models import ( ActionType, ClusterObject, @@ -26,6 +25,7 @@ ServiceComponent, TaskLog, ) +from cm.utils import str_remove_non_alnum def get_task_download_archive_name(task: TaskLog) -> str: diff --git a/python/api_v2/service/views.py b/python/api_v2/service/views.py index 4d187c9e0f..4d8fa67d2e 100644 --- a/python/api_v2/service/views.py +++ b/python/api_v2/service/views.py @@ -18,10 +18,11 @@ check_custom_perm, get_object_for_user, ) -from adcm.utils import delete_service_from_api, get_maintenance_mode_response from audit.utils import audit from cm.errors import AdcmEx from cm.models import Cluster, ClusterObject +from cm.services.maintenance_mode import get_maintenance_mode_response +from cm.services.service import delete_service_from_api from cm.services.status.notify import update_mm_objects from django_filters.rest_framework.backends import DjangoFilterBackend from guardian.mixins import PermissionListMixin diff --git a/python/audit/cases/config.py b/python/audit/cases/config.py index fcf08635c1..47770ab969 100644 --- a/python/audit/cases/config.py +++ b/python/audit/cases/config.py @@ -11,8 +11,8 @@ # limitations under the License. from contextlib import suppress -from adcm.utils import get_obj_type from cm.models import GroupConfig, Host, ObjectConfig, get_cm_model_by_type +from cm.utils import get_obj_type from django.contrib.contenttypes.models import ContentType from rest_framework.response import Response from rest_framework.viewsets import ViewSet diff --git a/python/cm/services/authorization.py b/python/cm/services/authorization.py new file mode 100644 index 0000000000..d3b812cf9a --- /dev/null +++ b/python/cm/services/authorization.py @@ -0,0 +1,55 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from cm.adcm_config.ansible import ansible_decrypt +from cm.models import ADCM, ConfigLog + + +def _get_oauth(oauth_key: str) -> tuple[str | None, str | None]: + adcm = ADCM.objects.filter().first() + if not adcm: + return None, None + + config_log = ConfigLog.objects.get(obj_ref=adcm.config, id=adcm.config.current) + if not config_log: + return None, None + + if not config_log.config.get(oauth_key): + return None, None + + if "client_id" not in config_log.config[oauth_key] or "secret" not in config_log.config[oauth_key]: + return None, None + + secret = config_log.config[oauth_key]["secret"] + if not secret: + return None, None + + return ( + config_log.config[oauth_key]["client_id"], + ansible_decrypt(secret), + ) + + +def get_yandex_oauth() -> tuple[str, str]: + return _get_oauth(oauth_key="yandex_oauth") + + +def get_google_oauth() -> tuple[str, str]: + return _get_oauth(oauth_key="google_oauth") + + +def has_yandex_oauth() -> bool: + return all(get_yandex_oauth()) + + +def has_google_oauth() -> bool: + return all(get_google_oauth()) diff --git a/python/cm/services/maintenance_mode.py b/python/cm/services/maintenance_mode.py new file mode 100644 index 0000000000..8f44aceb96 --- /dev/null +++ b/python/cm/services/maintenance_mode.py @@ -0,0 +1,166 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.conf import settings +from rest_framework.response import Response +from rest_framework.serializers import Serializer +from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_409_CONFLICT + +from cm.flag import update_flags +from cm.issue import update_hierarchy_issues, update_issue_after_deleting +from cm.job import ActionRunPayload, run_action +from cm.models import Action, ClusterObject, Host, HostComponent, MaintenanceMode, Prototype, ServiceComponent +from cm.services.status.notify import reset_objects_in_mm +from cm.status_api import send_object_update_event + + +def _change_mm_via_action( + prototype: Prototype, + action_name: str, + obj: Host | ClusterObject | ServiceComponent, + serializer: Serializer, +) -> Serializer: + action = Action.objects.filter(prototype=prototype, name=action_name).first() + if action: + run_action( + action=action, + obj=obj, + payload=ActionRunPayload(conf={}, attr={}, hostcomponent=[], verbose=False), + hosts=[], + ) + serializer.validated_data["maintenance_mode"] = MaintenanceMode.CHANGING + + return serializer + + +def _update_mm_hierarchy_issues(obj: Host | ClusterObject | ServiceComponent) -> None: + if isinstance(obj, Host): + update_hierarchy_issues(obj.provider) + + providers = {host_component.host.provider for host_component in HostComponent.objects.filter(cluster=obj.cluster)} + for provider in providers: + update_hierarchy_issues(provider) + + update_hierarchy_issues(obj.cluster) + update_issue_after_deleting() + update_flags() + reset_objects_in_mm() + + +def get_maintenance_mode_response( + obj: Host | ClusterObject | ServiceComponent, + serializer: Serializer, +) -> Response: + turn_on_action_name = settings.ADCM_TURN_ON_MM_ACTION_NAME + turn_off_action_name = settings.ADCM_TURN_OFF_MM_ACTION_NAME + prototype = obj.prototype + if isinstance(obj, Host): + obj_name = "host" + turn_on_action_name = settings.ADCM_HOST_TURN_ON_MM_ACTION_NAME + turn_off_action_name = settings.ADCM_HOST_TURN_OFF_MM_ACTION_NAME + + if not obj.cluster: + return Response( + data={ + "code": "MAINTENANCE_MODE_NOT_AVAILABLE", + "level": "error", + "desc": "Maintenance mode is not available", + }, + status=HTTP_409_CONFLICT, + ) + + prototype = obj.cluster.prototype + elif isinstance(obj, ClusterObject): + obj_name = "service" + elif isinstance(obj, ServiceComponent): + obj_name = "component" + else: + obj_name = "obj" + + service_has_hc = None + if obj_name == "service": + service_has_hc = HostComponent.objects.filter(service=obj).exists() + + component_has_hc = None + if obj_name == "component": + component_has_hc = HostComponent.objects.filter(component=obj).exists() + + if obj.maintenance_mode_attr == MaintenanceMode.CHANGING: + return Response( + data={ + "code": "MAINTENANCE_MODE", + "level": "error", + "desc": "Maintenance mode is changing now", + }, + status=HTTP_409_CONFLICT, + ) + + if obj.maintenance_mode_attr == MaintenanceMode.OFF: + if serializer.validated_data["maintenance_mode"] == MaintenanceMode.OFF: + return Response( + data={ + "code": "MAINTENANCE_MODE", + "level": "error", + "desc": "Maintenance mode already off", + }, + status=HTTP_409_CONFLICT, + ) + + if obj_name == "host" or service_has_hc or component_has_hc: + serializer = _change_mm_via_action( + prototype=prototype, + action_name=turn_on_action_name, + obj=obj, + serializer=serializer, + ) + else: + obj.maintenance_mode = MaintenanceMode.ON + serializer.validated_data["maintenance_mode"] = MaintenanceMode.ON + + serializer.save() + _update_mm_hierarchy_issues(obj=obj) + send_object_update_event(object_=obj, changes={"maintenanceMode": obj.maintenance_mode}) + + return Response() + + if obj.maintenance_mode_attr == MaintenanceMode.ON: + if serializer.validated_data["maintenance_mode"] == MaintenanceMode.ON: + return Response( + data={ + "code": "MAINTENANCE_MODE", + "level": "error", + "desc": "Maintenance mode already on", + }, + status=HTTP_409_CONFLICT, + ) + + if obj_name == "host" or service_has_hc or component_has_hc: + serializer = _change_mm_via_action( + prototype=prototype, + action_name=turn_off_action_name, + obj=obj, + serializer=serializer, + ) + else: + obj.maintenance_mode = MaintenanceMode.OFF + serializer.validated_data["maintenance_mode"] = MaintenanceMode.OFF + + serializer.save() + _update_mm_hierarchy_issues(obj=obj) + send_object_update_event(object_=obj, changes={"maintenanceMode": obj.maintenance_mode}) + + return Response() + + return Response( + data={"error": f'Unknown {obj_name} maintenance mode "{obj.maintenance_mode}"'}, + status=HTTP_400_BAD_REQUEST, + ) diff --git a/python/cm/services/service.py b/python/cm/services/service.py new file mode 100644 index 0000000000..62c66df5d0 --- /dev/null +++ b/python/cm/services/service.py @@ -0,0 +1,77 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.conf import settings +from rest_framework.response import Response +from rest_framework.status import HTTP_204_NO_CONTENT + +from cm.api import cancel_locking_tasks, delete_service +from cm.errors import AdcmEx +from cm.job import ActionRunPayload, run_action +from cm.models import Action, ClusterBind, ClusterObject, HostComponent, JobStatus, ServiceComponent, TaskLog + + +def delete_service_from_api(service: ClusterObject) -> Response: + delete_action = Action.objects.filter( + prototype=service.prototype, + name=settings.ADCM_DELETE_SERVICE_ACTION_NAME, + ).first() + host_components_exists = HostComponent.objects.filter(cluster=service.cluster, service=service).exists() + + if not delete_action: + if service.state != "created": + raise AdcmEx(code="SERVICE_DELETE_ERROR") + + if host_components_exists: + raise AdcmEx(code="SERVICE_CONFLICT", msg=f'Service "{service.display_name}" has component(s) on host(s)') + + cluster = service.cluster + + if cluster.state == "upgrading" and service.prototype.name in cluster.before_upgrade.get("services", ()): + raise AdcmEx(code="SERVICE_CONFLICT", msg="Can't remove service when upgrading cluster") + + if ClusterBind.objects.filter(source_service=service).exists(): + raise AdcmEx(code="SERVICE_CONFLICT", msg=f'Service "{service.display_name}" has exports(s)') + + if service.prototype.required: + raise AdcmEx(code="SERVICE_CONFLICT", msg=f'Service "{service.display_name}" is required') + + if TaskLog.objects.filter(action=delete_action, status__in={JobStatus.CREATED, JobStatus.RUNNING}).exists(): + raise AdcmEx(code="SERVICE_DELETE_ERROR", msg="Service is deleting now") + + for component in ServiceComponent.objects.filter(cluster=service.cluster).exclude(service=service): + if component.requires_service_name(service_name=service.name): + raise AdcmEx( + code="SERVICE_CONFLICT", + msg=f'Component "{component.name}" of service "{component.service.display_name}' + f" requires this service or its component", + ) + + for another_service in ClusterObject.objects.filter(cluster=service.cluster): + if another_service.requires_service_name(service_name=service.name): + raise AdcmEx( + code="SERVICE_CONFLICT", + msg=f'Service "{another_service.display_name}" requires this service or its component', + ) + + cancel_locking_tasks(obj=service, obj_deletion=True) + if delete_action and (host_components_exists or service.state != "created"): + run_action( + action=delete_action, + obj=service, + payload=ActionRunPayload(conf={}, attr={}, hostcomponent=[], verbose=False), + hosts=[], + ) + else: + delete_service(service=service) + + return Response(status=HTTP_204_NO_CONTENT) diff --git a/python/cm/utils.py b/python/cm/utils.py index 7d01970181..0ef763cb42 100644 --- a/python/cm/utils.py +++ b/python/cm/utils.py @@ -11,11 +11,8 @@ # limitations under the License. from collections.abc import Mapping -from pathlib import Path -from secrets import token_hex from typing import Any, Iterable, Protocol, TypeVar import os -import json class WithPK(Protocol): @@ -29,31 +26,6 @@ def build_id_object_mapping(objects: Iterable[ObjectWithPk]) -> dict[int, Object return {object_.pk: object_ for object_ in objects} -def dict_json_get_or_create(path: str | Path, field: str, value: Any = None) -> Any: - with open(path, encoding="utf-8") as f: - data = json.load(f) - - if field not in data: - data[field] = value - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f) - - return data[field] - - -def get_adcm_token(token_path: Path) -> str: - if not token_path.is_file(): - token_path.parent.mkdir(parents=True, exist_ok=True) - with token_path.open(mode="w", encoding="utf-8") as f: - f.write(token_hex(20)) - - with token_path.open(encoding="utf-8") as f: - adcm_token = f.read().strip() - adcm_token.encode(encoding="idna").decode(encoding="utf-8") - - return adcm_token - - def get_env_with_venv_path(venv: str, existing_env: dict | None = None) -> dict: if existing_env is None: existing_env = os.environ.copy() @@ -108,3 +80,22 @@ def obj_ref(obj: type["ADCMEntity"]) -> str: # noqa: F821 name = obj.prototype.name return f'{obj.prototype.type} #{obj.id} "{name}"' + + +def get_obj_type(obj_type: str) -> str: + object_names_to_object_types = { + "adcm": "adcm", + "cluster": "cluster", + "cluster object": "service", + "service component": "component", + "host provider": "provider", + "host": "host", + } + return object_names_to_object_types[obj_type] + + +def str_remove_non_alnum(value: str) -> str: + result = "".join(ch.lower().replace(" ", "-") for ch in value if (ch.isalnum() or ch == " ")) + while result.find("--") != -1: + result = result.replace("--", "-") + return result From e057148114b5a462ed6ac644228f11615e080960 Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Wed, 27 Mar 2024 07:29:22 +0000 Subject: [PATCH 021/208] ADCM-5341: tests for mm actions --- .../mm_plugins_mm_actions/component-mm.yaml | 33 +++ .../mm_plugins_mm_actions/config.yaml | 65 +++++ .../mm_plugins_mm_actions/host-mm.yaml | 33 +++ .../mm_plugins_mm_actions/service-mm.yaml | 33 +++ python/api_v2/tests/test_maintenance_mode.py | 224 ++++++++++++++++++ python/cm/tests/mocks/task_runner.py | 45 +++- 6 files changed, 427 insertions(+), 6 deletions(-) create mode 100644 python/api_v2/tests/bundles/maintenance_mode/mm_plugins_mm_actions/component-mm.yaml create mode 100755 python/api_v2/tests/bundles/maintenance_mode/mm_plugins_mm_actions/config.yaml create mode 100644 python/api_v2/tests/bundles/maintenance_mode/mm_plugins_mm_actions/host-mm.yaml create mode 100644 python/api_v2/tests/bundles/maintenance_mode/mm_plugins_mm_actions/service-mm.yaml create mode 100644 python/api_v2/tests/test_maintenance_mode.py diff --git a/python/api_v2/tests/bundles/maintenance_mode/mm_plugins_mm_actions/component-mm.yaml b/python/api_v2/tests/bundles/maintenance_mode/mm_plugins_mm_actions/component-mm.yaml new file mode 100644 index 0000000000..8baf048c6a --- /dev/null +++ b/python/api_v2/tests/bundles/maintenance_mode/mm_plugins_mm_actions/component-mm.yaml @@ -0,0 +1,33 @@ + +--- +- name: Change MM of component + hosts: localhost + connection: local + gather_facts: no + + tasks: + - name: debug_info + debug: + msg: "Component turn ON" + tags: + - turn_on + + - name: change mm + adcm_change_maintenance_mode: + type: component + value: True + tags: + - turn_on + + - name: debug_info + debug: + msg: "Component turn OFF" + tags: + - turn_off + + - name: change mm + adcm_change_maintenance_mode: + type: component + value: False + tags: + - turn_off diff --git a/python/api_v2/tests/bundles/maintenance_mode/mm_plugins_mm_actions/config.yaml b/python/api_v2/tests/bundles/maintenance_mode/mm_plugins_mm_actions/config.yaml new file mode 100755 index 0000000000..03dce2f79c --- /dev/null +++ b/python/api_v2/tests/bundles/maintenance_mode/mm_plugins_mm_actions/config.yaml @@ -0,0 +1,65 @@ +--- +- type: cluster + name: cluster_with_mm_plugins_mm_actions + display_name: cluster_with_mm_plugins_mm_actions + version: &version '1.0' + edition: community + allow_maintenance_mode: true + actions: + adcm_host_turn_on_maintenance_mode: &action + type: job + script: ./host-mm.yaml + script_type: ansible + host_action: true + states: + available: any + params: + ansible_tags: turn_on + adcm_host_turn_off_maintenance_mode: + <<: *action + params: + ansible_tags: turn_off + + config: &config + - name: float + type: float + required: false + default: 0.1 + +- name: service_1 + display_name: Service 1 + type: service + version: *version + actions: + adcm_turn_on_maintenance_mode: + <<: *action + host_action: false + script: ./service-mm.yaml + params: + ansible_tags: turn_on + adcm_turn_off_maintenance_mode: + <<: *action + host_action: false + script: ./service-mm.yaml + params: + ansible_tags: turn_off + config: *config + + components: + component_1: + display_name: Component 1 from Service 1 + constraint: [ 1,+ ] + actions: + adcm_turn_on_maintenance_mode: + <<: *action + host_action: false + script: ./component-mm.yaml + params: + ansible_tags: turn_on + adcm_turn_off_maintenance_mode: + <<: *action + host_action: false + script: ./component-mm.yaml + params: + ansible_tags: turn_off + config: *config diff --git a/python/api_v2/tests/bundles/maintenance_mode/mm_plugins_mm_actions/host-mm.yaml b/python/api_v2/tests/bundles/maintenance_mode/mm_plugins_mm_actions/host-mm.yaml new file mode 100644 index 0000000000..51ff261b7c --- /dev/null +++ b/python/api_v2/tests/bundles/maintenance_mode/mm_plugins_mm_actions/host-mm.yaml @@ -0,0 +1,33 @@ + +--- +- name: Change MM of host + hosts: localhost + connection: local + gather_facts: no + + tasks: + - name: debug_info + debug: + msg: "Host turn ON" + tags: + - turn_on + + - name: change mm + adcm_change_maintenance_mode: + type: host + value: True + tags: + - turn_on + + - name: debug_info + debug: + msg: "Host turn OFF" + tags: + - turn_off + + - name: change mm + adcm_change_maintenance_mode: + type: host + value: False + tags: + - turn_off diff --git a/python/api_v2/tests/bundles/maintenance_mode/mm_plugins_mm_actions/service-mm.yaml b/python/api_v2/tests/bundles/maintenance_mode/mm_plugins_mm_actions/service-mm.yaml new file mode 100644 index 0000000000..cb858b6786 --- /dev/null +++ b/python/api_v2/tests/bundles/maintenance_mode/mm_plugins_mm_actions/service-mm.yaml @@ -0,0 +1,33 @@ + +--- +- name: Change MM of service + hosts: localhost + connection: local + gather_facts: no + + tasks: + - name: debug_info + debug: + msg: "Service turn ON" + tags: + - turn_on + + - name: change mm + adcm_change_maintenance_mode: + type: service + value: True + tags: + - turn_on + + - name: debug_info + debug: + msg: "Service turn OFF" + tags: + - turn_off + + - name: change mm + adcm_change_maintenance_mode: + type: service + value: False + tags: + - turn_off diff --git a/python/api_v2/tests/test_maintenance_mode.py b/python/api_v2/tests/test_maintenance_mode.py new file mode 100644 index 0000000000..5b0ee86efe --- /dev/null +++ b/python/api_v2/tests/test_maintenance_mode.py @@ -0,0 +1,224 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from cm.models import ClusterObject, Host, MaintenanceMode, ServiceComponent +from cm.tests.mocks.task_runner import ExecutionTargetFactoryDummyMock, FailedJobInfo, RunTaskMock +from django.urls import reverse +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK + +from api_v2.tests.base import BaseAPITestCase + + +class TestMMActions(BaseAPITestCase): + """ + Tests for reserved mm-action names + No actual ansible playbook runs, thus checking for `changing` mm status + """ + + def setUp(self) -> None: + self.client.login(username="admin", password="admin") + + bundle_mm_plugins_mm_actions = self.add_bundle( + source_dir=self.test_bundles_dir / "maintenance_mode" / "mm_plugins_mm_actions" + ) + self.cluster = self.add_cluster(bundle=bundle_mm_plugins_mm_actions, name="cluster_mm_plugins_mm_actions") + self.service = self.add_services_to_cluster(service_names=["service_1"], cluster=self.cluster).get() + self.component = self.service.servicecomponent_set.get(prototype__name="component_1") + + provider_bundle = self.add_bundle(source_dir=self.test_bundles_dir / "provider") + provider = self.add_provider(bundle=provider_bundle, name="provider", description="provider") + self.host = self.add_host(bundle=provider_bundle, provider=provider, fqdn="host") + + def _do_change_mm_request( + self, obj: Host | ClusterObject | ServiceComponent, failed_job: FailedJobInfo | None = None + ) -> tuple[Response, RunTaskMock]: + match obj.maintenance_mode: + case MaintenanceMode.ON: + data = {"maintenanceMode": MaintenanceMode.OFF.value} + case MaintenanceMode.OFF: + data = {"maintenanceMode": MaintenanceMode.ON.value} + case _: + raise ValueError(f"Unexpected mm status: {obj.maintenance_mode}") + + match type(obj).__name__: + case Host.__name__: + viewname = "v2:host-cluster-maintenance-mode" + kwargs = {"cluster_pk": obj.cluster_id, "pk": obj.pk} + case ClusterObject.__name__: + viewname = "v2:service-maintenance-mode" + kwargs = {"cluster_pk": obj.cluster_id, "pk": obj.pk} + case ServiceComponent.__name__: + viewname = "v2:component-maintenance-mode" + kwargs = {"cluster_pk": obj.cluster_id, "service_pk": obj.service_id, "pk": obj.pk} + case _: + raise ValueError(f"Wrong obj type: {type(obj).__name__}") + + run_task_mock_kwargs = {} + if failed_job: + run_task_mock_kwargs = {"execution_target_factory": ExecutionTargetFactoryDummyMock(failed_job=failed_job)} + + with RunTaskMock(**run_task_mock_kwargs) as run_task_mock: + response = self.client.post(path=reverse(viewname=viewname, kwargs=kwargs), data=data) + + return response, run_task_mock + + def test_no_task_run_without_hc_service(self): + self.add_host_to_cluster(cluster=self.cluster, host=self.host) + + response, run_task_mock = self._do_change_mm_request(obj=self.service) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.service.refresh_from_db() + self.assertEqual(self.service.maintenance_mode, MaintenanceMode.ON) + self.assertIsNone(run_task_mock.target_task) + self.assertIsNone(run_task_mock.runner) + + def test_task_run_if_hc_exists_service(self): + self.add_host_to_cluster(cluster=self.cluster, host=self.host) + self.add_hostcomponent_map( + cluster=self.cluster, + hc_map=[{"host_id": self.host.pk, "service_id": self.service.pk, "component_id": self.component.pk}], + ) + + response, run_task_mock = self._do_change_mm_request(obj=self.service) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.service.refresh_from_db() + self.assertEqual(self.service.maintenance_mode, MaintenanceMode.CHANGING) + self.assertIsNotNone(run_task_mock.target_task) + self.assertEqual(run_task_mock.target_task.action.name, "adcm_turn_on_maintenance_mode") + self.assertIsNotNone(run_task_mock.runner) + + def test_no_task_run_without_hc_component(self): + self.add_host_to_cluster(cluster=self.cluster, host=self.host) + + response, run_task_mock = self._do_change_mm_request(obj=self.component) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.component.refresh_from_db() + self.assertEqual(self.component.maintenance_mode, MaintenanceMode.ON) + self.assertIsNone(run_task_mock.target_task) + self.assertIsNone(run_task_mock.runner) + + def test_task_run_if_hc_exists_component(self): + self.add_host_to_cluster(cluster=self.cluster, host=self.host) + self.add_hostcomponent_map( + cluster=self.cluster, + hc_map=[{"host_id": self.host.pk, "service_id": self.service.pk, "component_id": self.component.pk}], + ) + + response, run_task_mock = self._do_change_mm_request(obj=self.component) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.component.refresh_from_db() + self.assertEqual(self.component.maintenance_mode, MaintenanceMode.CHANGING) + self.assertIsNotNone(run_task_mock.target_task) + self.assertEqual(run_task_mock.target_task.action.name, "adcm_turn_on_maintenance_mode") + self.assertIsNotNone(run_task_mock.runner) + + def test_task_run_if_obj_is_host_without_hc(self): + self.add_host_to_cluster(cluster=self.cluster, host=self.host) + + response, run_task_mock = self._do_change_mm_request(obj=self.host) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.host.refresh_from_db() + self.assertEqual(self.host.maintenance_mode, MaintenanceMode.CHANGING) + self.assertIsNotNone(run_task_mock.target_task) + self.assertEqual(run_task_mock.target_task.action.name, "adcm_host_turn_on_maintenance_mode") + self.assertIsNotNone(run_task_mock.runner) + + def test_task_run_if_obj_is_host_hc_exists(self): + self.add_host_to_cluster(cluster=self.cluster, host=self.host) + self.add_hostcomponent_map( + cluster=self.cluster, + hc_map=[{"host_id": self.host.pk, "service_id": self.service.pk, "component_id": self.component.pk}], + ) + + response, run_task_mock = self._do_change_mm_request(obj=self.host) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.host.refresh_from_db() + self.assertEqual(self.host.maintenance_mode, MaintenanceMode.CHANGING) + self.assertIsNotNone(run_task_mock.target_task) + self.assertEqual(run_task_mock.target_task.action.name, "adcm_host_turn_on_maintenance_mode") + self.assertIsNotNone(run_task_mock.runner) + + def test_mm_not_changed_on_fail_service(self): + self.add_host_to_cluster(cluster=self.cluster, host=self.host) + self.add_hostcomponent_map( + cluster=self.cluster, + hc_map=[{"host_id": self.host.pk, "service_id": self.service.pk, "component_id": self.component.pk}], + ) + initial_object_mm = self.service.maintenance_mode + + response, run_task_mock = self._do_change_mm_request( + obj=self.service, failed_job=FailedJobInfo(position=0, return_code=1) + ) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.service.refresh_from_db() + self.assertEqual(self.service.maintenance_mode, MaintenanceMode.CHANGING) + self.assertIsNotNone(run_task_mock.target_task) + self.assertEqual(run_task_mock.target_task.action.name, "adcm_turn_on_maintenance_mode") + self.assertIsNotNone(run_task_mock.runner) + + run_task_mock.runner.run(task_id=run_task_mock.target_task.pk) + self.service.refresh_from_db() + self.assertEqual(self.service.maintenance_mode, initial_object_mm) + + def test_mm_not_changed_on_fail_component(self): + self.add_host_to_cluster(cluster=self.cluster, host=self.host) + self.add_hostcomponent_map( + cluster=self.cluster, + hc_map=[{"host_id": self.host.pk, "service_id": self.service.pk, "component_id": self.component.pk}], + ) + initial_object_mm = self.component.maintenance_mode + + response, run_task_mock = self._do_change_mm_request( + obj=self.component, failed_job=FailedJobInfo(position=0, return_code=1) + ) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.component.refresh_from_db() + self.assertEqual(self.component.maintenance_mode, MaintenanceMode.CHANGING) + self.assertIsNotNone(run_task_mock.target_task) + self.assertEqual(run_task_mock.target_task.action.name, "adcm_turn_on_maintenance_mode") + self.assertIsNotNone(run_task_mock.runner) + + run_task_mock.runner.run(task_id=run_task_mock.target_task.pk) + self.component.refresh_from_db() + self.assertEqual(self.component.maintenance_mode, initial_object_mm) + + def test_mm_not_changed_on_fail_host(self): + self.add_host_to_cluster(cluster=self.cluster, host=self.host) + self.add_hostcomponent_map( + cluster=self.cluster, + hc_map=[{"host_id": self.host.pk, "service_id": self.service.pk, "component_id": self.component.pk}], + ) + initial_object_mm = self.host.maintenance_mode + + response, run_task_mock = self._do_change_mm_request( + obj=self.host, failed_job=FailedJobInfo(position=0, return_code=1) + ) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.host.refresh_from_db() + self.assertEqual(self.host.maintenance_mode, MaintenanceMode.CHANGING) + self.assertIsNotNone(run_task_mock.target_task) + self.assertEqual(run_task_mock.target_task.action.name, "adcm_host_turn_on_maintenance_mode") + self.assertIsNotNone(run_task_mock.runner) + + run_task_mock.runner.run(task_id=run_task_mock.target_task.pk) + self.host.refresh_from_db() + self.assertEqual(self.host.maintenance_mode, initial_object_mm) diff --git a/python/cm/tests/mocks/task_runner.py b/python/cm/tests/mocks/task_runner.py index 95d23e8a8e..a20a3cbba0 100644 --- a/python/cm/tests/mocks/task_runner.py +++ b/python/cm/tests/mocks/task_runner.py @@ -30,15 +30,25 @@ class FakePopen(NamedTuple): pid: int +class FailedJobInfo(NamedTuple): + position: int + return_code: int + + # ExecutionTarget Factories class ExecutionTargetFactoryDummyMock(ExecutionTargetFactory): + def __init__(self, failed_job: FailedJobInfo | None = None): + super().__init__() + + self._failed_job = failed_job + def __call__( self, task: Task, jobs: Iterable[Job], configuration: ExternalSettings ) -> Generator[ExecutionTarget, None, None]: _ = task - for job in jobs: + for job_num, job in enumerate(jobs): work_dir = configuration.adcm.run_dir / str(job.id) if job.type == ScriptType.INTERNAL: @@ -47,8 +57,16 @@ def __call__( executor = InternalExecutorMock(config=ExecutorConfig(work_dir=work_dir), script=script) else: - executor = SuccessExecutorMock( - script_type=job.script, config=ExecutorConfig(work_dir=configuration.adcm.run_dir / str(job.id)) + executor_class = SuccessExecutorMock + executor_kwargs = {} + if self._failed_job is not None and job_num == self._failed_job.position: + executor_class = FailExecutorMock + executor_kwargs = {"return_code": self._failed_job.return_code} + + executor = executor_class( + script_type=job.script, + config=ExecutorConfig(work_dir=configuration.adcm.run_dir / str(job.id)), + **executor_kwargs, ) yield ExecutionTarget( @@ -59,8 +77,6 @@ def __call__( ) -DEFAULT_ETF_MOCK = ExecutionTargetFactoryDummyMock() - # Executors @@ -94,6 +110,20 @@ def script_type(self) -> str: return self._script_type +class FailExecutorMock(SuccessExecutorMock): + def __init__(self, return_code: int, **kwargs): + super().__init__(**kwargs) + + if return_code <= 0: + raise ValueError("Only positive integers allowed") + + self._return_code = return_code + + def wait_finished(self) -> Self: + self._result = ExecutionResult(code=self._return_code) + return self + + # Custom Mocks @@ -106,8 +136,11 @@ def now(self) -> datetime: return timezone.now() +_DEFAULT_ETF_MOCK = ExecutionTargetFactoryDummyMock() + + class RunTaskMock: - def __init__(self, execution_target_factory: ExecutionTargetFactoryI = DEFAULT_ETF_MOCK): + def __init__(self, execution_target_factory: ExecutionTargetFactoryI = _DEFAULT_ETF_MOCK): self.target_task: TaskLog | None = None self.runner: TaskRunner | None = None self._execution_target_factory = execution_target_factory From 4ba0bd024208e29abdffca1a3fbd74fe151164b3 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Wed, 27 Mar 2024 07:41:26 +0000 Subject: [PATCH 022/208] ADCM-5407 Fix `name` validation for `GroupConfig` on create --- python/api_v2/group_config/serializers.py | 8 +-- .../tests/bundles/cluster_one/config.yaml | 6 +- python/api_v2/tests/test_cluster.py | 6 +- python/api_v2/tests/test_component.py | 2 +- python/api_v2/tests/test_group_config.py | 65 +++++++++++++++++++ 5 files changed, 79 insertions(+), 8 deletions(-) diff --git a/python/api_v2/group_config/serializers.py b/python/api_v2/group_config/serializers.py index 88516842dc..f48a563af9 100644 --- a/python/api_v2/group_config/serializers.py +++ b/python/api_v2/group_config/serializers.py @@ -25,11 +25,11 @@ class Meta: fields = ["id", "name", "description", "hosts"] def validate_name(self, value): - model = self.context["view"].get_parent_object() - parent_content_type = ContentType.objects.get_for_model(model=model) - queryset = GroupConfig.objects.filter(name=value, object_type=parent_content_type) + object_ = self.context["view"].get_parent_object() + parent_content_type = ContentType.objects.get_for_model(model=object_) + queryset = GroupConfig.objects.filter(name=value, object_type=parent_content_type, object_id=object_.pk) if queryset.exists(): raise ValidationError( - f"Group config with name {value} already exists for {parent_content_type} {model.name}" + f"Group config with name {value} already exists for {parent_content_type} {object_.name}" ) return value diff --git a/python/api_v2/tests/bundles/cluster_one/config.yaml b/python/api_v2/tests/bundles/cluster_one/config.yaml index 8debd4841a..3206809ed2 100644 --- a/python/api_v2/tests/bundles/cluster_one/config.yaml +++ b/python/api_v2/tests/bundles/cluster_one/config.yaml @@ -94,7 +94,8 @@ export: - boolean -- name: service_1 +- &service_1 + name: service_1 type: service version: *version config: @@ -225,3 +226,6 @@ components: component: {} + +- <<: *service_1 + name: service_1_clone diff --git a/python/api_v2/tests/test_cluster.py b/python/api_v2/tests/test_cluster.py index 2ceb2676c8..818b5aa5e6 100644 --- a/python/api_v2/tests/test_cluster.py +++ b/python/api_v2/tests/test_cluster.py @@ -329,11 +329,12 @@ def test_service_prototypes_success(self): ) self.assertEqual(response.status_code, HTTP_200_OK) - self.assertEqual(len(response.json()), 6) + self.assertEqual(len(response.json()), 7) self.assertListEqual( [prototype["displayName"] for prototype in response.json()], [ "service_1", + "service_1_clone", "service_2", "service_3_manual_add", "service_4_save_config_without_required_field", @@ -350,11 +351,12 @@ def test_service_candidates_success(self): ) self.assertEqual(response.status_code, HTTP_200_OK) - self.assertEqual(len(response.json()), 5) + self.assertEqual(len(response.json()), 6) self.assertListEqual( [prototype["displayName"] for prototype in response.json()], [ "service_1", + "service_1_clone", "service_2", "service_4_save_config_without_required_field", "service_5_variant_type_without_values", diff --git a/python/api_v2/tests/test_component.py b/python/api_v2/tests/test_component.py index e9119762dc..1fada0bc90 100644 --- a/python/api_v2/tests/test_component.py +++ b/python/api_v2/tests/test_component.py @@ -31,7 +31,7 @@ def setUp(self) -> None: self.component_2_to_delete = ServiceComponent.objects.get( prototype__name="component_2", service=self.service_1, cluster=self.cluster_1 ) - self.action_1 = Action.objects.get(name="action_1_comp_1") + self.action_1 = Action.objects.get(name="action_1_comp_1", prototype=self.component_1.prototype) def test_list(self): response = self.client.get( diff --git a/python/api_v2/tests/test_group_config.py b/python/api_v2/tests/test_group_config.py index 86053d00ae..446236d4bf 100644 --- a/python/api_v2/tests/test_group_config.py +++ b/python/api_v2/tests/test_group_config.py @@ -79,6 +79,71 @@ def setUp(self) -> None: self.test_user = self.create_user(**self.test_user_credentials) +class TestGroupConfigNaming(BaseServiceGroupConfigTestCase): + def test_create_group_with_same_name_for_different_entities_of_same_type_success(self) -> None: + service_2 = self.add_services_to_cluster(service_names=["service_1_clone"], cluster=self.cluster_1).get() + component_of_service_2 = ServiceComponent.objects.get(service=service_2, prototype__name=self.component_1.name) + + with self.subTest("Cluster"): + self.assertEqual(GroupConfig.objects.filter(name=self.cluster_1_group_config.name).count(), 1) + + response = self.client.post( + path=reverse(viewname="v2:cluster-group-config-list", kwargs={"cluster_pk": self.cluster_2.pk}), + data={"name": self.cluster_1_group_config.name, "description": "group-config-new"}, + ) + + self.assertEqual(response.status_code, HTTP_201_CREATED) + self.assertEqual(GroupConfig.objects.filter(name=self.cluster_1_group_config.name).count(), 2) + + with self.subTest("Service"): + self.assertEqual(GroupConfig.objects.filter(name=self.service_1_group_config.name).count(), 1) + + response = self.client.post( + path=reverse( + viewname="v2:service-group-config-list", + kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": service_2.pk}, + ), + data={"name": self.service_1_group_config.name, "description": "group-config-new"}, + ) + + self.assertEqual(response.status_code, HTTP_201_CREATED) + self.assertEqual(GroupConfig.objects.filter(name=self.service_1_group_config.name).count(), 2) + + with self.subTest("Component"): + name = "component_group" + self.assertEqual(GroupConfig.objects.filter(name=name).count(), 0) + + response = self.client.post( + path=reverse( + viewname="v2:component-group-config-list", + kwargs={ + "cluster_pk": self.cluster_1.pk, + "service_pk": service_2.pk, + "component_pk": component_of_service_2.pk, + }, + ), + data={"name": name, "description": "group-config-new"}, + ) + + self.assertEqual(response.status_code, HTTP_201_CREATED) + self.assertEqual(GroupConfig.objects.filter(name=name).count(), 1) + + response = self.client.post( + path=reverse( + viewname="v2:component-group-config-list", + kwargs={ + "cluster_pk": self.cluster_1.pk, + "service_pk": self.service_1.pk, + "component_pk": self.component_1.pk, + }, + ), + data={"name": name, "description": "group-config-new"}, + ) + + self.assertEqual(response.status_code, HTTP_201_CREATED) + self.assertEqual(GroupConfig.objects.filter(name=name).count(), 2) + + class TestClusterGroupConfig(BaseClusterGroupConfigTestCase): def test_list_success(self): response = self.client.get( From 3113b1eb4a64d4adb03e96ca44cd92d01c35f58e Mon Sep 17 00:00:00 2001 From: Alexey Latunov Date: Wed, 27 Mar 2024 12:47:49 +0000 Subject: [PATCH 023/208] [UI] ADCM-5210: Fix search in Service configuration https://tracker.yandex.ru/ADCM-5210 --- .../ConfigurationTree.utils.test.ts | 48 +++++++++++++++++++ .../ConfigurationTree.utils.ts | 15 +++--- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.utils.test.ts b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.utils.test.ts index 8148a13b78..6d982a28c6 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.utils.test.ts +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.utils.test.ts @@ -387,6 +387,54 @@ describe('filter', () => { expect(clusterNameNode.key).toBe('/cluster_config/cluster/cluster_name'); expect(clusterNameNode.children).toBe(undefined); }); + + test('find in parent', () => { + const configuration = { + structure: { + someField1: 'value1', + someField2: 'value2', + }, + }; + const tree = buildConfigurationNodes(structureSchema, configuration, {}); + // eslint-disable-next-line spellcheck/spell-checker + const filteredTree = buildConfigurationTree(tree, { title: 'truct', showAdvanced: false, showInvisible: false }); + const structureNode = filteredTree.children?.[0]!; + + expect(structureNode.children?.length).toBe(2); + expect(structureNode.children?.[0].data.title).toBe('someField1'); + expect(structureNode.children?.[1].data.title).toBe('someField2'); + }); + + test('not find in parent', () => { + const configuration = { + structure: { + someField1: 'value1', + someField2: 'value2', + }, + }; + const tree = buildConfigurationNodes(structureSchema, configuration, {}); + // eslint-disable-next-line spellcheck/spell-checker + const filteredTree = buildConfigurationTree(tree, { title: 'blabla', showAdvanced: false, showInvisible: false }); + const structureNode = filteredTree.children?.[0]!; + + expect(structureNode).toBe(undefined); + }); + + test('find in children', () => { + const configuration = { + structure: { + someField1: 'value1', + someField2: 'value2', + }, + }; + const tree = buildConfigurationNodes(structureSchema, configuration, {}); + // eslint-disable-next-line spellcheck/spell-checker + const filteredTree = buildConfigurationTree(tree, { title: 'ld1', showAdvanced: false, showInvisible: false }); + const structureNode = filteredTree.children?.[0]!; + + expect(structureNode.children?.length).toBe(1); + expect(structureNode.children?.[0].data.title).toBe('someField1'); + }); }); describe('fillFieldAttributes', () => { diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.utils.ts b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.utils.ts index a5ebdc5e9f..aa515e2691 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.utils.ts +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.utils.ts @@ -456,15 +456,13 @@ export const buildConfigurationTree = ( if (rootNode.children) { const filteredChildren = []; for (const child of rootNode.children) { - const childNodeView = buildConfigurationTreeRecursively(child, filter); + const childNodeView = buildConfigurationTreeRecursively(child, filter, false); if (childNodeView) { filteredChildren.push(childNodeView); } } - if (filteredChildren.length) { - (rootNode as ConfigurationNodeView).children = filteredChildren; - } + (rootNode as ConfigurationNodeView).children = filteredChildren; } return rootNode; @@ -473,6 +471,7 @@ export const buildConfigurationTree = ( const buildConfigurationTreeRecursively = ( node: ConfigurationNode, filter: ConfigurationTreeFilter, + foundInParent: boolean, ): ConfigurationNodeView | undefined => { const treeNode = node as ConfigurationNodeView; @@ -484,10 +483,12 @@ const buildConfigurationTreeRecursively = ( return undefined; } + const foundInTitle = treeNode.data.title.toLowerCase().includes(filter.title.toLowerCase()); + const filteredChildren = []; if (node.children) { for (const child of node.children) { - const childNodeView = buildConfigurationTreeRecursively(child, filter); + const childNodeView = buildConfigurationTreeRecursively(child, filter, foundInTitle); if (childNodeView) { filteredChildren.push(childNodeView); } @@ -496,10 +497,8 @@ const buildConfigurationTreeRecursively = ( treeNode.children = filteredChildren.length ? filteredChildren : undefined; - const foundInTitle = treeNode.data.title.toLowerCase().includes(filter.title.toLowerCase()); const foundInChildren = Boolean(treeNode.children?.length); - - if (!foundInTitle && !foundInChildren) { + if (!(foundInParent || foundInTitle || foundInChildren)) { return undefined; } From 1454f900af67bda2c838560991b1b2c78b0307cb Mon Sep 17 00:00:00 2001 From: astarovo Date: Thu, 28 Mar 2024 08:57:00 +0300 Subject: [PATCH 024/208] ADCM-5372: [Backend] Block and unblock operation not fixated in audit.log --- python/api_v2/rbac/user/serializers.py | 10 ++++++++-- python/api_v2/tests/test_audit/test_user.py | 6 +++--- python/api_v2/tests/test_user.py | 12 ++++++++++++ python/audit/utils.py | 4 ++++ 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/python/api_v2/rbac/user/serializers.py b/python/api_v2/rbac/user/serializers.py index 4d668e6c21..04db713df1 100644 --- a/python/api_v2/rbac/user/serializers.py +++ b/python/api_v2/rbac/user/serializers.py @@ -88,7 +88,7 @@ class UserUpdateSerializer(ModelSerializer): last_name = RegexField(r"^[^\n]*$", max_length=150, allow_blank=True, required=False) email = EmailField(allow_blank=True, required=False) is_super_user = BooleanField(source="is_superuser", required=False) - groups = ListField(child=IntegerField(), required=False, allow_null=True) + groups = ListField(child=IntegerField(), required=False, allow_null=True, write_only=True) class Meta: model = User @@ -102,8 +102,14 @@ class UserCreateSerializer(UserUpdateSerializer): last_name = RegexField(r"^[^\n]*$", max_length=150, allow_blank=True, default="") email = EmailField(allow_blank=True, default="") is_super_user = BooleanField(source="is_superuser", default=False) - groups = ListField(child=IntegerField(), required=False, allow_null=True) + groups = ListField(child=IntegerField(), required=False, allow_null=True, write_only=True) class Meta: model = User fields = ["username", "password", "first_name", "last_name", "groups", "email", "is_super_user"] + + +class UserBlockStatusChangedSerializer(UserSerializer): + class Meta: + model = User + fields = ["status"] diff --git a/python/api_v2/tests/test_audit/test_user.py b/python/api_v2/tests/test_audit/test_user.py index 2af1e4c122..2298155a02 100644 --- a/python/api_v2/tests/test_audit/test_user.py +++ b/python/api_v2/tests/test_audit/test_user.py @@ -339,14 +339,14 @@ def test_user_unblock_not_exists_fail(self): ) def test_user_block_success(self): - response = self.client.post(path=reverse(viewname="v2:rbac:user-block", kwargs={"pk": self.blocked_user.pk})) + response = self.client.post(path=reverse(viewname="v2:rbac:user-block", kwargs={"pk": self.test_user.pk})) self.assertEqual(response.status_code, HTTP_200_OK) self.check_last_audit_record( - operation_name=f"{self.blocked_user.username} user blocked", + operation_name=f"{self.test_user.username} user blocked", operation_type="update", operation_result="success", - **self.prepare_audit_object_arguments(expected_object=self.blocked_user), + **self.prepare_audit_object_arguments(expected_object=self.test_user), user__username="admin", ) diff --git a/python/api_v2/tests/test_user.py b/python/api_v2/tests/test_user.py index 955a522544..12ba6734d3 100644 --- a/python/api_v2/tests/test_user.py +++ b/python/api_v2/tests/test_user.py @@ -32,6 +32,7 @@ import pytz from api_v2.rbac.user.constants import UserTypeChoices +from api_v2.rbac.user.serializers import UserCreateSerializer, UserUpdateSerializer from api_v2.tests.base import BaseAPITestCase @@ -246,6 +247,17 @@ def test_retrieve_success(self): self.assertEqual(response.json()["id"], user.pk) + def test_listfield_create_update_serializers_group_success(self): + user = self.create_user( + user_data={"username": "user", "password": "test_password1", "groups": [{"id": self.group.pk}]} + ) + + try: + UserUpdateSerializer().to_representation(user) + UserCreateSerializer().to_representation(user) + except TypeError: + self.fail("The exception is raised - ListField fails to represent list of Group objects") + def test_retrieve_not_found_fail(self): wrong_pk = self.get_non_existent_pk(model=User) diff --git a/python/audit/utils.py b/python/audit/utils.py index d7c1c318d2..ef00004e1e 100644 --- a/python/audit/utils.py +++ b/python/audit/utils.py @@ -30,6 +30,7 @@ ) from api_v2.host.serializers import HostAuditSerializer as HostAuditSerializerV2 from api_v2.host.serializers import HostChangeMaintenanceModeSerializer +from api_v2.rbac.user.serializers import UserBlockStatusChangedSerializer from api_v2.service.serializers import ( ServiceAuditSerializer as ServiceAuditSerializerV2, ) @@ -319,6 +320,9 @@ def _get_obj_changes_data(view: GenericAPIView | ModelViewSet) -> tuple[dict | N elif view.__class__.__name__ == "ComponentViewSet" and view.action == "maintenance_mode": serializer_class = ComponentAuditSerializerV2 pk = view.kwargs["pk"] + elif view.__class__.__name__ == "UserViewSet" and view.action in ("block", "unblock"): + serializer_class = UserBlockStatusChangedSerializer + pk = view.kwargs["pk"] if serializer_class: # for cases when get_queryset() raises error From ea9b2a410d6f8dc6bad3f89598a21c06a9df6f8e Mon Sep 17 00:00:00 2001 From: Artem Starovoitov Date: Thu, 28 Mar 2024 08:23:09 +0000 Subject: [PATCH 025/208] ADCM-5398: `/hostproviders` endpoints --- python/api_v2/hostprovider/views.py | 77 +++++++++++++++++++++++++++++ python/api_v2/tests/test_schema.py | 25 ++++++++++ 2 files changed, 102 insertions(+) create mode 100644 python/api_v2/tests/test_schema.py diff --git a/python/api_v2/hostprovider/views.py b/python/api_v2/hostprovider/views.py index 5693534f10..e29ff39006 100644 --- a/python/api_v2/hostprovider/views.py +++ b/python/api_v2/hostprovider/views.py @@ -16,10 +16,12 @@ from cm.models import HostProvider, ObjectType, Prototype from django.db.utils import IntegrityError from django_filters.rest_framework.backends import DjangoFilterBackend +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema, extend_schema_view from guardian.mixins import PermissionListMixin from rest_framework.response import Response from rest_framework.status import HTTP_201_CREATED, HTTP_204_NO_CONTENT +from api_v2.api_schema import ErrorSerializer from api_v2.config.utils import ConfigSchemaMixin from api_v2.hostprovider.filters import HostProviderFilter from api_v2.hostprovider.permissions import HostProviderPermissions @@ -30,6 +32,81 @@ from api_v2.views import CamelCaseReadOnlyModelViewSet +@extend_schema_view( + list=extend_schema( + operation_id="getHostproviders", + summary="GET hostproviders", + description="Get a list of ADCM hostproviders with information on them.", + parameters=[ + OpenApiParameter( + name="name", + required=False, + location=OpenApiParameter.QUERY, + description="Case insensitive and partial filter by hostprovider name.", + type=str, + ), + OpenApiParameter( + name="prototypeName", + required=False, + location=OpenApiParameter.QUERY, + description="Hostprovider prototype name.", + type=str, + ), + OpenApiParameter( + name="ordering", + required=False, + location=OpenApiParameter.QUERY, + description="Field to sort by. To sort in descending order, precede the attribute name with a '-'.", + type=str, + ), + ], + ), + create=extend_schema( + operation_id="postHostproviders", + summary="POST hostproviders", + description="Creation of a new ADCM hostprovider.", + responses={ + 201: HostProviderSerializer, + 403: ErrorSerializer, + 409: ErrorSerializer, + }, + ), + retrieve=extend_schema( + operation_id="getHostprovider", + summary="GET hostprovider", + description="Get information about a specific hostprovider.", + parameters=[ + OpenApiParameter( + name="hostproviderId", + required=True, + location=OpenApiParameter.QUERY, + description="Hostprovider id.", + type=int, + ), + ], + responses={200: HostProviderSerializer, 404: ErrorSerializer}, + ), + destroy=extend_schema( + operation_id="deleteHostprovider", + summary="DELETE hostprovider", + description="Delete a specific ADCM hostprovider.", + parameters=[ + OpenApiParameter( + name="hostproviderId", + required=True, + location=OpenApiParameter.QUERY, + description="Get information about a specific hostprovider.", + type=int, + ), + ], + responses={ + 200: OpenApiResponse(description="OK"), + 403: ErrorSerializer, + 404: ErrorSerializer, + 409: ErrorSerializer, + }, + ), +) class HostProviderViewSet(PermissionListMixin, ConfigSchemaMixin, CamelCaseReadOnlyModelViewSet): queryset = HostProvider.objects.select_related("prototype").order_by("name") serializer_class = HostProviderSerializer diff --git a/python/api_v2/tests/test_schema.py b/python/api_v2/tests/test_schema.py new file mode 100644 index 0000000000..642b8db16e --- /dev/null +++ b/python/api_v2/tests/test_schema.py @@ -0,0 +1,25 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from rest_framework.reverse import reverse +from rest_framework.status import HTTP_200_OK + + +def test_swagger_available(self): + response = self.client.get(path=reverse(viewname="v2:swagger-ui")) + + self.assertEqual(response.status_code, HTTP_200_OK) + + response = self.client.get(path=reverse(viewname="v2:schema")) + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertIn("openapi", response.data) From 1e643f971f99dc3ea1c4832c7aee24839108902e Mon Sep 17 00:00:00 2001 From: Egor Araslanov Date: Thu, 28 Mar 2024 16:02:24 +0500 Subject: [PATCH 026/208] ADCM-5442 Don't follow links when resolving bundle's filename --- python/cm/services/bundle.py | 16 +++++++-------- .../tests/files/files_with_symlinks/another | 1 + .../files/files_with_symlinks/another_link | 1 + .../files/files_with_symlinks/inside/another | 1 + .../files/files_with_symlinks/inside/backref | 1 + .../files/files_with_symlinks/inside/somefile | 1 + .../tests/files/files_with_symlinks/somefile | 1 + python/cm/tests/test_bundle.py | 20 +++++++++++++++++++ python/core/job/types.py | 2 ++ 9 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 python/cm/tests/files/files_with_symlinks/another create mode 120000 python/cm/tests/files/files_with_symlinks/another_link create mode 100644 python/cm/tests/files/files_with_symlinks/inside/another create mode 120000 python/cm/tests/files/files_with_symlinks/inside/backref create mode 100644 python/cm/tests/files/files_with_symlinks/inside/somefile create mode 120000 python/cm/tests/files/files_with_symlinks/somefile diff --git a/python/cm/services/bundle.py b/python/cm/services/bundle.py index 5350f00d1b..a8ce96eb3f 100644 --- a/python/cm/services/bundle.py +++ b/python/cm/services/bundle.py @@ -15,7 +15,7 @@ def detect_path_for_file_in_bundle(bundle_root: Path, config_yaml_dir: str | Path, file: str) -> Path: """ - Detect path to file within bundle directory + Detect path to file within bundle directory without symlink resolution :param bundle_root: Path to bundle root directory (like */adcm/data/bundle/somebundlehash/*) :param config_yaml_dir: Directory containing *config.yaml* file with definition @@ -30,28 +30,28 @@ def detect_path_for_file_in_bundle(bundle_root: Path, config_yaml_dir: str | Pat >>> from pathlib import Path >>> bundle_root_dir = Path("/adcm/data/bundle") / "bundle-hash" >>> this = detect_path_for_file_in_bundle - >>> str(this(bundle_root_dir, "", "./script.yaml")) == str((bundle_root_dir / "script.yaml").resolve()) + >>> str(this(bundle_root_dir, "", "./script.yaml")) == str(bundle_root_dir / "script.yaml") True >>> res = str(this(bundle_root_dir, ".", "./some/script.yaml")) - >>> exp = str((bundle_root_dir / "some" /"script.yaml").resolve()) + >>> exp = str(bundle_root_dir / "some" /"script.yaml") >>> res == exp True >>> str(this(bundle_root_dir, Path(""), "script.yaml")) == str((bundle_root_dir / "script.yaml").resolve()) True >>> res = str(this(bundle_root_dir, Path("inner"), "atroot/script.yaml")) - >>> exp = str((bundle_root_dir / "atroot" / "script.yaml").resolve()) + >>> exp = str(bundle_root_dir / "atroot" / "script.yaml") >>> res == exp True >>> res = str(this(bundle_root_dir, Path("inner"), "./script.yaml")) - >>> exp = str((bundle_root_dir / "inner" / "script.yaml").resolve()) + >>> exp = str(bundle_root_dir / "inner" / "script.yaml") >>> res == exp True >>> res = str(this(bundle_root_dir, Path("inner"), "./alongside/script.yaml")) - >>> exp = str((bundle_root_dir / "inner" / "alongside" / "script.yaml").resolve()) + >>> exp = str(bundle_root_dir / "inner" / "alongside" / "script.yaml") >>> res == exp True """ if file.startswith("./"): - return (bundle_root / config_yaml_dir / file).resolve() + return bundle_root / config_yaml_dir / file - return (bundle_root / file).resolve() + return bundle_root / file diff --git a/python/cm/tests/files/files_with_symlinks/another b/python/cm/tests/files/files_with_symlinks/another new file mode 100644 index 0000000000..3dfdc59b46 --- /dev/null +++ b/python/cm/tests/files/files_with_symlinks/another @@ -0,0 +1 @@ +something else diff --git a/python/cm/tests/files/files_with_symlinks/another_link b/python/cm/tests/files/files_with_symlinks/another_link new file mode 120000 index 0000000000..effacdf3ed --- /dev/null +++ b/python/cm/tests/files/files_with_symlinks/another_link @@ -0,0 +1 @@ +inside/somefile \ No newline at end of file diff --git a/python/cm/tests/files/files_with_symlinks/inside/another b/python/cm/tests/files/files_with_symlinks/inside/another new file mode 100644 index 0000000000..5240947387 --- /dev/null +++ b/python/cm/tests/files/files_with_symlinks/inside/another @@ -0,0 +1 @@ +inside something else \ No newline at end of file diff --git a/python/cm/tests/files/files_with_symlinks/inside/backref b/python/cm/tests/files/files_with_symlinks/inside/backref new file mode 120000 index 0000000000..f855df55e0 --- /dev/null +++ b/python/cm/tests/files/files_with_symlinks/inside/backref @@ -0,0 +1 @@ +../another \ No newline at end of file diff --git a/python/cm/tests/files/files_with_symlinks/inside/somefile b/python/cm/tests/files/files_with_symlinks/inside/somefile new file mode 100644 index 0000000000..6b584e8ece --- /dev/null +++ b/python/cm/tests/files/files_with_symlinks/inside/somefile @@ -0,0 +1 @@ +content \ No newline at end of file diff --git a/python/cm/tests/files/files_with_symlinks/somefile b/python/cm/tests/files/files_with_symlinks/somefile new file mode 120000 index 0000000000..effacdf3ed --- /dev/null +++ b/python/cm/tests/files/files_with_symlinks/somefile @@ -0,0 +1 @@ +inside/somefile \ No newline at end of file diff --git a/python/cm/tests/test_bundle.py b/python/cm/tests/test_bundle.py index e372a5354a..990b23be3d 100644 --- a/python/cm/tests/test_bundle.py +++ b/python/cm/tests/test_bundle.py @@ -30,6 +30,7 @@ from cm.bundle import delete_bundle from cm.errors import AdcmEx from cm.models import Bundle, ConfigLog, SubAction +from cm.services.bundle import detect_path_for_file_in_bundle from cm.tests.test_upgrade import ( cook_cluster, cook_cluster_bundle, @@ -44,6 +45,25 @@ def setUp(self) -> None: self.test_files_dir = self.base_dir / "python" / "cm" / "tests" / "files" + def test_path_resolution(self) -> None: + bundle_root = Path(__file__).parent / "files" / "files_with_symlinks" + inner_dir = Path("inside") + + result = detect_path_for_file_in_bundle(bundle_root=bundle_root, config_yaml_dir=Path(), file="somefile") + self.assertEqual(result, bundle_root / "somefile") + + result = detect_path_for_file_in_bundle(bundle_root=bundle_root, config_yaml_dir=inner_dir, file="./somefile") + self.assertEqual(result, bundle_root / "inside" / "somefile") + + result = detect_path_for_file_in_bundle(bundle_root=bundle_root, config_yaml_dir=inner_dir, file="./backref") + self.assertEqual(result, bundle_root / "inside" / "backref") + + result = detect_path_for_file_in_bundle(bundle_root=bundle_root, config_yaml_dir=Path(), file="inside/backref") + self.assertEqual(result, bundle_root / "inside" / "backref") + + result = detect_path_for_file_in_bundle(bundle_root=bundle_root, config_yaml_dir=Path(), file="./another_link") + self.assertEqual(result, bundle_root / "another_link") + def test_bundle_upload_duplicate_upgrade_fail(self): with self.assertRaises(IntegrityError): self.upload_and_load_bundle(path=Path(self.test_files_dir, "test_upgrade_duplicated.tar")) diff --git a/python/core/job/types.py b/python/core/job/types.py index 8810fe6221..9391213bf9 100644 --- a/python/core/job/types.py +++ b/python/core/job/types.py @@ -66,6 +66,8 @@ class BundleInfo(NamedTuple): # root is directory of bundle like /adcm/data/bundle/somehash root: Path # relative path to directory with `config.yaml` within `root` + # + # should point to directory with `config.yaml` where task owner is defined config_dir: Path From 03c5f81dc95f698f8216ff42831a19002be97979 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Thu, 28 Mar 2024 13:14:13 +0000 Subject: [PATCH 027/208] ADCM-5442 Fix `delta` detection bug during task launch when HC is changed during tark launch --- python/adcm/tests/base.py | 64 +++++++++------- .../cm/services/job/run/_target_factories.py | 2 +- python/cm/tests/mocks/task_runner.py | 62 ++++++++++++++-- python/cm/tests/test_task_runner/__init__.py | 12 +++ .../bundles/cluster/config.yaml | 23 ++++++ .../bundles/hostprovider/config.yaml | 8 ++ .../test_task_runner/test_plugin_effects.py | 74 +++++++++++++++++++ 7 files changed, 212 insertions(+), 33 deletions(-) create mode 100644 python/cm/tests/test_task_runner/__init__.py create mode 100644 python/cm/tests/test_task_runner/bundles/cluster/config.yaml create mode 100644 python/cm/tests/test_task_runner/bundles/hostprovider/config.yaml create mode 100644 python/cm/tests/test_task_runner/test_plugin_effects.py diff --git a/python/adcm/tests/base.py b/python/adcm/tests/base.py index 3ce4582740..c982f8fd0f 100644 --- a/python/adcm/tests/base.py +++ b/python/adcm/tests/base.py @@ -15,7 +15,7 @@ from pathlib import Path from shutil import rmtree from tempfile import mkdtemp -from typing import TypedDict +from typing import Iterable, TypedDict import random import string import tarfile @@ -118,31 +118,7 @@ def add_bundle(self, source_dir: Path) -> Bundle: return prepare_bundle(bundle_file=bundle_file, bundle_hash=bundle_hash, path=path) -class BaseTestCase(TestCase, ParallelReadyTestCase, BundleLogicMixin): - def setUp(self) -> None: - self.test_user_username = "test_user" - self.test_user_password = "test_user_password" - - self.test_user = User.objects.create_user( - username=self.test_user_username, - password=self.test_user_password, - is_superuser=True, - ) - self.test_user_group = Group.objects.create(name="simple_test_group") - self.test_user_group.user_set.add(self.test_user) - - self.no_rights_user_username = "no_rights_user" - self.no_rights_user_password = "no_rights_user_password" - self.no_rights_user = User.objects.create_user( - username="no_rights_user", - password="no_rights_user_password", - ) - self.no_rights_user_group = Group.objects.create(name="no_right_group") - self.no_rights_user_group.user_set.add(self.no_rights_user) - - self.client = Client(HTTP_USER_AGENT="Mozilla/5.0") - self.login() - +class TestCaseWithCommonSetUpTearDown(TestCase): @classmethod def setUpClass(cls): super().setUpClass() @@ -172,6 +148,32 @@ def tearDown(self) -> None: if item.name != ".gitkeep": item.unlink() + +class BaseTestCase(TestCaseWithCommonSetUpTearDown, ParallelReadyTestCase, BundleLogicMixin): + def setUp(self) -> None: + self.test_user_username = "test_user" + self.test_user_password = "test_user_password" + + self.test_user = User.objects.create_user( + username=self.test_user_username, + password=self.test_user_password, + is_superuser=True, + ) + self.test_user_group = Group.objects.create(name="simple_test_group") + self.test_user_group.user_set.add(self.test_user) + + self.no_rights_user_username = "no_rights_user" + self.no_rights_user_password = "no_rights_user_password" + self.no_rights_user = User.objects.create_user( + username="no_rights_user", + password="no_rights_user_password", + ) + self.no_rights_user_group = Group.objects.create(name="no_right_group") + self.no_rights_user_group.user_set.add(self.no_rights_user) + + self.client = Client(HTTP_USER_AGENT="Mozilla/5.0") + self.login() + def login(self): response: Response = self.client.post( path=reverse(viewname="v1:rbac:token"), @@ -478,6 +480,16 @@ def add_services_to_cluster(service_names: list[str], cluster: Cluster) -> Query def add_hostcomponent_map(cluster: Cluster, hc_map: list[HostComponentMapDictType]) -> list[HostComponent]: return add_hc(cluster=cluster, hc_in=hc_map) + @staticmethod + def set_hostcomponent(cluster: Cluster, entries: Iterable[tuple[Host, ServiceComponent]]) -> list[HostComponent]: + return add_hc( + cluster=cluster, + hc_in=[ + {"host_id": host.pk, "component_id": component.pk, "service_id": component.service_id} + for host, component in entries + ], + ) + @staticmethod def get_non_existent_pk(model: type[ADCMEntity | ADCMModel | User | Role | Group | Policy]): try: diff --git a/python/cm/services/job/run/_target_factories.py b/python/cm/services/job/run/_target_factories.py index 5b2e8835f8..e7dd7eb34f 100644 --- a/python/cm/services/job/run/_target_factories.py +++ b/python/cm/services/job/run/_target_factories.py @@ -223,7 +223,7 @@ def prepare_ansible_environment(task: Task, job: Job, configuration: ExternalSet def prepare_ansible_inventory(task: Task) -> dict[str, Any]: delta = {} - if task.hostcomponent.saved: + if task.action.hc_acl: cluster_id = None if task.owner: if task.owner.type == ADCMCoreType.CLUSTER: diff --git a/python/cm/tests/mocks/task_runner.py b/python/cm/tests/mocks/task_runner.py index a20a3cbba0..f8c3b01e9e 100644 --- a/python/cm/tests/mocks/task_runner.py +++ b/python/cm/tests/mocks/task_runner.py @@ -12,7 +12,7 @@ from abc import ABC from datetime import datetime from functools import partial -from typing import Callable, Generator, Iterable, NamedTuple +from typing import Any, Callable, Generator, Iterable, NamedTuple from unittest.mock import patch from core.job.executors import ExecutionResult, Executor, ExecutorConfig @@ -26,13 +26,22 @@ from cm.services.job.run._target_factories import ExecutionTargetFactory +def do_nothing(): + return None + + class FakePopen(NamedTuple): pid: int class FailedJobInfo(NamedTuple): position: int - return_code: int + return_code: int = 1 + + +class JobImitator(NamedTuple): + call: Callable[[], Any] = do_nothing + return_code: int = 0 # ExecutionTarget Factories @@ -77,11 +86,52 @@ def __call__( ) +class ETFMockWithEnvPreparation(ExecutionTargetFactory): + def __init__(self, change_jobs: dict[int, JobImitator] | None = None): + super().__init__() + + self.imitators = change_jobs or {} + self.default_imitator = JobImitator() + + def __call__( + self, task: Task, jobs: Iterable[Job], configuration: ExternalSettings + ) -> Generator[ExecutionTarget, None, None]: + for i, target in enumerate(super().__call__(task=task, jobs=jobs, configuration=configuration)): + if target.job.type == ScriptType.INTERNAL: + yield target + continue + + imitator = self.imitators.get(i, self.default_imitator) + common_kwargs = { + "script_type": target.executor.script_type, + "config": ExecutorConfig(work_dir=configuration.adcm.run_dir / str(target.job.id)), + "on_execute_": imitator.call, + } + executor = ( + SuccessExecutorMock(**common_kwargs) + if imitator.return_code == 0 + else FailExecutorMock(**common_kwargs, return_code=imitator.return_code) + ) + + yield ExecutionTarget( + job=target.job, + executor=executor, + environment_builders=target.environment_builders, + finalizers=target.finalizers, + ) + + # Executors class MockExecutor(Executor, ABC): + def __init__(self, *args, on_execute_: Callable[[], Any] = lambda: None, **kwargs): + super().__init__(*args, **kwargs) + + self._on_execute = on_execute_ + def execute(self) -> Self: + self._on_execute() return self def wait_finished(self) -> Self: @@ -101,8 +151,8 @@ def execute(self) -> Self: class SuccessExecutorMock(MockExecutor): - def __init__(self, script_type: str, **kwargs): - super().__init__(**kwargs) + def __init__(self, *args, script_type: str, **kwargs): + super().__init__(*args, **kwargs) self._script_type = script_type @property @@ -111,8 +161,8 @@ def script_type(self) -> str: class FailExecutorMock(SuccessExecutorMock): - def __init__(self, return_code: int, **kwargs): - super().__init__(**kwargs) + def __init__(self, *args, return_code: int, **kwargs): + super().__init__(*args, **kwargs) if return_code <= 0: raise ValueError("Only positive integers allowed") diff --git a/python/cm/tests/test_task_runner/__init__.py b/python/cm/tests/test_task_runner/__init__.py new file mode 100644 index 0000000000..f3e59d81d8 --- /dev/null +++ b/python/cm/tests/test_task_runner/__init__.py @@ -0,0 +1,12 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/python/cm/tests/test_task_runner/bundles/cluster/config.yaml b/python/cm/tests/test_task_runner/bundles/cluster/config.yaml new file mode 100644 index 0000000000..8b152ff18c --- /dev/null +++ b/python/cm/tests/test_task_runner/bundles/cluster/config.yaml @@ -0,0 +1,23 @@ +- type: cluster + name: for_task_runner_tests + version: 1.2 + + actions: + two_ansible_steps: + type: task + masking: + scripts: + - name: first + script_type: ansible + script: ./actions.yaml + - name: second + script_type: ansible + script: ./actions.yaml + +- type: service + name: simple + version: 1 + + components: + part_1: + part_2: diff --git a/python/cm/tests/test_task_runner/bundles/hostprovider/config.yaml b/python/cm/tests/test_task_runner/bundles/hostprovider/config.yaml new file mode 100644 index 0000000000..7b7dd08f92 --- /dev/null +++ b/python/cm/tests/test_task_runner/bundles/hostprovider/config.yaml @@ -0,0 +1,8 @@ +--- +- type: provider + name: provider + version: 1.0 + +- type: host + name: host + version: 1.0 diff --git a/python/cm/tests/test_task_runner/test_plugin_effects.py b/python/cm/tests/test_task_runner/test_plugin_effects.py new file mode 100644 index 0000000000..be1c3a6e2c --- /dev/null +++ b/python/cm/tests/test_task_runner/test_plugin_effects.py @@ -0,0 +1,74 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from pathlib import Path +import json + +from adcm.tests.base import BusinessLogicMixin, ParallelReadyTestCase, TestCaseWithCommonSetUpTearDown + +from cm.ansible_plugin import change_hc +from cm.models import Action, ServiceComponent +from cm.services.job.action import ActionRunPayload, run_action +from cm.tests.mocks.task_runner import ETFMockWithEnvPreparation, JobImitator, RunTaskMock + + +class TestEffectsOfADCMAnsiblePlugins(TestCaseWithCommonSetUpTearDown, ParallelReadyTestCase, BusinessLogicMixin): + def setUp(self) -> None: + super().setUp() + + self.bundles_dir = Path(__file__).parent / "bundles" + + self.cluster_bundle = self.add_bundle(self.bundles_dir / "cluster") + self.hostprovider_bundle = self.add_bundle(self.bundles_dir / "hostprovider") + + self.cluster = self.add_cluster(bundle=self.cluster_bundle, name="Just Cluster") + + self.hostprovider = self.add_provider(bundle=self.hostprovider_bundle, name="Just HP") + self.host_1 = self.add_host(bundle=self.hostprovider_bundle, provider=self.hostprovider, fqdn="host-1") + self.host_2 = self.add_host(bundle=self.hostprovider_bundle, provider=self.hostprovider, fqdn="host-2") + + def test_adcm_hc_should_not_cause_hc_acl_effect(self) -> None: + service = self.add_services_to_cluster(["simple"], cluster=self.cluster).first() + component_1, component_2 = ServiceComponent.objects.filter(service=service).all() + + self.add_host_to_cluster(cluster=self.cluster, host=self.host_1) + self.add_host_to_cluster(cluster=self.cluster, host=self.host_2) + + self.set_hostcomponent( + cluster=self.cluster, + entries=((self.host_1, component_1), (self.host_1, component_2), (self.host_2, component_1)), + ) + + operations = [ + {"action": "add", "service": service.name, "component": component_2.name, "host": self.host_2.name}, + {"action": "remove", "service": service.name, "component": component_1.name, "host": self.host_1.name}, + ] + + with RunTaskMock( + execution_target_factory=ETFMockWithEnvPreparation( + change_jobs={0: JobImitator(call=lambda: change_hc(1, self.cluster.id, operations))} + ) + ) as run_task: + run_action( + action=Action.objects.get(prototype=self.cluster.prototype, name="two_ansible_steps"), + obj=self.cluster, + payload=ActionRunPayload(), + ) + + self.assertIsNotNone(run_task.target_task) + run_task.runner.run(task_id=run_task.target_task.pk) + run_task.target_task.refresh_from_db() + self.assertEqual(run_task.target_task.status, "success") + for job_id in run_task.target_task.joblog_set.values_list("id", flat=True): + inventory = json.loads((self.directories["RUN_DIR"] / str(job_id) / "inventory.json").read_text()) + self.assertTrue( + all(".add" not in key and ".remove" not in key for key in map(str.lower, inventory["all"]["children"])) + ) From 23411120a37709338785014d3663026ce4e5cb29 Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Thu, 28 Mar 2024 13:18:03 +0000 Subject: [PATCH 028/208] ADCM-5420: refactor adcm_change_maintenance_mode ansible plugin, add tests --- .../ansible/plugins/action/adcm_add_host.py | 4 +- .../action/adcm_add_host_to_cluster.py | 4 +- .../plugins/action/adcm_change_flag.py | 2 +- .../action/adcm_change_maintenance_mode.py | 63 +++------- python/ansible/plugins/action/adcm_check.py | 2 +- python/ansible/plugins/action/adcm_config.py | 2 +- .../ansible/plugins/action/adcm_custom_log.py | 2 +- .../plugins/action/adcm_delete_host.py | 4 +- .../plugins/action/adcm_delete_service.py | 6 +- python/ansible/plugins/action/adcm_hc.py | 4 +- .../plugins/action/adcm_multi_state_set.py | 2 +- .../plugins/action/adcm_multi_state_unset.py | 2 +- .../action/adcm_remove_host_from_cluster.py | 4 +- python/ansible/plugins/action/adcm_state.py | 2 +- python/ansible/plugins/lookup/adcm_config.py | 2 +- python/ansible/plugins/lookup/adcm_state.py | 2 +- python/ansible_plugin/__init__.py | 12 ++ python/ansible_plugin/maintenance_mode.py | 70 +++++++++++ python/ansible_plugin/messages.py | 43 +++++++ .../utils.py} | 59 ++++----- python/api/job/serializers.py | 2 +- python/api_v2/log_storage/serializers.py | 2 +- python/cm/services/maintenance_mode.py | 8 ++ .../test_ansible_plugins/test_adcm_config.py | 2 +- .../test_maintenance_mode.py | 118 ++++++++++++++++++ python/job_runner.py | 2 +- 26 files changed, 317 insertions(+), 108 deletions(-) create mode 100644 python/ansible_plugin/__init__.py create mode 100644 python/ansible_plugin/maintenance_mode.py create mode 100644 python/ansible_plugin/messages.py rename python/{cm/ansible_plugin.py => ansible_plugin/utils.py} (92%) create mode 100644 python/cm/tests/test_ansible_plugins/test_maintenance_mode.py diff --git a/python/ansible/plugins/action/adcm_add_host.py b/python/ansible/plugins/action/adcm_add_host.py index 853abcf694..f434dcd6e0 100644 --- a/python/ansible/plugins/action/adcm_add_host.py +++ b/python/ansible/plugins/action/adcm_add_host.py @@ -51,7 +51,7 @@ sys.path.append("/adcm/python") import adcm.init_django # noqa: F401, isort:skip -from cm.ansible_plugin import get_object_id_from_context +from ansible_plugin.utils import get_object_id_from_context from cm.api import add_host from cm.errors import AdcmEx from cm.logger import logger @@ -65,7 +65,7 @@ class ActionModule(ActionBase): def run(self, tmp=None, task_vars=None): super().run(tmp, task_vars) - provider_pk = get_object_id_from_context( + provider_pk, _ = get_object_id_from_context( task_vars=task_vars, id_type="provider_id", context_types=("provider",), diff --git a/python/ansible/plugins/action/adcm_add_host_to_cluster.py b/python/ansible/plugins/action/adcm_add_host_to_cluster.py index 68e623245f..22678d1740 100644 --- a/python/ansible/plugins/action/adcm_add_host_to_cluster.py +++ b/python/ansible/plugins/action/adcm_add_host_to_cluster.py @@ -50,7 +50,7 @@ import adcm.init_django # noqa: F401, isort:skip -from cm.ansible_plugin import get_object_id_from_context +from ansible_plugin.utils import get_object_id_from_context from cm.errors import AdcmEx from cm.logger import logger import cm.api @@ -63,7 +63,7 @@ class ActionModule(ActionBase): def run(self, tmp=None, task_vars=None): super().run(tmp, task_vars) msg = "You can add host only in cluster or service context" - cluster_id = get_object_id_from_context( + cluster_id, _ = get_object_id_from_context( task_vars=task_vars, id_type="cluster_id", context_types=("cluster", "service"), err_msg=msg ) fqdn = self._task.args.get("fqdn", None) diff --git a/python/ansible/plugins/action/adcm_change_flag.py b/python/ansible/plugins/action/adcm_change_flag.py index b3d92448ab..e4c6f17304 100644 --- a/python/ansible/plugins/action/adcm_change_flag.py +++ b/python/ansible/plugins/action/adcm_change_flag.py @@ -71,7 +71,7 @@ import adcm.init_django # noqa: F401, isort:skip -from cm.ansible_plugin import check_context_type, get_context_object +from ansible_plugin.utils import check_context_type, get_context_object from cm.flag import remove_flag, update_object_flag from cm.logger import logger from cm.models import ( diff --git a/python/ansible/plugins/action/adcm_change_maintenance_mode.py b/python/ansible/plugins/action/adcm_change_maintenance_mode.py index ddc6d6b528..fa162a19f0 100644 --- a/python/ansible/plugins/action/adcm_change_maintenance_mode.py +++ b/python/ansible/plugins/action/adcm_change_maintenance_mode.py @@ -51,11 +51,9 @@ import adcm.init_django # noqa: F401, isort:skip -from cm.ansible_plugin import get_object_id_from_context -from cm.issue import update_hierarchy_issues -from cm.models import ClusterObject, Host, ServiceComponent -from cm.services.status.notify import reset_objects_in_mm -from cm.status_api import send_object_update_event +from ansible_plugin.maintenance_mode import get_object, validate_args, validate_obj +from cm.models import MaintenanceMode +from cm.services.maintenance_mode import set_maintenance_mode class ActionModule(ActionBase): @@ -65,49 +63,22 @@ class ActionModule(ActionBase): def run(self, tmp=None, task_vars=None): super().run(tmp, task_vars) - type_class_map = { - "host": Host, - "service": ClusterObject, - "component": ServiceComponent, - } - type_choices = set(type_class_map.keys()) + error = validate_args(task_args=self._task.args) + if error is not None: + raise error - if not self._task.args.get("type"): - raise AnsibleActionFail('"type" option is required') + obj, error = get_object(task_vars=task_vars, obj_type=self._task.args["type"]) + if error is not None: + raise error - if self._task.args.get("value") is None: - raise AnsibleActionFail('"value" option is required') + error = validate_obj(obj=obj) + if error is not None: + raise error - if self._task.args["type"] not in type_choices: - raise AnsibleActionFail(f'"type" should be one of {type_choices}') - - if not isinstance(self._task.args["value"], bool): - raise AnsibleActionFail('"value" should be boolean') - - obj_type = self._task.args["type"] - context_type = obj_type - if obj_type == "host": - context_type = "cluster" - - obj_value = "on" if self._task.args["value"] else "off" - obj_pk = get_object_id_from_context( - task_vars=task_vars, - id_type=f"{obj_type}_id", - context_types=(context_type,), - err_msg=f'You can change "{obj_type}" maintenance mode only in {context_type} context', - ) - - obj = type_class_map[obj_type].objects.filter(pk=obj_pk).first() - if not obj: - raise AnsibleActionFail(f'Object of type "{obj_type}" with PK "{obj_pk}" does not exist') - - if obj.maintenance_mode != "changing": - raise AnsibleActionFail('Only "changing" state of object maintenance mode can be changed') - - obj.maintenance_mode = obj_value - obj.save() - send_object_update_event(object_=obj, changes={"maintenanceMode": obj.maintenance_mode}) - update_hierarchy_issues(obj.cluster) - reset_objects_in_mm() + value = MaintenanceMode.ON if self._task.args["value"] else MaintenanceMode.OFF + try: + set_maintenance_mode(obj=obj, value=value) + except Exception as e: # noqa: BLE001 + raise AnsibleActionFail("Unexpected error occurred while changing object's maintenance mode") from e return {"failed": False, "changed": True} diff --git a/python/ansible/plugins/action/adcm_check.py b/python/ansible/plugins/action/adcm_check.py index aed05ac31d..1e72483263 100644 --- a/python/ansible/plugins/action/adcm_check.py +++ b/python/ansible/plugins/action/adcm_check.py @@ -99,7 +99,7 @@ import adcm.init_django # noqa: F401, isort:skip -from cm.ansible_plugin import create_checklog_object +from ansible_plugin.utils import create_checklog_object from cm.errors import AdcmEx from cm.logger import logger diff --git a/python/ansible/plugins/action/adcm_config.py b/python/ansible/plugins/action/adcm_config.py index 7eca12452e..135edaa19a 100644 --- a/python/ansible/plugins/action/adcm_config.py +++ b/python/ansible/plugins/action/adcm_config.py @@ -19,7 +19,7 @@ import adcm.init_django # noqa: F401, isort:skip -from cm.ansible_plugin import ( +from ansible_plugin.utils import ( ContextActionModule, set_cluster_config, set_component_config, diff --git a/python/ansible/plugins/action/adcm_custom_log.py b/python/ansible/plugins/action/adcm_custom_log.py index 658511bb33..55350a21b0 100644 --- a/python/ansible/plugins/action/adcm_custom_log.py +++ b/python/ansible/plugins/action/adcm_custom_log.py @@ -67,7 +67,7 @@ import adcm.init_django # noqa: F401, isort:skip -from cm.ansible_plugin import create_custom_log +from ansible_plugin.utils import create_custom_log from cm.errors import AdcmEx from cm.logger import logger diff --git a/python/ansible/plugins/action/adcm_delete_host.py b/python/ansible/plugins/action/adcm_delete_host.py index e3e16488ab..810cd1cd0e 100644 --- a/python/ansible/plugins/action/adcm_delete_host.py +++ b/python/ansible/plugins/action/adcm_delete_host.py @@ -41,7 +41,7 @@ import adcm.init_django # noqa: F401, isort:skip -from cm.ansible_plugin import get_object_id_from_context +from ansible_plugin.utils import get_object_id_from_context from cm.errors import AdcmEx from cm.logger import logger import cm.api @@ -54,7 +54,7 @@ class ActionModule(ActionBase): def run(self, tmp=None, task_vars=None): super().run(tmp, task_vars) msg = "You can delete host only in host context" - host_id = get_object_id_from_context( + host_id, _ = get_object_id_from_context( task_vars=task_vars, id_type="host_id", context_types=("host",), err_msg=msg ) logger.info("ansible module adcm_delete_host: host #%s", host_id) diff --git a/python/ansible/plugins/action/adcm_delete_service.py b/python/ansible/plugins/action/adcm_delete_service.py index 57c1b6a9fb..d11081bd39 100644 --- a/python/ansible/plugins/action/adcm_delete_service.py +++ b/python/ansible/plugins/action/adcm_delete_service.py @@ -41,7 +41,7 @@ import adcm.init_django # noqa: F401, isort:skip -from cm.ansible_plugin import get_object_id_from_context +from ansible_plugin.utils import get_object_id_from_context from cm.errors import AdcmEx from cm.logger import logger import cm.api @@ -56,7 +56,7 @@ def run(self, tmp=None, task_vars=None): service = self._task.args.get("service", None) if service: msg = "You can delete service by name only in cluster context" - cluster_id = get_object_id_from_context( + cluster_id, _ = get_object_id_from_context( task_vars=task_vars, id_type="cluster_id", context_types=("cluster",), err_msg=msg ) logger.info('ansible module adcm_delete_service: service "%s"', service) @@ -66,7 +66,7 @@ def run(self, tmp=None, task_vars=None): raise AnsibleError(e.code + ":" + e.msg) from e else: msg = "You can delete service only in service context" - service_id = get_object_id_from_context( + service_id, _ = get_object_id_from_context( task_vars=task_vars, id_type="service_id", context_types=("service",), err_msg=msg ) logger.info("ansible module adcm_delete_service: service #%s", service_id) diff --git a/python/ansible/plugins/action/adcm_hc.py b/python/ansible/plugins/action/adcm_hc.py index 9c1dd21242..1824f903fc 100644 --- a/python/ansible/plugins/action/adcm_hc.py +++ b/python/ansible/plugins/action/adcm_hc.py @@ -53,7 +53,7 @@ import adcm.init_django # noqa: F401, isort:skip -from cm.ansible_plugin import change_hc, get_object_id_from_context +from ansible_plugin.utils import change_hc, get_object_id_from_context from cm.errors import AdcmEx from cm.logger import logger @@ -66,7 +66,7 @@ class ActionModule(ActionBase): def run(self, tmp=None, task_vars=None): super().run(tmp, task_vars) msg = "You can modify hc only in cluster, service or component context" - cluster_id = get_object_id_from_context( + cluster_id, _ = get_object_id_from_context( task_vars=task_vars, id_type="cluster_id", context_types=("cluster", "service", "component"), err_msg=msg ) job_id = task_vars["job"]["id"] diff --git a/python/ansible/plugins/action/adcm_multi_state_set.py b/python/ansible/plugins/action/adcm_multi_state_set.py index 21a8a7cc37..195e5aa621 100644 --- a/python/ansible/plugins/action/adcm_multi_state_set.py +++ b/python/ansible/plugins/action/adcm_multi_state_set.py @@ -17,7 +17,7 @@ import adcm.init_django # noqa: F401, isort:skip -from cm.ansible_plugin import ( +from ansible_plugin.utils import ( ContextActionModule, set_cluster_multi_state, set_component_multi_state, diff --git a/python/ansible/plugins/action/adcm_multi_state_unset.py b/python/ansible/plugins/action/adcm_multi_state_unset.py index cde0381493..5ff35bb38a 100644 --- a/python/ansible/plugins/action/adcm_multi_state_unset.py +++ b/python/ansible/plugins/action/adcm_multi_state_unset.py @@ -17,7 +17,7 @@ import adcm.init_django # noqa: F401, isort:skip -from cm.ansible_plugin import ( +from ansible_plugin.utils import ( ContextActionModule, unset_cluster_multi_state, unset_component_multi_state, diff --git a/python/ansible/plugins/action/adcm_remove_host_from_cluster.py b/python/ansible/plugins/action/adcm_remove_host_from_cluster.py index dbfe661e42..1feca9f609 100644 --- a/python/ansible/plugins/action/adcm_remove_host_from_cluster.py +++ b/python/ansible/plugins/action/adcm_remove_host_from_cluster.py @@ -51,7 +51,7 @@ import adcm.init_django # noqa: F401, isort:skip -from cm.ansible_plugin import get_object_id_from_context +from ansible_plugin.utils import get_object_id_from_context from cm.errors import AdcmEx from cm.logger import logger import cm.api @@ -64,7 +64,7 @@ class ActionModule(ActionBase): def run(self, tmp=None, task_vars=None): super().run(tmp, task_vars) msg = "You can remove host only in cluster or service context" - cluster_id = get_object_id_from_context( + cluster_id, _ = get_object_id_from_context( task_vars=task_vars, id_type="cluster_id", context_types=("cluster", "service"), err_msg=msg ) fqdn = self._task.args.get("fqdn", None) diff --git a/python/ansible/plugins/action/adcm_state.py b/python/ansible/plugins/action/adcm_state.py index a9708d2e0d..cc810ee7af 100644 --- a/python/ansible/plugins/action/adcm_state.py +++ b/python/ansible/plugins/action/adcm_state.py @@ -17,7 +17,7 @@ import adcm.init_django # noqa: F401, isort:skip -from cm.ansible_plugin import ( +from ansible_plugin.utils import ( ContextActionModule, set_cluster_state, set_component_state, diff --git a/python/ansible/plugins/lookup/adcm_config.py b/python/ansible/plugins/lookup/adcm_config.py index 9bccda2a47..8ee99bc10c 100644 --- a/python/ansible/plugins/lookup/adcm_config.py +++ b/python/ansible/plugins/lookup/adcm_config.py @@ -19,7 +19,7 @@ import adcm.init_django # noqa: F401, isort:skip -from cm.ansible_plugin import ( +from ansible_plugin.utils import ( set_cluster_config, set_host_config, set_provider_config, diff --git a/python/ansible/plugins/lookup/adcm_state.py b/python/ansible/plugins/lookup/adcm_state.py index 957940f139..360cb53063 100644 --- a/python/ansible/plugins/lookup/adcm_state.py +++ b/python/ansible/plugins/lookup/adcm_state.py @@ -19,7 +19,7 @@ import adcm.init_django # noqa: F401, isort:skip -from cm.ansible_plugin import ( +from ansible_plugin.utils import ( set_cluster_state, set_host_state, set_provider_state, diff --git a/python/ansible_plugin/__init__.py b/python/ansible_plugin/__init__.py new file mode 100644 index 0000000000..f3e59d81d8 --- /dev/null +++ b/python/ansible_plugin/__init__.py @@ -0,0 +1,12 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/python/ansible_plugin/maintenance_mode.py b/python/ansible_plugin/maintenance_mode.py new file mode 100644 index 0000000000..5bb62ffd9c --- /dev/null +++ b/python/ansible_plugin/maintenance_mode.py @@ -0,0 +1,70 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Literal + +from ansible.errors import AnsibleActionFail, AnsibleError +from cm.models import ClusterObject, Host, MaintenanceMode, ServiceComponent +from pydantic import BaseModel, ValidationError + +from ansible_plugin.utils import get_object_id_from_context + +TYPE_CLASS_MAP = { + "host": Host, + "service": ClusterObject, + "component": ServiceComponent, +} + + +class TaskArgs(BaseModel): + type: Literal["host", "service", "component"] + value: bool + + class Config: + frozen = True + + +def validate_args(task_args: dict) -> AnsibleActionFail | None: + try: + TaskArgs(**task_args) + except ValidationError as e: + return AnsibleActionFail(str(e)) + + +def validate_obj(obj: Host | ClusterObject | ServiceComponent) -> AnsibleActionFail | None: + if obj.maintenance_mode != MaintenanceMode.CHANGING: + return AnsibleActionFail(f'Only "{MaintenanceMode.CHANGING}" state of object maintenance mode can be changed') + + +def get_object( + task_vars: dict, obj_type: Literal["host", "service", "component"] +) -> tuple[Host | ClusterObject | ServiceComponent | None, None | AnsibleError]: + context_type = obj_type + if obj_type == "host": + context_type = "cluster" + + obj_pk, error = get_object_id_from_context( + task_vars=task_vars, + id_type=f"{obj_type}_id", + context_types=(context_type,), + err_msg=f'You can change "{obj_type}" maintenance mode only in {context_type} context', + raise_=False, + ) + if error: + return None, error + + obj_qs = TYPE_CLASS_MAP[obj_type].objects.filter(pk=obj_pk) + + if obj_qs.exists(): + return obj_qs.get(), None + + return None, AnsibleActionFail(f'Object of type "{obj_type}" with PK "{obj_pk}" does not exist') diff --git a/python/ansible_plugin/messages.py b/python/ansible_plugin/messages.py new file mode 100644 index 0000000000..9feaa9ea11 --- /dev/null +++ b/python/ansible_plugin/messages.py @@ -0,0 +1,43 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +MSG_NO_CONFIG = ( + "There are no job related vars in inventory. It's mandatory for that module to have some" + " info from context. During normal execution it runs with inventory and config.yaml generated" + " by ADCM. Did you forget to pass them during debug?" +) +MSG_NO_CONTEXT = ( + "There are no context variable in job related vars in inventory. It's mandatory for that " + "module to have some info from context. During normal execution it runs with inventory and " + "config.yaml generated by ADCM. Did you forget to pass them during debug?" +) +MSG_WRONG_CONTEXT = 'Wrong context. Should be "{}", not "{}"' +MSG_WRONG_CONTEXT_ID = 'Wrong context. There are no "{}" in context' +MSG_NO_CLUSTER_CONTEXT = ( + "You are trying to change cluster state outside of cluster context. Cluster state can be " + "changed in cluster's, service's or component's actions only" +) +MSG_NO_CLUSTER_CONTEXT2 = ( + "You are trying to change service state outside of cluster context. Service state can be" + " changed by service_name in cluster's actions only" +) +MSG_NO_SERVICE_CONTEXT = ( + "You are trying to change unnamed service's state outside of service context." + " Service state can be changed in service's actions only or in cluster's actions but" + " with using service_name arg" +) +MSG_MANDATORY_ARGS = "Arguments {} are mandatory" +MSG_NO_ROUTE = "Incorrect combination of args" +MSG_NO_SERVICE_NAME = "You must specify service name in arguments." +MSG_NO_MULTI_STATE_TO_DELETE = ( + "You try to delete absent multi_state. You should define missing_ok as True or choose an existing multi_state" +) diff --git a/python/cm/ansible_plugin.py b/python/ansible_plugin/utils.py similarity index 92% rename from python/cm/ansible_plugin.py rename to python/ansible_plugin/utils.py index 5281fa264b..f8d4f1d03b 100644 --- a/python/cm/ansible_plugin.py +++ b/python/ansible_plugin/utils.py @@ -23,6 +23,17 @@ from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType +from ansible_plugin.messages import ( + MSG_NO_CONFIG, + MSG_NO_CONTEXT, + MSG_WRONG_CONTEXT, + MSG_WRONG_CONTEXT_ID, + MSG_NO_CLUSTER_CONTEXT, + MSG_MANDATORY_ARGS, + MSG_NO_ROUTE, + MSG_NO_SERVICE_NAME, + MSG_NO_MULTI_STATE_TO_DELETE, +) from cm.adcm_config.ansible import ansible_decrypt from cm.adcm_config.config import get_option_value from cm.api import add_hc, get_hc, set_object_config_with_plugin @@ -50,38 +61,6 @@ from rbac.roles import assign_group_perm # isort: on -MSG_NO_CONFIG = ( - "There are no job related vars in inventory. It's mandatory for that module to have some" - " info from context. During normal execution it runs with inventory and config.yaml generated" - " by ADCM. Did you forget to pass them during debug?" -) -MSG_NO_CONTEXT = ( - "There are no context variable in job related vars in inventory. It's mandatory for that " - "module to have some info from context. During normal execution it runs with inventory and " - "config.yaml generated by ADCM. Did you forget to pass them during debug?" -) -MSG_WRONG_CONTEXT = 'Wrong context. Should be "{}", not "{}"' -MSG_WRONG_CONTEXT_ID = 'Wrong context. There are no "{}" in context' -MSG_NO_CLUSTER_CONTEXT = ( - "You are trying to change cluster state outside of cluster context. Cluster state can be " - "changed in cluster's, service's or component's actions only" -) -MSG_NO_CLUSTER_CONTEXT2 = ( - "You are trying to change service state outside of cluster context. Service state can be" - " changed by service_name in cluster's actions only" -) -MSG_NO_SERVICE_CONTEXT = ( - "You are trying to change unnamed service's state outside of service context." - " Service state can be changed in service's actions only or in cluster's actions but" - " with using service_name arg" -) -MSG_MANDATORY_ARGS = "Arguments {} are mandatory" -MSG_NO_ROUTE = "Incorrect combination of args" -MSG_NO_SERVICE_NAME = "You must specify service name in arguments." -MSG_NO_MULTI_STATE_TO_DELETE = ( - "You try to delete absent multi_state. You should define missing_ok as True or choose an existing multi_state" -) - def job_lock(job_id): file_descriptor = open( # noqa: SIM115 @@ -117,21 +96,29 @@ def check_context_type(task_vars: dict, context_types: tuple, err_msg: str | Non raise AnsibleError(err_msg) -def get_object_id_from_context(task_vars: dict, id_type: str, context_types: tuple, err_msg: str | None = None) -> int: +def get_object_id_from_context( + task_vars: dict, id_type: str, context_types: tuple, err_msg: str | None = None, raise_: bool = True +) -> tuple[int | None, None | AnsibleError]: """ Get object id from context. """ check_context_type(task_vars=task_vars, context_types=context_types, err_msg=err_msg) context = task_vars["context"] + if id_type not in context: - raise AnsibleError(MSG_WRONG_CONTEXT_ID.format(id_type)) - return context[id_type] + error = AnsibleError(MSG_WRONG_CONTEXT_ID.format(id_type)) + if raise_: + raise error + + return None, error + + return context[id_type], None def get_context_object(task_vars: dict, err_msg: str = None) -> ADCMEntity: obj_type = task_vars["context"]["type"] - obj_pk = get_object_id_from_context( + obj_pk, _ = get_object_id_from_context( task_vars=task_vars, id_type=f"{obj_type}_id", context_types=(obj_type,), err_msg=err_msg ) obj = get_model_by_type(object_type=obj_type).objects.filter(pk=obj_pk).first() diff --git a/python/api/job/serializers.py b/python/api/job/serializers.py index e681da1296..75bd7af7b9 100644 --- a/python/api/job/serializers.py +++ b/python/api/job/serializers.py @@ -13,7 +13,7 @@ from pathlib import Path import json -from cm.ansible_plugin import get_checklogs_data_by_job_id +from ansible_plugin.utils import get_checklogs_data_by_job_id from cm.job import ActionRunPayload, run_action from cm.models import JobLog, JobStatus, LogStorage, TaskLog from django.conf import settings diff --git a/python/api_v2/log_storage/serializers.py b/python/api_v2/log_storage/serializers.py index eda3267c7c..fe3767d2dc 100644 --- a/python/api_v2/log_storage/serializers.py +++ b/python/api_v2/log_storage/serializers.py @@ -14,7 +14,7 @@ import json from adcm import settings -from cm.ansible_plugin import get_checklogs_data_by_job_id +from ansible_plugin.utils import get_checklogs_data_by_job_id from cm.log import extract_log_content_from_fs from cm.models import LogStorage from rest_framework.fields import SerializerMethodField diff --git a/python/cm/services/maintenance_mode.py b/python/cm/services/maintenance_mode.py index 8f44aceb96..24d2313381 100644 --- a/python/cm/services/maintenance_mode.py +++ b/python/cm/services/maintenance_mode.py @@ -164,3 +164,11 @@ def get_maintenance_mode_response( data={"error": f'Unknown {obj_name} maintenance mode "{obj.maintenance_mode}"'}, status=HTTP_400_BAD_REQUEST, ) + + +def set_maintenance_mode(obj: ClusterObject | ServiceComponent | Host, value: MaintenanceMode) -> None: + obj.maintenance_mode = value + obj.save(update_fields=["maintenance_mode"] if isinstance(obj, Host) else ["_maintenance_mode"]) + send_object_update_event(object_=obj, changes={"maintenanceMode": obj.maintenance_mode}) + update_hierarchy_issues(obj.cluster) + reset_objects_in_mm() diff --git a/python/cm/tests/test_ansible_plugins/test_adcm_config.py b/python/cm/tests/test_ansible_plugins/test_adcm_config.py index 06b54f7cec..dc03f93fa6 100644 --- a/python/cm/tests/test_ansible_plugins/test_adcm_config.py +++ b/python/cm/tests/test_ansible_plugins/test_adcm_config.py @@ -13,9 +13,9 @@ from pathlib import Path from adcm.tests.base import BaseTestCase, BusinessLogicMixin +from ansible_plugin.utils import set_cluster_config, set_provider_config from cm.adcm_config.ansible import ansible_decrypt -from cm.ansible_plugin import set_cluster_config, set_provider_config from cm.models import ConfigLog diff --git a/python/cm/tests/test_ansible_plugins/test_maintenance_mode.py b/python/cm/tests/test_ansible_plugins/test_maintenance_mode.py new file mode 100644 index 0000000000..75a8c0a496 --- /dev/null +++ b/python/cm/tests/test_ansible_plugins/test_maintenance_mode.py @@ -0,0 +1,118 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from itertools import chain, product +from pathlib import Path + +from adcm.tests.base import BaseTestCase, BusinessLogicMixin +from ansible_plugin.maintenance_mode import TYPE_CLASS_MAP, get_object, validate_args, validate_obj + +from cm.models import MaintenanceMode + + +class TestMaintenanceModePlugin(BusinessLogicMixin, BaseTestCase): + def setUp(self) -> None: + self.client.login(username="admin", password="admin") + bundles_dir = Path(__file__).parent.parent / "bundles" + + cluster_bundle = self.add_bundle(source_dir=bundles_dir / "cluster_1") + self.cluster = self.add_cluster(bundle=cluster_bundle, name="test_cluster") + self.service = self.add_services_to_cluster(service_names=["service_one_component"], cluster=self.cluster).get() + self.component = self.service.servicecomponent_set.get(prototype__name="component_1") + + provider_bundle = self.add_bundle(source_dir=bundles_dir / "provider") + provider = self.add_provider(bundle=provider_bundle, name="test_provider") + self.host = self.add_host(bundle=provider_bundle, provider=provider, fqdn="test_host", cluster=self.cluster) + self.add_hostcomponent_map( + cluster=self.cluster, + hc_map=[ + { + "host_id": self.host.pk, + "service_id": self.service.pk, + "component_id": self.component.pk, + } + ], + ) + + def _set_objects_mm( + self, + host: MaintenanceMode = MaintenanceMode.OFF, + service: MaintenanceMode = MaintenanceMode.OFF, + component: MaintenanceMode = MaintenanceMode.OFF, + ) -> None: + for obj, mm_value in zip((self.host, self.service, self.component), (host, service, component)): + obj.maintenance_mode = mm_value + obj.save() + + def test_task_args_validation(self): + correct_types = tuple(TYPE_CLASS_MAP.keys()) + correct_values = (True, False) + wrong_types = ("cluster", "provider", "some_string", 1.3, None) + wrong_values = (8, None, []) + + for assert_func, type_value_pairs in ( + (self.assertIsNone, product(correct_types, correct_values)), + ( + self.assertIsNotNone, + chain( + product(wrong_types, wrong_values), + product(wrong_types, correct_values), + product(correct_types, wrong_values), + ), + ), + ): + for type_, value_ in type_value_pairs: + args = {"type": type_, "value": value_} + with self.subTest(args): + error = validate_args(task_args=args) + assert_func(error) + + def test_object_validation(self): + correct_values = (MaintenanceMode.CHANGING,) + wrong_values = (MaintenanceMode.ON, MaintenanceMode.OFF) + object_type_pairs = ((self.host, "host"), (self.service, "service"), (self.component, "component")) + + for assert_func, mm_states in ((self.assertIsNone, correct_values), (self.assertIsNotNone, wrong_values)): + for mm_state, object_type_pair in product(mm_states, object_type_pairs): + object_, type_ = object_type_pair + self._set_objects_mm(**{type_: mm_state}) + with self.subTest(f"{object_.__class__.__name__} with mm `{object_.maintenance_mode}`"): + error = validate_obj(obj=object_) + assert_func(error) + + def test_object_getting(self): + test_data = { + "host": { + "context": { + "type": "cluster", + "host_id": self.host.pk, + } + }, + "service": { + "context": { + "type": "service", + "service_id": self.service.pk, + } + }, + "component": { + "context": { + "type": "component", + "component_id": self.component.pk, + } + }, + } + for type_, task_vars in test_data.items(): + with self.subTest(type_): + object_, error = get_object(task_vars=task_vars, obj_type=type_) + self.assertIsNotNone(object_) + self.assertIsInstance(object_, TYPE_CLASS_MAP[type_]) + self.assertIsNone(error) diff --git a/python/job_runner.py b/python/job_runner.py index b28f492591..2eaab92594 100755 --- a/python/job_runner.py +++ b/python/job_runner.py @@ -19,7 +19,7 @@ import adcm.init_django # noqa: F401, isort:skip -from cm.ansible_plugin import finish_check +from ansible_plugin.utils import finish_check from cm.api import get_hc, save_hc from cm.errors import AdcmEx from cm.job import check_hostcomponentmap, set_job_final_status, set_job_start_status From cad581602bc106afab695150d02f5a649be8f08e Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Fri, 29 Mar 2024 04:16:39 +0000 Subject: [PATCH 029/208] ADCM-5414 Rework concerns representation in database Changed: 1. Special functions for creating issue/lock 2. `ConcernItem` model reworked 3. `ConcernItem` name generation changed for issue/lock (flag will be changed during ansible plugin rework) Added: 1. `ConcernMessage` and utilities instead of `MessageTemplate` Removed: 1. `MessageTemplate` as DB entity --- python/api_v2/tests/test_concerns.py | 18 +- python/api_v2/tests/test_service.py | 4 + python/cm/flag.py | 19 +- python/cm/issue.py | 123 ++++++++----- python/cm/job.py | 8 +- .../migrations/0116_delete_MessageTemplate.py | 23 +++ .../cm/migrations/0117_change_concern_item.py | 79 +++++++++ python/cm/models.py | 167 ++---------------- python/cm/services/concern/__init__.py | 12 ++ python/cm/services/concern/messages.py | 150 ++++++++++++++++ python/cm/tests/test_adcm_entity.py | 44 ++--- python/cm/tests/test_issue.py | 42 +++-- python/cm/tests/test_message_template.py | 88 --------- python/cm/tests/test_task_log.py | 2 +- python/cm/tests/test_upgrade.py | 4 +- python/cm/tests/utils.py | 4 +- 16 files changed, 419 insertions(+), 368 deletions(-) create mode 100644 python/cm/migrations/0116_delete_MessageTemplate.py create mode 100644 python/cm/migrations/0117_change_concern_item.py create mode 100644 python/cm/services/concern/__init__.py create mode 100644 python/cm/services/concern/messages.py delete mode 100644 python/cm/tests/test_message_template.py diff --git a/python/api_v2/tests/test_concerns.py b/python/api_v2/tests/test_concerns.py index 7a24f52dc5..0d0f386a6c 100644 --- a/python/api_v2/tests/test_concerns.py +++ b/python/api_v2/tests/test_concerns.py @@ -11,12 +11,11 @@ # limitations under the License. from cm.models import ( - KnownNames, - MessageTemplate, ObjectType, Prototype, PrototypeImport, ) +from cm.services.concern.messages import ConcernMessage from django.urls import reverse from rest_framework.response import Response from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED @@ -49,7 +48,7 @@ def setUp(self) -> None: def test_required_service_concern(self): cluster = self.add_cluster(bundle=self.required_service_bundle, name="required_service_cluster") expected_concern_reason = { - "message": MessageTemplate.objects.get(name=KnownNames.REQUIRED_SERVICE_ISSUE.value).template["message"], + "message": ConcernMessage.REQUIRED_SERVICE_ISSUE.template.message, "placeholder": { "source": {"type": "cluster", "name": cluster.name, "params": {"clusterId": cluster.pk}}, "target": { @@ -76,7 +75,7 @@ def test_required_service_concern(self): def test_required_config_concern(self): cluster = self.add_cluster(bundle=self.required_config_bundle, name="required_config_cluster") expected_concern_reason = { - "message": MessageTemplate.objects.get(name=KnownNames.CONFIG_ISSUE.value).template["message"], + "message": ConcernMessage.CONFIG_ISSUE.template.message, "placeholder": {"source": {"name": cluster.name, "params": {"clusterId": cluster.pk}, "type": "cluster"}}, } @@ -92,7 +91,7 @@ def test_required_config_concern(self): def test_required_import_concern(self): cluster = self.add_cluster(bundle=self.required_import_bundle, name="required_import_cluster") expected_concern_reason = { - "message": MessageTemplate.objects.get(name=KnownNames.REQUIRED_IMPORT_ISSUE.value).template["message"], + "message": ConcernMessage.REQUIRED_IMPORT_ISSUE.template.message, "placeholder": {"source": {"name": cluster.name, "params": {"clusterId": cluster.pk}, "type": "cluster"}}, } @@ -106,7 +105,7 @@ def test_required_hc_concern(self): cluster = self.add_cluster(bundle=self.required_hc_bundle, name="required_hc_cluster") self.add_services_to_cluster(service_names=["service_1"], cluster=cluster) expected_concern_reason = { - "message": MessageTemplate.objects.get(name=KnownNames.HOST_COMPONENT_ISSUE.value).template["message"], + "message": ConcernMessage.HOST_COMPONENT_ISSUE.template.message, "placeholder": {"source": {"name": cluster.name, "params": {"clusterId": cluster.pk}, "type": "cluster"}}, } @@ -119,7 +118,8 @@ def test_required_hc_concern(self): def test_outdated_config_flag(self): cluster = self.add_cluster(bundle=self.config_flag_bundle, name="config_flag_cluster") expected_concern_reason = { - "message": MessageTemplate.objects.get(name=KnownNames.CONFIG_FLAG.value).template["message"], + # todo fix expectations, because it was expected CONFIG_FLAG + "message": ConcernMessage.FLAG.template.message, "placeholder": {"source": {"name": cluster.name, "params": {"clusterId": cluster.pk}, "type": "cluster"}}, } @@ -142,9 +142,7 @@ def test_service_requirements(self): cluster = self.add_cluster(bundle=self.service_requirements_bundle, name="service_requirements_cluster") service = self.add_services_to_cluster(service_names=["service_1"], cluster=cluster).get() expected_concern_reason = { - "message": MessageTemplate.objects.get(name=KnownNames.UNSATISFIED_REQUIREMENT_ISSUE.value).template[ - "message" - ], + "message": ConcernMessage.UNSATISFIED_REQUIREMENT_ISSUE.template.message, "placeholder": { "source": { "name": service.name, diff --git a/python/api_v2/tests/test_service.py b/python/api_v2/tests/test_service.py index 83ba81efda..711e2fdfc6 100644 --- a/python/api_v2/tests/test_service.py +++ b/python/api_v2/tests/test_service.py @@ -314,6 +314,10 @@ def test_delete_service_abort_own_actions_success(self) -> None: ) ) self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) + self.assertEqual( + "adcm_delete_service", + self.service_to_delete.concerns.get(type=ConcernType.LOCK).reason["placeholder"]["job"]["name"], + ) @staticmethod def imitate_task_running(action: Action, object_: Cluster | ClusterObject) -> TaskLog: diff --git a/python/cm/flag.py b/python/cm/flag.py index 3965f8197f..fce6f8225a 100644 --- a/python/cm/flag.py +++ b/python/cm/flag.py @@ -12,17 +12,13 @@ from cm.hierarchy import Tree from cm.issue import add_concern_to_object, remove_concern_from_object -from cm.models import ( - ADCMEntity, - ConcernCause, - ConcernItem, - ConcernType, - KnownNames, - MessageTemplate, -) +from cm.models import ADCMEntity, ConcernCause, ConcernItem, ConcernType +from cm.services.concern.messages import ConcernMessage, PlaceholderObjectsDTO, build_concern_reason def get_flag_name(obj: ADCMEntity, msg: str = "") -> str: + # todo it should be changed (adjusted to one format) + # after plugin is reworked name = f"{obj} has an outdated configuration" if msg: name = f"{name}: {msg}" @@ -31,8 +27,13 @@ def get_flag_name(obj: ADCMEntity, msg: str = "") -> str: def create_flag(obj: ADCMEntity, msg: str = "") -> ConcernItem: - reason = MessageTemplate.get_message_from_template(name=KnownNames.CONFIG_FLAG.value, source=obj) + # todo make correct message preparation + reason = build_concern_reason( + concern_message=ConcernMessage.FLAG, placeholder_objects=PlaceholderObjectsDTO(source=obj) + ) if msg: + # todo it should be changed (adjusted to one format) + # after plugin is reworked reason["message"] = f"{reason['message']}: {msg}" return ConcernItem.objects.create( diff --git a/python/cm/issue.py b/python/cm/issue.py index d0b0c0fd32..ca0a122313 100755 --- a/python/cm/issue.py +++ b/python/cm/issue.py @@ -35,14 +35,13 @@ Host, HostComponent, JobLog, - KnownNames, - MessageTemplate, ObjectType, Prototype, PrototypeImport, ServiceComponent, TaskLog, ) +from cm.services.concern.messages import ConcernMessage, PlaceholderObjectsDTO, build_concern_reason from cm.status_api import send_concern_creation_event, send_concern_delete_event from cm.utils import obj_ref @@ -382,33 +381,36 @@ def check_component_constraint( ObjectType.HOST: (ConcernCause.CONFIG,), } _issue_template_map = { - ConcernCause.CONFIG: KnownNames.CONFIG_ISSUE, - ConcernCause.IMPORT: KnownNames.REQUIRED_IMPORT_ISSUE, - ConcernCause.SERVICE: KnownNames.REQUIRED_SERVICE_ISSUE, - ConcernCause.HOSTCOMPONENT: KnownNames.HOST_COMPONENT_ISSUE, - ConcernCause.REQUIREMENT: KnownNames.UNSATISFIED_REQUIREMENT_ISSUE, + ConcernCause.CONFIG: ConcernMessage.CONFIG_ISSUE, + ConcernCause.IMPORT: ConcernMessage.REQUIRED_IMPORT_ISSUE, + ConcernCause.SERVICE: ConcernMessage.REQUIRED_SERVICE_ISSUE, + ConcernCause.HOSTCOMPONENT: ConcernMessage.HOST_COMPONENT_ISSUE, + ConcernCause.REQUIREMENT: ConcernMessage.UNSATISFIED_REQUIREMENT_ISSUE, } -def _gen_issue_name(obj: ADCMEntity, cause: ConcernCause) -> str: - """Make human-understandable issue name for debug use""" - return f"{obj} has issue with {cause.value}" +def _gen_issue_name(cause: ConcernCause) -> str: + return f"{ConcernType.ISSUE}_{cause.value}" -def get_kwargs_for_issue(msg_name: KnownNames, source: ADCMEntity) -> dict: +def _get_kwargs_for_issue(concern_name: ConcernMessage, source: ADCMEntity) -> dict: kwargs = {"source": source} target = None - if msg_name == KnownNames.REQUIRED_SERVICE_ISSUE: + if concern_name == ConcernMessage.REQUIRED_SERVICE_ISSUE: bundle = source.prototype.bundle - for proto in Prototype.objects.filter(bundle=bundle, type="service", required=True): - try: - ClusterObject.objects.get(cluster=source, prototype=proto) - except ClusterObject.DoesNotExist: - target = proto - break + # source is expected to be Cluster here + target = ( + Prototype.objects.filter( + bundle=bundle, + type="service", + required=True, + ) + .exclude(id__in=ClusterObject.objects.values_list("prototype_id", flat=True).filter(cluster=source)) + .first() + ) - elif msg_name == KnownNames.UNSATISFIED_REQUIREMENT_ISSUE: + elif concern_name == ConcernMessage.UNSATISFIED_REQUIREMENT_ISSUE: for require in source.prototype.requires: try: ClusterObject.objects.get(prototype__name=require["service"], cluster=source.cluster) @@ -420,30 +422,24 @@ def get_kwargs_for_issue(msg_name: KnownNames, source: ADCMEntity) -> dict: return kwargs -def _create_concern_item(obj: ADCMEntity, issue_cause: ConcernCause) -> ConcernItem: - msg_name = _issue_template_map[issue_cause] - kwargs = get_kwargs_for_issue(msg_name=msg_name, source=obj) - reason = MessageTemplate.get_message_from_template(name=msg_name.value, **kwargs) - issue_name = _gen_issue_name(obj=obj, cause=issue_cause) +def create_issue(obj: ADCMEntity, issue_cause: ConcernCause) -> ConcernItem: + concern_message = _issue_template_map[issue_cause] + kwargs = _get_kwargs_for_issue(concern_name=concern_message, source=obj) + reason = build_concern_reason(concern_message=concern_message, placeholder_objects=PlaceholderObjectsDTO(**kwargs)) + type_: str = ConcernType.ISSUE.value + cause: str = issue_cause.value return ConcernItem.objects.create( - type=ConcernType.ISSUE, - name=issue_name, - reason=reason, - owner=obj, - cause=issue_cause, + type=type_, name=f"{cause or ''}_{type_}".strip("_"), reason=reason, owner=obj, cause=cause ) -def create_issue(obj: ADCMEntity, issue_cause: ConcernCause) -> None: +def add_issue_on_linked_objects(obj: ADCMEntity, issue_cause: ConcernCause) -> None: """Create newly discovered issue and add it to linked objects concerns""" - issue = obj.get_own_issue(cause=issue_cause) - - if issue is None: - issue = _create_concern_item(obj=obj, issue_cause=issue_cause) + issue = obj.get_own_issue(cause=issue_cause) or create_issue(obj=obj, issue_cause=issue_cause) - if issue.name != _gen_issue_name(obj=obj, cause=issue_cause): - issue.delete() - issue = _create_concern_item(obj=obj, issue_cause=issue_cause) + # todo here was a code that was re-creating issue if it has different name + # but can't see why it may be needed, just re-check it. + # Since we've got the `issue` by cause, I see no way it'll have different cause. tree = Tree(obj) affected_nodes = tree.get_directly_affected(node=tree.built_from) @@ -465,7 +461,7 @@ def recheck_issues(obj: ADCMEntity) -> None: issue_causes = _prototype_issue_map.get(obj.prototype.type, []) for issue_cause in issue_causes: if not _issue_check_map[issue_cause](obj): - create_issue(obj=obj, issue_cause=issue_cause) + add_issue_on_linked_objects(obj=obj, issue_cause=issue_cause) else: remove_issue(obj=obj, issue_cause=issue_cause) @@ -527,21 +523,54 @@ def lock_affected_objects(task: TaskLog, objects: Iterable[ADCMEntity]) -> None: if task.lock: return + # fixme It is possible that lock exists, but is not bound to task. + # Not it's done for case like `test_delete_service_abort_own_actions_success` + # thou it's not proven that it can happen in real world (the opposite wasn't proven either). + # Most likely relation to task should be improved somehow (not just "let's check if it's None"), + # but can't think of good and universal solution at the moment. + # Problem with this fix is that such concern "may" be deleted during task cancellation, + # so it'll be removed from this task too. + owner: ADCMEntity = task.task_object first_job = JobLog.obj.filter(task=task).order_by("id").first() - task.lock = ConcernItem.objects.create( - type=ConcernType.LOCK.value, - name=None, - reason=first_job.cook_reason(), - blocking=True, - owner=task.task_object, - cause=ConcernCause.JOB.value, - ) - task.save() + existing_lock = ConcernItem.objects.filter( + owner_id=owner.pk, owner_type=owner.content_type, type=ConcernType.LOCK + ).first() + if existing_lock: + # it may be `lock` from another job + # (case: delete service when another action on service is running) + task.lock = update_job_in_lock_reason(lock=existing_lock, job=first_job) + else: + task.lock = create_lock(owner=owner, job=first_job) + + task.save(update_fields=["lock"]) for obj in objects: add_concern_to_object(object_=obj, concern=task.lock) +def create_lock(owner: ADCMEntity, job: JobLog): + type_: str = ConcernType.LOCK.value + cause: str = ConcernCause.JOB.value + return ConcernItem.objects.create( + type=type_, + name=f"{cause or ''}_{type_}".strip("_"), + reason=build_concern_reason( + ConcernMessage.LOCKED_BY_JOB, placeholder_objects=PlaceholderObjectsDTO(job=job, target=owner) + ), + blocking=True, + owner=owner, + cause=cause, + ) + + +def update_job_in_lock_reason(lock: ConcernItem, job: JobLog) -> ConcernItem: + lock.reason = build_concern_reason( + ConcernMessage.LOCKED_BY_JOB, placeholder_objects=PlaceholderObjectsDTO(job=job, target=lock.owner) + ) + lock.save(update_fields=["reason"]) + return lock + + def unlock_affected_objects(task: TaskLog) -> None: task.refresh_from_db() diff --git a/python/cm/job.py b/python/cm/job.py index 03057b296d..1643f099e9 100644 --- a/python/cm/job.py +++ b/python/cm/job.py @@ -85,6 +85,7 @@ Upgrade, get_object_cluster, ) +from cm.services.concern.messages import ConcernMessage, PlaceholderObjectsDTO, build_concern_reason from cm.services.config.spec import convert_to_flat_spec_from_proto_flat_spec from cm.services.job.config import get_job_config from cm.services.job.inventory import get_inventory_data @@ -764,14 +765,17 @@ def set_task_final_status(task: TaskLog, status: str): def set_job_start_status(job_id: int, pid: int) -> None: - job = JobLog.objects.get(id=job_id) + job = JobLog.objects.select_related("task").get(id=job_id) job.status = JobStatus.RUNNING job.start_date = timezone.now() job.pid = pid job.save(update_fields=["status", "start_date", "pid"]) if job.task.lock and job.task.task_object: - job.task.lock.reason = job.cook_reason() + job.task.lock.reason = build_concern_reason( + ConcernMessage.LOCKED_BY_JOB, + placeholder_objects=PlaceholderObjectsDTO(job=job, target=job.task.task_object), + ) job.task.lock.save(update_fields=["reason"]) diff --git a/python/cm/migrations/0116_delete_MessageTemplate.py b/python/cm/migrations/0116_delete_MessageTemplate.py new file mode 100644 index 0000000000..46d5701186 --- /dev/null +++ b/python/cm/migrations/0116_delete_MessageTemplate.py @@ -0,0 +1,23 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated by Django 3.2.23 on 2024-03-26 05:31 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("cm", "0115_auto_20231025_1823"), + ] + + operations = [migrations.DeleteModel(name="MessageTemplate")] diff --git a/python/cm/migrations/0117_change_concern_item.py b/python/cm/migrations/0117_change_concern_item.py new file mode 100644 index 0000000000..002b568e4a --- /dev/null +++ b/python/cm/migrations/0117_change_concern_item.py @@ -0,0 +1,79 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated by Django 3.2.23 on 2024-03-27 05:34 + +from django.db import migrations, models +import django.db.models.deletion +from django.db.models import Q + + +def remove_unlinked_concerns(apps, schema_editor): + ConcernItem = apps.get_model("cm", "ConcernItem") + + # todo add test on it + ConcernItem.objects.filter(Q(owner_type__isnull=True) | Q(owner_id__isnull=True)).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("cm", "0116_delete_MessageTemplate"), + ] + + operations = [ + migrations.RunPython(code=remove_unlinked_concerns, reverse_code=migrations.RunPython.noop), + migrations.AlterField( + model_name="concernitem", + name="cause", + field=models.CharField( + choices=[ + ("config", "config"), + ("job", "job"), + ("host-component", "host-component"), + ("import", "import"), + ("service", "service"), + ("requirement", "requirement"), + ], + max_length=100, + null=True, + ), + ), + migrations.AlterField( + model_name="concernitem", + name="name", + field=models.CharField(default="", max_length=1000), + ), + migrations.AlterField( + model_name="concernitem", + name="owner_id", + field=models.PositiveIntegerField(), + ), + migrations.AlterField( + model_name="concernitem", + name="owner_type", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype"), + ), + migrations.AlterField( + model_name="concernitem", + name="type", + field=models.CharField( + choices=[("lock", "lock"), ("issue", "issue"), ("flag", "flag")], default="lock", max_length=100 + ), + ), + migrations.AddConstraint( + model_name="concernitem", + constraint=models.UniqueConstraint( + fields=("name", "owner_id", "owner_type"), name="cm_concernitem_name_owner_uc" + ), + ), + ] diff --git a/python/cm/models.py b/python/cm/models.py index 8c760e22ca..252066924e 100644 --- a/python/cm/models.py +++ b/python/cm/models.py @@ -12,7 +12,6 @@ from collections.abc import Iterable, Mapping from copy import deepcopy -from enum import Enum from itertools import chain from typing import Optional, TypeAlias from uuid import uuid4 @@ -1416,13 +1415,6 @@ class JobLog(ADCMModel): class Meta: ordering = ["id"] - def cook_reason(self): - return MessageTemplate.get_message_from_template( - KnownNames.LOCKED_BY_JOB.value, - job=self, - target=self.task.task_object, - ) - def cancel(self): if self.sub_action and not self.sub_action.allowed_to_terminate: raise AdcmEx("JOB_TERMINATION_ERROR", f"Job #{self.pk} can not be terminated") @@ -1588,148 +1580,6 @@ class Meta: unique_together = (("prototype", "name"),) -class KnownNames(Enum): - LOCKED_BY_JOB = "locked by running job on target" # kwargs=(job, target) - CONFIG_ISSUE = "object config issue" # kwargs=(source, ) - REQUIRED_SERVICE_ISSUE = "required service issue" # kwargs=(source, ) - REQUIRED_IMPORT_ISSUE = "required import issue" # kwargs=(source, ) - HOST_COMPONENT_ISSUE = "host component issue" # kwargs=(source, ) - UNSATISFIED_REQUIREMENT_ISSUE = "unsatisfied service requirement" # kwargs=(source, ) - CONFIG_FLAG = "outdated configuration flag" # kwargs=(source, ) - - -class PlaceHolderType(Enum): - ACTION = "action" - JOB = "job" - ADCM_ENTITY = "adcm_entity" - ADCM = "adcm" - CLUSTER = "cluster" - SERVICE = "service" - COMPONENT = "component" - PROVIDER = "provider" - HOST = "host" - PROTOTYPE = "prototype" - - -class MessageTemplate(ADCMModel): - """ - Templates for `ConcernItem.reason - There are two sources of templates - they are pre-created in migrations or loaded from bundles - - expected template format is - { - 'message': 'Lorem ${ipsum} dolor sit ${amet}', - 'placeholder': { - 'lorem': {'type': 'cluster'}, - 'amet': {'type': 'action'} - } - } - - placeholder fill functions have unified interface: - @classmethod - def _func(cls, placeholder_name, **kwargs) -> dict - """ - - name = models.CharField(max_length=1000, unique=True) - template = models.JSONField() - - @classmethod - def get_message_from_template(cls, name: KnownNames, **kwargs) -> dict: - """Find message template by its name and fill placeholders""" - - tpl = cls.obj.get(name=name).template - filled_placeholders = {} - try: - for ph_name, ph_data in tpl["placeholder"].items(): - filled_placeholders[ph_name] = cls._fill_placeholder(ph_name, ph_data, **kwargs) - except (KeyError, AttributeError, TypeError, AssertionError) as e: - if isinstance(e, KeyError): - msg = f'Message templating KeyError: "{e.args[0]}" not found' - elif isinstance(e, AttributeError): - msg = f'Message templating AttributeError: "{e.args[0]}"' - elif isinstance(e, TypeError): - msg = f'Message templating TypeError: "{e.args[0]}"' - elif isinstance(e, AssertionError): - msg = "Message templating AssertionError: expected kwarg were not found" - else: - msg = None - raise AdcmEx("MESSAGE_TEMPLATING_ERROR", msg=msg) from e - - tpl["placeholder"] = filled_placeholders - - return tpl - - @classmethod - def _fill_placeholder(cls, ph_name: str, ph_data: dict, **ph_source_data) -> dict: - type_map = { - PlaceHolderType.ACTION.value: cls._action_placeholder, - PlaceHolderType.ADCM_ENTITY.value: cls._adcm_entity_placeholder, - PlaceHolderType.ADCM.value: cls._adcm_entity_placeholder, - PlaceHolderType.CLUSTER.value: cls._adcm_entity_placeholder, - PlaceHolderType.SERVICE.value: cls._adcm_entity_placeholder, - PlaceHolderType.COMPONENT.value: cls._adcm_entity_placeholder, - PlaceHolderType.PROVIDER.value: cls._adcm_entity_placeholder, - PlaceHolderType.HOST.value: cls._adcm_entity_placeholder, - PlaceHolderType.JOB.value: cls._job_placeholder, - PlaceHolderType.PROTOTYPE.value: cls._prototype_placeholder, - } - return type_map[ph_data["type"]](ph_name, **ph_source_data) - - @classmethod - def _action_placeholder(cls, _, **kwargs) -> dict: - action = kwargs.get("action") - target = kwargs.get("target") - if not target or not action: - return {} - - ids = target.get_id_chain() - ids["action_id"] = action.pk - return { - "type": PlaceHolderType.ACTION.value, - "name": action.display_name, - "params": ids, - } - - @classmethod - def _prototype_placeholder(cls, _, **kwargs) -> dict: - proto = kwargs.get("target") - - if proto: - return { - "params": {"prototype_id": proto.id}, - "type": "prototype", - "name": proto.display_name or proto.name, - } - - return {} - - @classmethod - def _adcm_entity_placeholder(cls, ph_name, **kwargs) -> dict: - obj = kwargs.get(ph_name) - if not obj: - return {} - - return { - "type": obj.prototype.type, - "name": obj.display_name, - "params": obj.get_id_chain(), - } - - @classmethod - def _job_placeholder(cls, _, **kwargs) -> dict: - job = kwargs.get("job") - action = job.sub_action or job.action - - if not job: - return {} - - return { - "type": PlaceHolderType.JOB.value, - "name": action.display_name or action.name, - "params": {"job_id": job.task.id}, - } - - class ConcernType(models.TextChoices): LOCK = "lock", "lock" ISSUE = "issue", "issue" @@ -1755,21 +1605,26 @@ class ConcernItem(ADCMModel): `type` is literally type of concern `name` is used for (un)setting flags from ansible playbooks `reason` is used to display/notify on front-end, text template and data for URL generation - should be generated from pre-created templates model `MessageTemplate` + should be generated from `MessageTemplate` `blocking` blocks actions from running `owner` is object-origin of concern `cause` is owner's parameter causing concern `related_objects` are back-refs from affected `ADCMEntities.concerns` """ - type = models.CharField(max_length=1000, choices=ConcernType.choices, default=ConcernType.LOCK) - name = models.CharField(max_length=1000, null=True, unique=True) + type = models.CharField(max_length=100, choices=ConcernType.choices, default=ConcernType.LOCK) + name = models.CharField(max_length=1000, default="") reason = models.JSONField(default=dict) blocking = models.BooleanField(default=True) - owner_id = models.PositiveIntegerField(null=True) - owner_type = models.ForeignKey(ContentType, null=True, on_delete=models.CASCADE) + owner_id = models.PositiveIntegerField() + owner_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) owner = GenericForeignKey("owner_type", "owner_id") - cause = models.CharField(max_length=1000, null=True, choices=ConcernCause.choices) + cause = models.CharField(max_length=100, null=True, choices=ConcernCause.choices) + + class Meta: + constraints = [ + models.UniqueConstraint(name="cm_concernitem_name_owner_uc", fields=("name", "owner_id", "owner_type")) + ] @property def related_objects(self) -> Iterable[ADCMEntity]: diff --git a/python/cm/services/concern/__init__.py b/python/cm/services/concern/__init__.py new file mode 100644 index 0000000000..f3e59d81d8 --- /dev/null +++ b/python/cm/services/concern/__init__.py @@ -0,0 +1,12 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/python/cm/services/concern/messages.py b/python/cm/services/concern/messages.py new file mode 100644 index 0000000000..1d2f5d7ab5 --- /dev/null +++ b/python/cm/services/concern/messages.py @@ -0,0 +1,150 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import dataclass +from enum import Enum +from typing import Callable, Generic, NamedTuple, TypeVar + +from cm.models import ADCMEntity, JobLog, Prototype + +_PlaceholderObjectT = TypeVar("_PlaceholderObjectT", bound=Callable) + + +class PlaceholderObjectsDTO(NamedTuple): + source: ADCMEntity | None = None + target: ADCMEntity | Prototype | None = None + job: JobLog | None = None + + +@dataclass(slots=True, frozen=True) +class Placeholder(Generic[_PlaceholderObjectT]): + retrieve: Callable[[_PlaceholderObjectT], dict] | None = None + + @property + def is_required(self) -> bool: + return self.retrieve is not None + + +class Placeholders: + source: Placeholder[ADCMEntity] + target: Placeholder[ADCMEntity | Prototype] + job: Placeholder[JobLog] + + def __init__( + self, + retrieve_source: Callable[[ADCMEntity], dict] | None = None, + retrieve_target: Callable[[ADCMEntity | Prototype], dict] | None = None, + retrieve_job: Callable[[JobLog], dict] | None = None, + ): + self.source = Placeholder(retrieve=retrieve_source) + self.target = Placeholder(retrieve=retrieve_target) + self.job = Placeholder(retrieve=retrieve_job) + + +@dataclass(frozen=True, slots=True) +class ConcernMessageTemplate: + message: str + placeholders: Placeholders + + +def _retrieve_placeholder_from_adcm_entity(entity: ADCMEntity) -> dict: + return { + "type": entity.prototype.type, + "name": entity.display_name, # fixme only entities with display name can be here, not any ADCMEntity + "params": entity.get_id_chain(), + } + + +def _retrieve_placeholder_from_prototype(entity: Prototype) -> dict: + if not isinstance(entity, Prototype): + message = f"Expected instance of Prototype, not {type(entity)}" + raise TypeError(message) + + return { + "params": {"prototype_id": entity.id}, + "type": "prototype", + "name": entity.display_name or entity.name, + } + + +def _retrieve_placeholder_from_job(entity: JobLog) -> dict: + # todo should be updated after task rework feature branch is merged + # name should be taken from `entity.task.display_name` + action = entity.sub_action or entity.action + + return { + "type": "job", + "name": action.display_name or action.name, + # todo should it be job id or task id? + "params": {"job_id": entity.task.id}, + } + + +ADCM_ENTITY_SOURCE_RESOLVER = Placeholders(retrieve_source=_retrieve_placeholder_from_adcm_entity) + + +class ConcernMessage(Enum): + CONFIG_ISSUE = ConcernMessageTemplate( + message="${source} has an issue with its config", placeholders=ADCM_ENTITY_SOURCE_RESOLVER + ) + HOST_COMPONENT_ISSUE = ConcernMessageTemplate( + message="${source} has an issue with host-component mapping", placeholders=ADCM_ENTITY_SOURCE_RESOLVER + ) + REQUIRED_IMPORT_ISSUE = ConcernMessageTemplate( + message="${source} has an issue with required import", placeholders=ADCM_ENTITY_SOURCE_RESOLVER + ) + REQUIRED_SERVICE_ISSUE = ConcernMessageTemplate( + message="${source} require service ${target} to be installed", + placeholders=Placeholders( + retrieve_source=_retrieve_placeholder_from_adcm_entity, retrieve_target=_retrieve_placeholder_from_prototype + ), + ) + UNSATISFIED_REQUIREMENT_ISSUE = ConcernMessageTemplate( + message="${source} has an issue with requirement. Need to be installed: ${target}", + placeholders=Placeholders( + retrieve_source=_retrieve_placeholder_from_adcm_entity, retrieve_target=_retrieve_placeholder_from_prototype + ), + ) + LOCKED_BY_JOB = ConcernMessageTemplate( + message="Object was locked by running job ${job} on ${target}", + placeholders=Placeholders( + retrieve_job=_retrieve_placeholder_from_job, + retrieve_target=_retrieve_placeholder_from_adcm_entity, + ), + ) + # todo update message and naming here + FLAG = ConcernMessageTemplate( + message="${source} has an outdated configuration", placeholders=ADCM_ENTITY_SOURCE_RESOLVER + ) + + def __init__(self, template: ConcernMessageTemplate): + self.template = template + + +def build_concern_reason(concern_message: ConcernMessage, placeholder_objects: PlaceholderObjectsDTO) -> dict: + template = concern_message.template + + resolved_placeholders = {} + for placeholder_name in ("source", "target", "job"): + placeholder: Placeholder = getattr(template.placeholders, placeholder_name) + if not placeholder.is_required: + continue + + entity = getattr(placeholder_objects, placeholder_name) + if entity is None: + # todo if there will be cases when those can be null, set placeholder to `{}` instead of error + message = f"Concern message {concern_message.name} requires `{placeholder_name}` to fill placeholders" + raise RuntimeError(message) + + resolved_placeholders[placeholder_name] = placeholder.retrieve(entity) + + return {"message": template.message, "placeholder": resolved_placeholders} diff --git a/python/cm/tests/test_adcm_entity.py b/python/cm/tests/test_adcm_entity.py index c771bd7408..28816a6506 100644 --- a/python/cm/tests/test_adcm_entity.py +++ b/python/cm/tests/test_adcm_entity.py @@ -11,14 +11,8 @@ # limitations under the License. from adcm.tests.base import BaseTestCase -from cm.issue import add_concern_to_object, remove_concern_from_object -from cm.models import ( - ConcernCause, - ConcernItem, - ConcernType, - KnownNames, - MessageTemplate, -) +from cm.issue import add_concern_to_object, create_issue, remove_concern_from_object +from cm.models import ConcernCause, ConcernItem, ConcernType from cm.tests.utils import gen_concern_item, generate_hierarchy @@ -36,14 +30,14 @@ def test_is_locked__false(self): self.assertFalse(item, "No locks expected") def test_is_locked__true(self): - lock = gen_concern_item(ConcernType.LOCK) + lock = gen_concern_item(ConcernType.LOCK, owner=self.hierarchy["cluster"]) for obj in self.hierarchy.values(): add_concern_to_object(object_=obj, concern=lock) self.assertTrue(obj.locked) def test_is_locked__deleted(self): - lock = gen_concern_item(ConcernType.LOCK) + lock = gen_concern_item(ConcernType.LOCK, owner=self.hierarchy["cluster"]) for obj in self.hierarchy.values(): add_concern_to_object(object_=obj, concern=lock) @@ -59,14 +53,14 @@ def test_add_to_concern__none(self): self.assertFalse(obj.locked) def test_add_to_concern__deleted(self): - lock = ConcernItem(type=ConcernType.LOCK, name=None, reason="unsaved") + lock = ConcernItem(type=ConcernType.LOCK, name="", reason="unsaved") for obj in self.hierarchy.values(): add_concern_to_object(object_=obj, concern=lock) self.assertFalse(obj.locked) def test_add_to_concern(self): - lock = gen_concern_item(ConcernType.LOCK) + lock = gen_concern_item(ConcernType.LOCK, owner=self.hierarchy["cluster"]) for obj in self.hierarchy.values(): add_concern_to_object(object_=obj, concern=lock) @@ -79,7 +73,7 @@ def test_add_to_concern(self): def test_remove_from_concern__none(self): nolock = None - lock = gen_concern_item(ConcernType.LOCK) + lock = gen_concern_item(ConcernType.LOCK, owner=self.hierarchy["cluster"]) for obj in self.hierarchy.values(): add_concern_to_object(object_=obj, concern=lock) remove_concern_from_object(object_=obj, concern=nolock) @@ -87,8 +81,8 @@ def test_remove_from_concern__none(self): self.assertTrue(obj.locked) def test_remove_from_concern__deleted(self): - nolock = ConcernItem(type=ConcernType.LOCK, name=None, reason="unsaved") - lock = gen_concern_item(ConcernType.LOCK) + nolock = ConcernItem(type=ConcernType.LOCK, name="", reason="unsaved") + lock = gen_concern_item(ConcernType.LOCK, owner=self.hierarchy["cluster"]) for obj in self.hierarchy.values(): add_concern_to_object(object_=obj, concern=lock) remove_concern_from_object(object_=obj, concern=nolock) @@ -103,25 +97,17 @@ def test_get_own_issue__empty(self): def test_get_own_issue__others(self): cluster = self.hierarchy["cluster"] service = self.hierarchy["service"] - reason = MessageTemplate.get_message_from_template( - KnownNames.CONFIG_ISSUE.value, - source=cluster, - ) - issue_type = ConcernCause.CONFIG - issue = ConcernItem.objects.create(type=ConcernType.ISSUE, reason=reason, owner=cluster, cause=issue_type) + issue_cause = ConcernCause.CONFIG + issue = create_issue(obj=cluster, issue_cause=issue_cause) add_concern_to_object(object_=cluster, concern=issue) add_concern_to_object(object_=service, concern=issue) - self.assertIsNone(service.get_own_issue(issue_type)) + self.assertIsNone(service.get_own_issue(issue_cause)) def test_get_own_issue__exists(self): cluster = self.hierarchy["cluster"] - reason = MessageTemplate.get_message_from_template( - KnownNames.CONFIG_ISSUE.value, - source=cluster, - ) - issue_type = ConcernCause.CONFIG - issue = ConcernItem.objects.create(type=ConcernType.ISSUE, reason=reason, owner=cluster, cause=issue_type) + issue_cause = ConcernCause.CONFIG + issue = create_issue(obj=cluster, issue_cause=issue_cause) add_concern_to_object(object_=cluster, concern=issue) - self.assertIsNotNone(cluster.get_own_issue(issue_type)) + self.assertIsNotNone(cluster.get_own_issue(issue_cause)) diff --git a/python/cm/tests/test_issue.py b/python/cm/tests/test_issue.py index 129c732c03..f7e726718e 100644 --- a/python/cm/tests/test_issue.py +++ b/python/cm/tests/test_issue.py @@ -19,7 +19,8 @@ from cm.hierarchy import Tree from cm.issue import ( add_concern_to_object, - create_issue, + add_issue_on_linked_objects, + create_lock, do_check_import, recheck_issues, remove_issue, @@ -29,15 +30,14 @@ ADCMEntity, Bundle, ClusterBind, + ClusterObject, ConcernCause, - ConcernItem, - ConcernType, Prototype, PrototypeImport, ) from cm.services.cluster import perform_host_to_cluster_map from cm.services.status import notify -from cm.tests.utils import gen_service, generate_hierarchy +from cm.tests.utils import gen_job_log, gen_service, gen_task_log, generate_hierarchy mock_issue_check_map = { ConcernCause.CONFIG: lambda x: False, @@ -62,7 +62,7 @@ def test_new_issue(self): """Test if new issue is propagated to all affected objects""" issue_type = ConcernCause.CONFIG - create_issue(self.cluster, issue_type) + add_issue_on_linked_objects(self.cluster, issue_type) own_issue = self.cluster.get_own_issue(issue_type) self.assertIsNotNone(own_issue) @@ -77,8 +77,8 @@ def test_same_issue(self): """Test if issue could not be added more than once""" issue_type = ConcernCause.CONFIG - create_issue(self.cluster, issue_type) - create_issue(self.cluster, issue_type) # create twice + add_issue_on_linked_objects(self.cluster, issue_type) + add_issue_on_linked_objects(self.cluster, issue_type) # create twice for node in self.tree.get_directly_affected(self.tree.built_from): concerns = list(node.value.concerns.all()) @@ -89,8 +89,8 @@ def test_few_issues(self): issue_type_1 = ConcernCause.CONFIG issue_type_2 = ConcernCause.IMPORT - create_issue(self.cluster, issue_type_1) - create_issue(self.cluster, issue_type_2) + add_issue_on_linked_objects(self.cluster, issue_type_1) + add_issue_on_linked_objects(self.cluster, issue_type_2) own_issue_1 = self.cluster.get_own_issue(issue_type_1) self.assertIsNotNone(own_issue_1) @@ -111,8 +111,11 @@ def test_inherit_on_creation(self): """Test if new object in hierarchy inherits existing issues""" issue_type = ConcernCause.CONFIG - create_issue(self.cluster, issue_type) + add_issue_on_linked_objects(self.cluster, issue_type) cluster_issue = self.cluster.get_own_issue(issue_type) + ClusterObject.objects.filter(cluster=self.cluster).delete() + Prototype.objects.filter(bundle=self.cluster.prototype.bundle, type="service").update(required=True) + new_service = gen_service(self.cluster, self.cluster.prototype.bundle) self.assertListEqual(list(new_service.concerns.all()), []) @@ -151,7 +154,7 @@ def test_no_issue(self): def test_single_issue(self): issue_type = ConcernCause.CONFIG - create_issue(self.cluster, issue_type) + add_issue_on_linked_objects(self.cluster, issue_type) remove_issue(self.cluster, issue_type) @@ -165,8 +168,8 @@ def test_single_issue(self): def test_few_issues(self): issue_type_1 = ConcernCause.CONFIG issue_type_2 = ConcernCause.IMPORT - create_issue(self.cluster, issue_type_1) - create_issue(self.cluster, issue_type_2) + add_issue_on_linked_objects(self.cluster, issue_type_1) + add_issue_on_linked_objects(self.cluster, issue_type_2) remove_issue(self.cluster, issue_type_1) own_issue_1 = self.cluster.get_own_issue(issue_type_1) @@ -332,7 +335,7 @@ def setUp(self) -> None: self.host = self.hierarchy["host"] for object_ in self.hierarchy.values(): - create_issue(object_, ConcernCause.CONFIG) + add_issue_on_linked_objects(object_, ConcernCause.CONFIG) tree = Tree(object_) self.add_lock( owner=object_, affected_objects=map(attrgetter("value"), tree.get_all_affected(node=tree.built_from)) @@ -340,14 +343,9 @@ def setUp(self) -> None: def add_lock(self, owner: ADCMEntity, affected_objects: Iterable[ADCMEntity]): """Check out lock_affected_objects""" - lock = ConcernItem.objects.create( - type=ConcernType.LOCK.value, - name=None, - reason=f"Lock from {owner.__class__.__name__} {owner.id}", - blocking=True, - owner=owner, - cause=ConcernCause.JOB.value, - ) + + lock = create_lock(owner=owner, job=gen_job_log(gen_task_log(obj=owner))) + for obj in affected_objects: add_concern_to_object(object_=obj, concern=lock) diff --git a/python/cm/tests/test_message_template.py b/python/cm/tests/test_message_template.py deleted file mode 100644 index 619537a258..0000000000 --- a/python/cm/tests/test_message_template.py +++ /dev/null @@ -1,88 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from uuid import uuid4 - -from adcm.tests.base import BaseTestCase - -from cm.errors import AdcmEx -from cm.models import KnownNames, MessageTemplate -from cm.tests.utils import gen_adcm - - -class MessageTemplateTest(BaseTestCase): - def setUp(self): - super().setUp() - - gen_adcm() - - def test_unknown_message(self): - with self.assertRaises(AdcmEx) as e: - MessageTemplate.get_message_from_template("unknown") - - self.assertEqual(e.exception.args[0], "NO_MODEL_ERROR_CODE") - - def test_bad_template__no_placeholder(self): - tpl = MessageTemplate.obj.create( - name=uuid4().hex, - template={ - "message": "Some message", - }, - ) - with self.assertRaises(AdcmEx) as e: - MessageTemplate.get_message_from_template(tpl.name) - - self.assertIn("KeyError", e.exception.msg) - self.assertIn("placeholder", e.exception.msg) - - def test_bad_template__no_type(self): - tpl = MessageTemplate.obj.create( - name=uuid4().hex, - template={"message": "Some message ${data}", "placeholder": {"data": {}}}, - ) - with self.assertRaises(AdcmEx) as e: - MessageTemplate.get_message_from_template(tpl.name) - - self.assertIn("KeyError", e.exception.msg) - self.assertIn("type", e.exception.msg) - - def test_bad_template__unknown_type(self): - tpl = MessageTemplate.obj.create( - name=uuid4().hex, - template={ - "message": "Some message ${data}", - "placeholder": {"data": {"type": "foobar"}}, - }, - ) - with self.assertRaises(AdcmEx) as e: - MessageTemplate.get_message_from_template(tpl.name) - - self.assertIn("KeyError", e.exception.msg) - self.assertIn("foobar", e.exception.msg) - - def test_bad_template__bad_placeholder(self): - tpl = MessageTemplate.obj.create( - name=uuid4().hex, - template={"message": "Some message ${cluster}", "placeholder": []}, - ) - with self.assertRaises(AdcmEx) as e: - MessageTemplate.get_message_from_template(tpl.name) - - self.assertIn("AttributeError", e.exception.msg) - self.assertIn("list", e.exception.msg) - - def test_bad_template__bad_args(self): - name = KnownNames.LOCKED_BY_JOB.value - with self.assertRaises(AdcmEx) as e: - MessageTemplate.get_message_from_template(name) - - self.assertIn("AttributeError", e.exception.msg) diff --git a/python/cm/tests/test_task_log.py b/python/cm/tests/test_task_log.py index d6e9944f9e..77c9000ec2 100644 --- a/python/cm/tests/test_task_log.py +++ b/python/cm/tests/test_task_log.py @@ -54,7 +54,7 @@ def test_lock_affected__lock_is_single(self): cluster = gen_cluster() task = gen_task_log(cluster) gen_job_log(task) - task.lock = gen_concern_item(ConcernType.LOCK) + task.lock = gen_concern_item(ConcernType.LOCK, owner=cluster) task.save() lock_affected_objects(task=task, objects=[cluster]) diff --git a/python/cm/tests/test_upgrade.py b/python/cm/tests/test_upgrade.py index eac51c78e1..a60c895b0e 100644 --- a/python/cm/tests/test_upgrade.py +++ b/python/cm/tests/test_upgrade.py @@ -23,7 +23,7 @@ update_obj_config, ) from cm.errors import AdcmEx -from cm.issue import create_issue +from cm.issue import add_issue_on_linked_objects from cm.models import ( Bundle, ClusterObject, @@ -164,7 +164,7 @@ def test_state(self): self.check_upgrade(self.obj, self.upgrade, True) def test_issue(self): - create_issue(self.obj, ConcernCause.CONFIG) + add_issue_on_linked_objects(self.obj, ConcernCause.CONFIG) self.check_upgrade(self.obj, self.upgrade, False) diff --git a/python/cm/tests/utils.py b/python/cm/tests/utils.py index 268f1e7fa3..fa41858081 100644 --- a/python/cm/tests/utils.py +++ b/python/cm/tests/utils.py @@ -165,7 +165,7 @@ def gen_host_component(component: ServiceComponent, host: Host) -> HostComponent ) -def gen_concern_item(concern_type, name: str | None = None, reason=None, blocking=True, owner=None) -> ConcernItem: +def gen_concern_item(concern_type, owner, name: str = "", reason="", blocking=True) -> ConcernItem: """Generate ConcernItem object""" reason = reason or {"message": "Test", "placeholder": {}} return ConcernItem.objects.create(type=concern_type, name=name, reason=reason, blocking=blocking, owner=owner) @@ -188,7 +188,7 @@ def gen_action(name: str | None = None, bundle=None, prototype=None) -> Action: def gen_task_log(obj: ADCMEntity, action: Action = None) -> TaskLog: return TaskLog.objects.create( - action=action or gen_action(), + action=action or gen_action(prototype=obj.prototype), object_id=obj.pk, status="CREATED", task_object=obj, From a75e71384ee3cdeb0daf77610a7e7e2350be0fbe Mon Sep 17 00:00:00 2001 From: Egor Araslanov Date: Fri, 29 Mar 2024 09:56:44 +0500 Subject: [PATCH 030/208] ADCM-5147 Fix imports --- python/api/job/serializers.py | 2 +- python/cm/services/job/run/_target_factories.py | 2 +- python/cm/services/maintenance_mode.py | 3 +-- python/cm/services/service.py | 3 +-- python/cm/tests/test_task_runner/test_plugin_effects.py | 2 +- 5 files changed, 5 insertions(+), 7 deletions(-) diff --git a/python/api/job/serializers.py b/python/api/job/serializers.py index 1cb5aba81d..0f2f534cc1 100644 --- a/python/api/job/serializers.py +++ b/python/api/job/serializers.py @@ -13,7 +13,7 @@ from pathlib import Path import json -from cm.ansible_plugin import get_checklogs_data_by_job_id +from ansible_plugin.utils import get_checklogs_data_by_job_id from cm.models import JobLog, JobStatus, LogStorage, TaskLog from cm.services.job.action import ActionRunPayload, run_action from django.conf import settings diff --git a/python/cm/services/job/run/_target_factories.py b/python/cm/services/job/run/_target_factories.py index e7dd7eb34f..4e1187f273 100644 --- a/python/cm/services/job/run/_target_factories.py +++ b/python/cm/services/job/run/_target_factories.py @@ -15,6 +15,7 @@ from typing import Any, Generator, Iterable, Literal import json +from ansible_plugin.utils import finish_check from core.job.executors import BundleExecutorConfig, ExecutorConfig from core.job.runners import ExecutionTarget, ExternalSettings from core.job.types import Job, ScriptType, Task @@ -22,7 +23,6 @@ from django.db.transaction import atomic from rbac.roles import re_apply_policy_for_jobs -from cm.ansible_plugin import finish_check from cm.api import get_hc, save_hc from cm.models import ( Cluster, diff --git a/python/cm/services/maintenance_mode.py b/python/cm/services/maintenance_mode.py index 24d2313381..d4916ec0f3 100644 --- a/python/cm/services/maintenance_mode.py +++ b/python/cm/services/maintenance_mode.py @@ -17,8 +17,8 @@ from cm.flag import update_flags from cm.issue import update_hierarchy_issues, update_issue_after_deleting -from cm.job import ActionRunPayload, run_action from cm.models import Action, ClusterObject, Host, HostComponent, MaintenanceMode, Prototype, ServiceComponent +from cm.services.job.action import ActionRunPayload, run_action from cm.services.status.notify import reset_objects_in_mm from cm.status_api import send_object_update_event @@ -35,7 +35,6 @@ def _change_mm_via_action( action=action, obj=obj, payload=ActionRunPayload(conf={}, attr={}, hostcomponent=[], verbose=False), - hosts=[], ) serializer.validated_data["maintenance_mode"] = MaintenanceMode.CHANGING diff --git a/python/cm/services/service.py b/python/cm/services/service.py index 62c66df5d0..9da6625d44 100644 --- a/python/cm/services/service.py +++ b/python/cm/services/service.py @@ -16,8 +16,8 @@ from cm.api import cancel_locking_tasks, delete_service from cm.errors import AdcmEx -from cm.job import ActionRunPayload, run_action from cm.models import Action, ClusterBind, ClusterObject, HostComponent, JobStatus, ServiceComponent, TaskLog +from cm.services.job.action import ActionRunPayload, run_action def delete_service_from_api(service: ClusterObject) -> Response: @@ -69,7 +69,6 @@ def delete_service_from_api(service: ClusterObject) -> Response: action=delete_action, obj=service, payload=ActionRunPayload(conf={}, attr={}, hostcomponent=[], verbose=False), - hosts=[], ) else: delete_service(service=service) diff --git a/python/cm/tests/test_task_runner/test_plugin_effects.py b/python/cm/tests/test_task_runner/test_plugin_effects.py index be1c3a6e2c..955b80e9b7 100644 --- a/python/cm/tests/test_task_runner/test_plugin_effects.py +++ b/python/cm/tests/test_task_runner/test_plugin_effects.py @@ -13,8 +13,8 @@ import json from adcm.tests.base import BusinessLogicMixin, ParallelReadyTestCase, TestCaseWithCommonSetUpTearDown +from ansible_plugin.utils import change_hc -from cm.ansible_plugin import change_hc from cm.models import Action, ServiceComponent from cm.services.job.action import ActionRunPayload, run_action from cm.tests.mocks.task_runner import ETFMockWithEnvPreparation, JobImitator, RunTaskMock From 8bf88e018264d7a23f16e7a877fea01d47ee174c Mon Sep 17 00:00:00 2001 From: Egor Araslanov Date: Fri, 29 Mar 2024 16:34:56 +0500 Subject: [PATCH 031/208] ADCM-5444 Return `None` for `action_id` if no action bound to `JobLog` --- python/api/job/serializers.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/python/api/job/serializers.py b/python/api/job/serializers.py index 0f2f534cc1..903ad1ef55 100644 --- a/python/api/job/serializers.py +++ b/python/api/job/serializers.py @@ -191,10 +191,16 @@ class JobRetrieveSerializer(HyperlinkedModelSerializer): terminatable = SerializerMethodField() def get_selector(self, obj: JobLog): - return obj.task.selector + try: + return obj.task.selector + except AttributeError: + return {} def get_action_id(self, obj: JobLog): - return obj.action.id + try: + return obj.task.action.id + except AttributeError: + return None def get_sub_action_id(self, _: JobLog): return None From 32ad035a2e094cb7c68e56c4138731c5ca22b4e3 Mon Sep 17 00:00:00 2001 From: Pavel Nesterovkiy Date: Mon, 1 Apr 2024 07:27:13 +0000 Subject: [PATCH 032/208] feature/ADCM-5391 higlighter v2 to jobs table https://tracker.yandex.ru/ADCM-5391 --- .../JobLog/JobLogText/JobLogText.module.scss | 3 + .../job/JobLog/JobLogText/JobLogText.tsx | 7 +- .../CodeHighlighterV2.module.scss | 23 +- .../CodeHighlighterV2/CodeHighlighterV2.tsx | 15 +- .../uikit/ScrollBar/ScrollBar.stories.tsx | 204 ++++++++++++++++++ .../components/uikit/ScrollBar/ScrollBar.tsx | 27 +++ .../uikit/ScrollBar/ScrollBarHelper.ts | 70 ++++++ .../ScrollBar/ScrollBarStories.module.scss | 74 +++++++ .../uikit/ScrollBar/ScrollBarTypes.ts | 30 +++ .../uikit/ScrollBar/ScrollBarWrapper.tsx | 15 ++ .../uikit/ScrollBar/Scrollbar.module.scss | 80 +++++++ .../uikit/ScrollBar/useScrollBar.ts | 86 ++++++++ .../uikit/Table/TableRow/ExpandableRow.tsx | 3 +- adcm-web/app/src/scss/common.scss | 9 + 14 files changed, 627 insertions(+), 19 deletions(-) create mode 100644 adcm-web/app/src/components/common/job/JobLog/JobLogText/JobLogText.module.scss create mode 100644 adcm-web/app/src/components/uikit/ScrollBar/ScrollBar.stories.tsx create mode 100644 adcm-web/app/src/components/uikit/ScrollBar/ScrollBar.tsx create mode 100644 adcm-web/app/src/components/uikit/ScrollBar/ScrollBarHelper.ts create mode 100644 adcm-web/app/src/components/uikit/ScrollBar/ScrollBarStories.module.scss create mode 100644 adcm-web/app/src/components/uikit/ScrollBar/ScrollBarTypes.ts create mode 100644 adcm-web/app/src/components/uikit/ScrollBar/ScrollBarWrapper.tsx create mode 100644 adcm-web/app/src/components/uikit/ScrollBar/Scrollbar.module.scss create mode 100644 adcm-web/app/src/components/uikit/ScrollBar/useScrollBar.ts diff --git a/adcm-web/app/src/components/common/job/JobLog/JobLogText/JobLogText.module.scss b/adcm-web/app/src/components/common/job/JobLog/JobLogText/JobLogText.module.scss new file mode 100644 index 0000000000..3a5d46bcd1 --- /dev/null +++ b/adcm-web/app/src/components/common/job/JobLog/JobLogText/JobLogText.module.scss @@ -0,0 +1,3 @@ +.jobLogText { + margin-top: var(--base-margin-v); +} diff --git a/adcm-web/app/src/components/common/job/JobLog/JobLogText/JobLogText.tsx b/adcm-web/app/src/components/common/job/JobLog/JobLogText/JobLogText.tsx index 9dd9389a88..b110729168 100644 --- a/adcm-web/app/src/components/common/job/JobLog/JobLogText/JobLogText.tsx +++ b/adcm-web/app/src/components/common/job/JobLog/JobLogText/JobLogText.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import CodeHighlighter from '@uikit/CodeHighlighter/CodeHighlighter'; +import CodeHighlighterV2 from '@uikit/CodeHighlighterV2/CodeHighlighterV2'; import { AdcmJobLogItemCustom, AdcmJobLogItemStd } from '@models/adcm'; +import s from './JobLogText.module.scss'; interface JobLogTextProps { log: AdcmJobLogItemStd | AdcmJobLogItemCustom; @@ -8,8 +9,8 @@ interface JobLogTextProps { const JobLogText: React.FC = ({ log }) => { const content = log.content?.trim() || ''; - const language = log.format === 'json' ? 'json' : 'accesslog'; + const language = log.format === 'json' ? 'json' : 'bash'; - return ; + return ; }; export default JobLogText; diff --git a/adcm-web/app/src/components/uikit/CodeHighlighterV2/CodeHighlighterV2.module.scss b/adcm-web/app/src/components/uikit/CodeHighlighterV2/CodeHighlighterV2.module.scss index 996dcfb734..b1dccb1799 100644 --- a/adcm-web/app/src/components/uikit/CodeHighlighterV2/CodeHighlighterV2.module.scss +++ b/adcm-web/app/src/components/uikit/CodeHighlighterV2/CodeHighlighterV2.module.scss @@ -156,16 +156,15 @@ pre.codeHighlighterCode { font-size: 13px; } -.patch { - position: absolute; - box-sizing: unset; - left: 1px; - bottom: -1px; - height: 24px; - border-radius: 10px 0 0 10px; - overflow: hidden; - background: var(--code-highlite-numbers-backgroundV2); - border: 1px solid var(--code-highlite-borderV2); - z-index: 0; - border-bottom: none; +div.highlighterScrollVertical { + top: var(--default-scroll-height); + height: calc(100% - calc(var(--default-scroll-height) * 2)); + z-index: 2; } + +div.highlighterScrollHorizontal { + left: calc(var(--default-scroll-width) + var(--code-higlite-calc-line-width) + 0px); + width: calc(100% - var(--code-higlite-calc-line-width) - calc(var(--default-scroll-width) * 2) + 1px); + z-index: 2; +} + diff --git a/adcm-web/app/src/components/uikit/CodeHighlighterV2/CodeHighlighterV2.tsx b/adcm-web/app/src/components/uikit/CodeHighlighterV2/CodeHighlighterV2.tsx index 801493fc00..e230b7f03f 100644 --- a/adcm-web/app/src/components/uikit/CodeHighlighterV2/CodeHighlighterV2.tsx +++ b/adcm-web/app/src/components/uikit/CodeHighlighterV2/CodeHighlighterV2.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useMemo, useState } from 'react'; +import React, { ReactNode, useMemo, useRef, useState } from 'react'; import { refractor } from 'refractor'; import { getLines, getParsedCode } from '@uikit/CodeHighlighterV2/CodeHighlighterHelperV2'; import './CodeHighlighterTemeV2.scss'; @@ -6,6 +6,9 @@ import s from './CodeHighlighterV2.module.scss'; import cn from 'classnames'; import CopyButton from '@uikit/CodeHighlighter/CopyButton/CopyButton'; import IconButton from '@uikit/IconButton/IconButton'; +import ScrollBar from '@uikit/ScrollBar/ScrollBar'; +import ScrollBarWrapper from '@uikit/ScrollBar/ScrollBarWrapper'; + export interface CodeHighlighterV2Props { code: string; lang: string; @@ -27,6 +30,7 @@ const CodeHighlighterV2 = ({ }: CodeHighlighterV2Props) => { const [isSecretVisible, setIsSecretVisible] = useState(!isSecret); const prepCode = useMemo(() => (isSecretVisible ? code : code.replace(/./g, '*')), [code, isSecretVisible]); + const contentRef = useRef(null); const { parsedCode, lines, patchWidth } = useMemo(() => { const lines = getLines(prepCode); @@ -50,7 +54,6 @@ const CodeHighlighterV2 = ({ return (
-
{!isNotCopy && } {isSecret && ( )} -
+
{lines.map((lineNum) => ( @@ -74,6 +77,12 @@ const CodeHighlighterV2 = ({ {codeOverlay &&
{codeOverlay}
}
+ + + + + +
); }; diff --git a/adcm-web/app/src/components/uikit/ScrollBar/ScrollBar.stories.tsx b/adcm-web/app/src/components/uikit/ScrollBar/ScrollBar.stories.tsx new file mode 100644 index 0000000000..4c82aae676 --- /dev/null +++ b/adcm-web/app/src/components/uikit/ScrollBar/ScrollBar.stories.tsx @@ -0,0 +1,204 @@ +import React, { PropsWithChildren, RefObject, useRef } from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import ScrollBar from '@uikit/ScrollBar/ScrollBar'; +import ScrollBarWrapper from '@uikit/ScrollBar/ScrollBarWrapper'; +import s from './ScrollBarStories.module.scss'; +import { Text } from '@uikit'; + +type Story = StoryObj; + +export default { + title: 'uikit/ScrollBar', + component: ScrollBar, + argTypes: { + variant: { + defaultValue: 'vertical', + }, + }, +} as Meta; + +export const ScrollBarStory: Story = { + render: () => , +}; + +interface TextContentProps extends PropsWithChildren { + contentRef: RefObject; +} + +const TextContent = ({ contentRef, children }: TextContentProps) => { + return ( +
+ Chicken Coder: the incredible coder journey! +

+ Once upon a time, in the bustling town of Techtopia, there lived a peculiar chicken named Cluckbert. Unlike the + other chickens in the coop, Cluckbert was not content with the simple life of pecking at grains and strutting + around the yard. No, Cluckbert had grand dreams of becoming a coder. +

+

+ Every day, while the other chickens were busy with their usual activities, Cluckbert would perch himself on a + pile of hay in the corner of the coop, pecking away at an old keyboard he had found discarded in the barn. He + had a natural talent for understanding patterns and logic, and soon he began to experiment with simple lines of + code. +

+

+ His fellow chickens thought Cluckbert was simply scratching at the keys for fun, but little did they know that + he was actually teaching himself the basics of programming. He studied online tutorials, read coding books + scavenged from the farmer's library, and even attended virtual coding classes whenever he could find them. +

+

+ As days turned into weeks and weeks into months, Cluckbert's coding skills grew by leaps and bounds. He wrote + programs to help the farmer keep track of egg production, algorithms to optimize the feeding schedule for the + chickens, and even games to entertain his fellow coop-mates during their downtime. +

+

+ But Cluckbert's ambitions didn't stop there. He dreamed of creating something truly groundbreaking, something + that would change the world of technology forever. And so, with determination in his heart and a gleam in his + eye, Cluckbert set out to develop his masterpiece: an app that would revolutionize the way chickens communicated + with each other. +

+

+ Day and night, Cluckbert tirelessly worked on his project, pecking away at the keyboard with unwavering focus. + He encountered countless bugs and setbacks along the way, but he refused to give up. With each obstacle he + overcame, Cluckbert grew more determined to see his vision come to life. +

+

+ Finally, after months of hard work, Cluckbert unveiled his app to the world: "ChickChat." It was a messaging + platform designed specifically for chickens, complete with customizable emojis and built-in translation features + for different dialects. The response from the chicken community was overwhelmingly positive, and soon ChickChat + became the talk of the town. +

+

+ News of Cluckbert's remarkable achievement spread far and wide, attracting attention from tech enthusiasts and + poultry aficionados alike. Before long, he was invited to speak at coding conferences and innovation summits, + where he shared his story with audiences of humans and chickens alike. +

+

+ But amidst all the fame and recognition, Cluckbert remained humble and grounded. He never forgot his roots or + the coop-mates who had supported him from the beginning. And though he had achieved his dream of becoming a + coder, Cluckbert knew that his journey was far from over. +

+

+ For Cluckbert, the sky was the limit, and he couldn't wait to see where his coding adventures would take him + next. And so, with a contented cluck and a satisfied smile, he returned to his keyboard, ready to tackle + whatever challenges lay ahead. After all, for a chicken with a passion for coding, the world was full of endless + possibilities. +

+ + Unchecked Lines: The Story of Matilda's Code Catastrophe +

+ Once upon a time, in the bustling world of tech, there was a small but talented mouse named Matilda who worked + as a software engineer in a vibrant company called ByteTech Inc. Matilda was known for her exceptional coding + skills and her ability to churn out lines of code with lightning speed. However, there was one aspect of her job + that Matilda consistently neglected: code reviews. +

+

+ While her colleagues diligently reviewed each other's code, offering valuable feedback and catching potential + bugs before they became serious issues, Matilda preferred to work in isolation. She believed that her code was + flawless and didn't need the scrutiny of others. "Why waste time reviewing code when I can just get the job done + myself?" she would often muse, brushing off her colleagues' suggestions to participate in the review process. +

+

+ At first, Matilda's approach seemed to work. Her projects were completed on time, and her code appeared to + function smoothly. However, as time went on, cracks began to appear in Matilda's flawless facade. +

+

+ One day, the company launched a new software update that Matilda had been working on for weeks. Excited to see + her hard work come to fruition, Matilda eagerly clicked the "update" button, expecting smooth sailing ahead. +

+

+ But as soon as the update went live, disaster struck. Users reported a myriad of issues ranging from glitches + and crashes to security vulnerabilities. Panic swept through ByteTech Inc. as the company scrambled to address + the fallout from the faulty update. +

+

+ As the chaos unfolded around her, Matilda found herself in hot water. It quickly became apparent that the root + cause of the problems stemmed from her code, which had not undergone proper review and testing. Without the + fresh eyes of her colleagues to catch potential flaws, critical bugs had slipped through the cracks and wreaked + havoc on the company's software. Feeling a sinking sense of guilt and regret, Matilda realized the gravity of + her mistake. By neglecting to participate in code reviews, she had not only let down her colleagues but also + jeopardized the reputation of the entire company. +

+

+ In the aftermath of the debacle, Matilda faced the consequences of her actions. She was reprimanded by her + superiors and tasked with the arduous process of identifying and fixing the issues in her code. It was a + humbling experience for Matilda, who came to understand the importance of collaboration and peer review in the + world of software development. +

+

+ From that day forward, Matilda made a vow to always prioritize code reviews and to actively seek feedback from + her colleagues. Though the lesson had been learned the hard way, Matilda emerged from the experience as a wiser + and more conscientious engineer, determined never to repeat her past mistakes. And as she worked alongside her + fellow mice at ByteTech Inc., she knew that together, they could overcome any challenge that came their way. +

+ {children} +
+ ); +}; + +const ScrollBarExample = () => { + const contentRef = useRef(null); + const contentRefSecond = useRef(null); + + return ( + <> + Default scrollbar +
+ + + + + + + + +
+

+ "Test long text" is a phrase often used to verify the display and formatting of text in various contexts, + particularly in software development. It's a placeholder for content, allowing developers to assess how + text appears within a layout or interface before finalizing it with actual content. +

+
+
+
+ + Custom scroll bar, with container stretchable by width +
+
+ +
+ + + + + + + + + +
+

+ "Test long text" is a phrase often used to verify the display and formatting of text in various contexts, + particularly in software development. It's a placeholder for content, allowing developers to assess how + text appears within a layout or interface before finalizing it with actual content. +

+
+
+
+ + ); +}; diff --git a/adcm-web/app/src/components/uikit/ScrollBar/ScrollBar.tsx b/adcm-web/app/src/components/uikit/ScrollBar/ScrollBar.tsx new file mode 100644 index 0000000000..c41757a802 --- /dev/null +++ b/adcm-web/app/src/components/uikit/ScrollBar/ScrollBar.tsx @@ -0,0 +1,27 @@ +import React, { useRef } from 'react'; +import { ScrollBarProps } from '@uikit/ScrollBar/ScrollBarTypes'; +import { useScrollBar } from '@uikit/ScrollBar/useScrollBar'; +import s from '@uikit/ScrollBar/Scrollbar.module.scss'; +import cn from 'classnames'; + +const ScrollBar = ({ contentRef, orientation, trackClasses, thumbClasses, thumbCaptureRadius = 6 }: ScrollBarProps) => { + const thumbRef = useRef(null); + const trackRef = useRef(null); + + useScrollBar({ contentRef, thumbRef, trackRef, orientation }); + + const trackClassName = cn(s.defaultTrack, s[`defaultTrack_${orientation}`], trackClasses); + const thumbClassName = cn(s.defaultThumb, thumbClasses); + const thumbStyles = { + height: '100%', + '--thumb-capture-radius': `${thumbCaptureRadius}px`, + }; + + return ( +
+
+
+ ); +}; + +export default ScrollBar; diff --git a/adcm-web/app/src/components/uikit/ScrollBar/ScrollBarHelper.ts b/adcm-web/app/src/components/uikit/ScrollBar/ScrollBarHelper.ts new file mode 100644 index 0000000000..121ebd366e --- /dev/null +++ b/adcm-web/app/src/components/uikit/ScrollBar/ScrollBarHelper.ts @@ -0,0 +1,70 @@ +import { useMemo } from 'react'; +import { GetScrollDataResponse, ScrollDataProps, ScrollOrientation } from '@uikit/ScrollBar/ScrollBarTypes'; + +export const defaultScrollData: GetScrollDataResponse = { + scrollFactor: 1, + scrollTo: 'Top', + axisName: 'y', + upperCasedAxis: 'Y', +}; + +export const getScrollData = ({ contentRef, trackRef, thumbRef, orientation }: ScrollDataProps) => { + if (!contentRef?.current || !trackRef?.current || !thumbRef?.current) { + return defaultScrollData; + } + + const { magnitudeName, axisName, scrollTo } = getScrollHandlersParams(orientation); + + const lowerCasedMagnitudeName = magnitudeName.toLowerCase() as 'height' | 'width'; + const contentMagnitude = contentRef?.current?.[`client${magnitudeName}`]; + const contentScrollMagnitude = contentRef?.current?.[`scroll${magnitudeName}`]; + const scrollTrackMagnitude = trackRef?.current?.[`client${magnitudeName}`]; + + const scrollFactor = contentScrollMagnitude / scrollTrackMagnitude; + + const upperCasedAxis = axisName.toUpperCase() as 'Y' | 'X'; + + if (contentMagnitude !== contentScrollMagnitude) { + thumbRef.current.style[lowerCasedMagnitudeName] = `${(contentMagnitude * 100) / contentScrollMagnitude}%`; + trackRef.current.style.display = ''; + } else { + trackRef.current.style.display = 'none'; + } + + return { + scrollFactor, + upperCasedAxis, + scrollTo, + axisName, + }; +}; + +interface GetScrollHandlerParams { + magnitudeName: 'Height' | 'Width'; + axisName: 'y' | 'x'; + scrollTo: 'Left' | 'Top'; +} + +const getScrollHandlersParams = (orientation: ScrollOrientation): GetScrollHandlerParams => { + return orientation === 'vertical' + ? { + magnitudeName: 'Height', + axisName: 'y', + scrollTo: 'Top', + } + : { + magnitudeName: 'Width', + axisName: 'x', + scrollTo: 'Left', + }; +}; + +export const useObserver = (callBack: (entry: ResizeObserverEntry) => void) => { + return useMemo(() => { + return new ResizeObserver((entries) => { + for (const entry of entries) { + callBack(entry); + } + }); + }, [callBack]); +}; diff --git a/adcm-web/app/src/components/uikit/ScrollBar/ScrollBarStories.module.scss b/adcm-web/app/src/components/uikit/ScrollBar/ScrollBarStories.module.scss new file mode 100644 index 0000000000..4bd804d297 --- /dev/null +++ b/adcm-web/app/src/components/uikit/ScrollBar/ScrollBarStories.module.scss @@ -0,0 +1,74 @@ +:global { + body.theme-dark { + --scroll-content-wrapper: rgba(18, 18, 18, 1); + } + + body.theme-light { + --scroll-content-wrapper: rgba(246, 247, 252, 1); + } +} + + +.allMightyWrapper { + position: relative; + max-width: 900px; + min-width: 500px; + width: 100%; + height: 500px; + font-size: 18px; + line-height: 30px; + margin-bottom: 30px; + +} + +.contentWrapper { + box-sizing: border-box; + overflow: auto; + width: 100%; + border: 1px solid rebeccapurple; + padding: 40px; + position: relative; + height: 100%; + background: var(--scroll-content-wrapper); +} + +.longBlock { + width: 1400px; +} + +.stretchableBlock { + min-width: 700px; + width: 100%; +} + +.scrollTop { + left: 5%; + width: 90%; + background: #00c78791; + border-radius: 4px; +} + +.scrollWrapperLeft { + position: absolute; + top: 80px; + left: 0; + height: calc(100% - 160px); + border-radius: 4px; + width: 12px; + z-index: 1; + + &_track { + background: #8a2be259; + } +} + + + +.scrollBottom { + background: #0d77ff6b; + border-radius: 4px; +} + +.thumb { + background: #09a77bab; +} diff --git a/adcm-web/app/src/components/uikit/ScrollBar/ScrollBarTypes.ts b/adcm-web/app/src/components/uikit/ScrollBar/ScrollBarTypes.ts new file mode 100644 index 0000000000..98e10e550a --- /dev/null +++ b/adcm-web/app/src/components/uikit/ScrollBar/ScrollBarTypes.ts @@ -0,0 +1,30 @@ +import { RefObject } from 'react'; + +export type ScrollPosition = 'top' | 'left' | 'bottom' | 'right'; + +export type ScrollOrientation = 'vertical' | 'horizontal'; + +export interface Scroll { + orientation: ScrollOrientation; +} + +export interface ScrollBarProps extends Scroll { + contentRef: RefObject; + trackClasses?: string; + thumbClasses?: string; + thumbCaptureRadius?: number; +} + +export interface GetScrollDataResponse { + scrollFactor: number; + scrollTo: 'Top' | 'Left'; + axisName: 'y' | 'x'; + upperCasedAxis: 'Y' | 'X'; +} + +export interface ScrollDataProps { + contentRef: RefObject; + trackRef: RefObject; + thumbRef: RefObject; + orientation: ScrollOrientation; +} diff --git a/adcm-web/app/src/components/uikit/ScrollBar/ScrollBarWrapper.tsx b/adcm-web/app/src/components/uikit/ScrollBar/ScrollBarWrapper.tsx new file mode 100644 index 0000000000..1fc02fed43 --- /dev/null +++ b/adcm-web/app/src/components/uikit/ScrollBar/ScrollBarWrapper.tsx @@ -0,0 +1,15 @@ +import { PropsWithChildren } from 'react'; +import { ScrollPosition } from '@uikit/ScrollBar/ScrollBarTypes'; +import s from './Scrollbar.module.scss'; +import cn from 'classnames'; + +interface ScrollBarWrapper extends PropsWithChildren { + position: ScrollPosition; + className?: string; +} + +const ScrollBarWrapper = ({ children, position, className }: ScrollBarWrapper) => { + return
{children}
; +}; + +export default ScrollBarWrapper; diff --git a/adcm-web/app/src/components/uikit/ScrollBar/Scrollbar.module.scss b/adcm-web/app/src/components/uikit/ScrollBar/Scrollbar.module.scss new file mode 100644 index 0000000000..c11c81887a --- /dev/null +++ b/adcm-web/app/src/components/uikit/ScrollBar/Scrollbar.module.scss @@ -0,0 +1,80 @@ +body { + --default-scroll-height: 6px; + --default-scroll-width: 6px; +} + +.defaultTrack { + display: flex; + position: relative; + z-index: 1; + + &_horizontal { + align-items: center; + height: var(--default-scroll-height); + width: 100%; + + .defaultThumb::after { + width: 100%; + transform: translateY(calc(var(--thumb-capture-radius) / 2 * -1 - 0px)); + } + } + + &_vertical { + justify-content: center; + width: var(--default-scroll-width); + height: 100%; + + .defaultThumb::after { + height: 100%; + transform: translateX(calc(var(--thumb-capture-radius) / 2 * -1 - 0px)); + } + } +} + +.defaultThumb { + background: rgba(127, 130, 133, 0.2); + width: 100%; + height: 100%; + border-radius: 4px; + position: relative; + + &::after { + position: absolute; + display: block; + content: ''; + height: calc(var(--thumb-capture-radius) * 2); + width: calc(var(--thumb-capture-radius) * 2); + } +} + +.scrollBarWrapper { + position: absolute; + + &_top { + top: 0; + left: 0; + height: var(--default-scroll-height); + width: 100%; + } + + &_bottom { + bottom: 0; + left: 0; + height: var(--default-scroll-height); + width: 100%; + } + + &_left { + top: 0; + left: 0; + width: var(--default-scroll-width); + height: 100%; + } + + &_right { + top: 0; + right: 0; + width: var(--default-scroll-width); + height: 100%; + } +} diff --git a/adcm-web/app/src/components/uikit/ScrollBar/useScrollBar.ts b/adcm-web/app/src/components/uikit/ScrollBar/useScrollBar.ts new file mode 100644 index 0000000000..79126645f5 --- /dev/null +++ b/adcm-web/app/src/components/uikit/ScrollBar/useScrollBar.ts @@ -0,0 +1,86 @@ +import { GetScrollDataResponse, ScrollDataProps } from '@uikit/ScrollBar/ScrollBarTypes'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { defaultScrollData, getScrollData, useObserver } from '@uikit/ScrollBar/ScrollBarHelper'; + +export const useScrollBar = ({ orientation, contentRef, thumbRef, trackRef }: ScrollDataProps) => { + const [scrollData, setScrollData] = useState(defaultScrollData); + const initialMousePosition = useRef({ x: 0, y: 0 }); + + const updateScrollData = useCallback(() => { + if (!contentRef.current || !thumbRef.current || !thumbRef.current) return; + setScrollData(getScrollData({ contentRef, trackRef, thumbRef, orientation })); + }, [contentRef, orientation, trackRef, thumbRef]); + + const resizeObserver = useObserver(updateScrollData); + + useEffect(() => { + const curContent = contentRef?.current; + const curThumb = thumbRef?.current; + + curContent?.addEventListener('scroll', scrollHandler); + curThumb?.addEventListener('pointerdown', onMouseDown); + curContent?.classList.add('scrollBar'); + + return () => { + clearDocumentHandlers(); + curContent?.removeEventListener('scroll', scrollHandler); + curThumb?.removeEventListener('pointerdown', onMouseDown); + }; + }, [scrollData, contentRef.current, thumbRef.current, orientation]); + + const onMouseDown = useCallback( + (e: PointerEvent) => { + if (!thumbRef?.current || !trackRef?.current) return; + + const thumbPosition = thumbRef.current.getBoundingClientRect(); + const trackPosition = trackRef.current.getBoundingClientRect(); + + initialMousePosition.current.x = e.clientX - (thumbPosition.left - trackPosition.left); + initialMousePosition.current.y = e.clientY - (thumbPosition.top - trackPosition.top); + + thumbRef.current.setPointerCapture(e.pointerId); + thumbRef.current.addEventListener('pointermove', onPointerMove); + thumbRef.current.addEventListener('pointerup', onPointerUp); + }, + [thumbRef?.current, trackRef?.current], + ); + + const scrollHandler = useCallback(() => { + if (!thumbRef?.current || !contentRef?.current) return; + + thumbRef.current.style.transform = `translate${scrollData.upperCasedAxis}(${ + contentRef.current[`scroll${scrollData.scrollTo}`] / scrollData.scrollFactor + }px)`; + }, [scrollData, contentRef?.current, thumbRef?.current]); + + const onPointerMove = useCallback( + (e: MouseEvent) => { + if (!contentRef?.current || !trackRef?.current || !thumbRef?.current) return; + + contentRef.current[`scroll${scrollData.scrollTo}`] = + (e[`client${scrollData.upperCasedAxis}`] - initialMousePosition.current[scrollData.axisName]) * + scrollData.scrollFactor; + }, + [scrollData, contentRef?.current, thumbRef?.current], + ); + + useEffect(() => { + if (!contentRef?.current) return; + resizeObserver.observe(contentRef.current); + + return () => { + if (!contentRef?.current) return; + resizeObserver.unobserve(contentRef.current); + }; + }, [contentRef?.current]); + + const clearDocumentHandlers = () => { + if (!thumbRef?.current) return; + thumbRef.current.removeEventListener('pointermove', onPointerMove); + thumbRef.current.removeEventListener('pointerup', onPointerUp); + }; + + const onPointerUp = () => { + clearDocumentHandlers(); + }; +}; diff --git a/adcm-web/app/src/components/uikit/Table/TableRow/ExpandableRow.tsx b/adcm-web/app/src/components/uikit/Table/TableRow/ExpandableRow.tsx index 544c747401..0d8dbd0325 100644 --- a/adcm-web/app/src/components/uikit/Table/TableRow/ExpandableRow.tsx +++ b/adcm-web/app/src/components/uikit/Table/TableRow/ExpandableRow.tsx @@ -35,7 +35,8 @@ const ExpandableRow = ({ const setRowNewWidth = useCallback(() => { if (!refRow.current) return; - setRowWidth(refRow.current.offsetWidth); + const parent = refRow.current.closest(`.${t.tableWrapper}`) as HTMLDivElement; + setRowWidth(parent ? parent.offsetWidth : refRow.current.offsetWidth); }, []); useResizeObserver(refRow, setRowNewWidth); diff --git a/adcm-web/app/src/scss/common.scss b/adcm-web/app/src/scss/common.scss index b0b485b31b..bed1e31220 100644 --- a/adcm-web/app/src/scss/common.scss +++ b/adcm-web/app/src/scss/common.scss @@ -87,3 +87,12 @@ strong { margin-left: calc(-1 * var(--page-padding-h)); margin-right: calc(-1 * var(--page-padding-h)); } + +.scrollBar { + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} From 4cf388e4fa6d2ed6d548977a0271443866680065 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Tue, 2 Apr 2024 07:46:58 +0000 Subject: [PATCH 033/208] ADCM-5413 Rework `flag` raising/lowering functions and replace `allow_flags` with `flag_autogeneration` --- python/adcm/tests/base.py | 31 +- .../plugins/action/adcm_change_flag.py | 18 +- .../cluster_with_allowed_flags/config.yaml | 3 +- python/api_v2/tests/test_concerns.py | 3 +- python/cm/adcm_schema.yaml | 9 +- python/cm/api.py | 19 +- python/cm/bundle.py | 51 +++- python/cm/converters.py | 10 +- python/cm/flag.py | 97 ------- python/cm/issue.py | 8 +- ...late.py => 0119_delete_MessageTemplate.py} | 2 +- ...rn_item.py => 0120_change_concern_item.py} | 4 +- .../0121_flag_autogeneration_object.py | 43 +++ python/cm/models.py | 8 +- python/cm/services/concern/flags.py | 157 +++++++++++ python/cm/services/concern/messages.py | 26 +- .../cm/services/job/run/_task_finalizers.py | 6 +- python/cm/services/maintenance_mode.py | 2 +- python/cm/stack.py | 5 +- .../cluster_true/config.yaml | 39 +++ .../cluster_undefined/config.yaml | 36 +++ .../provider_false_host_true/config.yaml | 10 + .../provider_true_host_undefined/config.yaml | 10 + python/cm/tests/test_bundle.py | 46 ++- python/cm/tests/test_flag.py | 265 ++++++++++++++---- python/cm/tests/test_inventory/base.py | 31 +- python/cm/tests/test_job.py | 37 --- 27 files changed, 695 insertions(+), 281 deletions(-) delete mode 100644 python/cm/flag.py rename python/cm/migrations/{0116_delete_MessageTemplate.py => 0119_delete_MessageTemplate.py} (93%) rename python/cm/migrations/{0117_change_concern_item.py => 0120_change_concern_item.py} (94%) create mode 100644 python/cm/migrations/0121_flag_autogeneration_object.py create mode 100644 python/cm/services/concern/flags.py create mode 100644 python/cm/tests/bundles/flag_autogeneration/cluster_true/config.yaml create mode 100644 python/cm/tests/bundles/flag_autogeneration/cluster_undefined/config.yaml create mode 100644 python/cm/tests/bundles/flag_autogeneration/provider_false_host_true/config.yaml create mode 100644 python/cm/tests/bundles/flag_autogeneration/provider_true_host_undefined/config.yaml diff --git a/python/adcm/tests/base.py b/python/adcm/tests/base.py index c982f8fd0f..0c8384f0b0 100644 --- a/python/adcm/tests/base.py +++ b/python/adcm/tests/base.py @@ -15,14 +15,15 @@ from pathlib import Path from shutil import rmtree from tempfile import mkdtemp -from typing import Iterable, TypedDict +from typing import Callable, Iterable, TypedDict import random import string import tarfile +from api_v2.config.utils import convert_adcm_meta_to_attr, convert_attr_to_adcm_meta from api_v2.prototype.utils import accept_license from api_v2.service.utils import bulk_add_services_to_cluster -from cm.api import add_cluster, add_hc, add_host, add_host_provider, add_host_to_cluster +from cm.api import add_cluster, add_hc, add_host, add_host_provider, add_host_to_cluster, update_obj_config from cm.bundle import prepare_bundle, process_file from cm.models import ( ADCM, @@ -32,6 +33,7 @@ Cluster, ClusterObject, ConfigLog, + GroupConfig, Host, HostComponent, HostProvider, @@ -39,6 +41,7 @@ Prototype, ServiceComponent, ) +from cm.utils import deep_merge from core.rbac.dto import UserCreateDTO from django.conf import settings from django.db.models import QuerySet @@ -538,3 +541,27 @@ def grant_permissions(self, to: User, on: list[ADCMEntity] | ADCMEntity, role_na if delete_role: custom_role.delete() group.delete() + + @staticmethod + def change_configuration( + target: ADCMModel | GroupConfig, + config_diff: dict, + meta_diff: dict | None = None, + preprocess_config: Callable[[dict], dict] = lambda x: x, + ) -> ConfigLog: + meta = meta_diff or {} + + target.refresh_from_db() + current_config = ConfigLog.objects.get(id=target.config.current) + + updated = update_obj_config( + obj_conf=target.config, + config=deep_merge(origin=preprocess_config(current_config.config), renovator=config_diff), + attr=convert_adcm_meta_to_attr( + deep_merge(origin=convert_attr_to_adcm_meta(current_config.attr), renovator=meta) + ), + description="", + ) + target.refresh_from_db() + + return updated diff --git a/python/ansible/plugins/action/adcm_change_flag.py b/python/ansible/plugins/action/adcm_change_flag.py index e4c6f17304..a2accbfb14 100644 --- a/python/ansible/plugins/action/adcm_change_flag.py +++ b/python/ansible/plugins/action/adcm_change_flag.py @@ -9,6 +9,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + DOCUMENTATION = """ --- module: adcm_change_flag @@ -66,13 +67,15 @@ from ansible.errors import AnsibleError from ansible.plugins.action import ActionBase +from cm.converters import orm_object_to_core_type +from cm.services.concern.flags import BuiltInFlag, lower_flag, raise_flag +from core.types import CoreObjectDescriptor sys.path.append("/adcm/python") import adcm.init_django # noqa: F401, isort:skip from ansible_plugin.utils import check_context_type, get_context_object -from cm.flag import remove_flag, update_object_flag from cm.logger import logger from cm.models import ( ADCMEntity, @@ -183,9 +186,9 @@ def run(self, tmp=None, task_vars=None): super().run(tmp, task_vars) self._check_args() - msg = "" - if "msg" in self._task.args: - msg = str(self._task.args["msg"]) + # msg = "" + # if "msg" in self._task.args: + # msg = str(self._task.args["msg"]) objects = [] context_obj = get_context_object(task_vars=task_vars) @@ -195,11 +198,14 @@ def run(self, tmp=None, task_vars=None): objects.append(context_obj) for obj in objects: + target = CoreObjectDescriptor(id=obj.id, type=orm_object_to_core_type(obj)) if self._task.args["operation"] == "up": - update_object_flag(obj=obj, msg=msg) + # todo rework + raise_flag(flag=BuiltInFlag.ADCM_OUTDATED_CONFIG.value, on_objects=[target]) send_object_update_event(object_=obj, changes={"status": ADCMEntityStatus.UP.value}) elif self._task.args["operation"] == "down": - remove_flag(obj=obj, msg=msg) + # todo rework + lower_flag(name=BuiltInFlag.ADCM_OUTDATED_CONFIG.value.name, on_objects=[target]) send_object_update_event(object_=obj, changes={"status": ADCMEntityStatus.DOWN.value}) return {"failed": False, "changed": True} diff --git a/python/api_v2/tests/bundles/cluster_with_allowed_flags/config.yaml b/python/api_v2/tests/bundles/cluster_with_allowed_flags/config.yaml index af8fdb5d70..87f73facdb 100644 --- a/python/api_v2/tests/bundles/cluster_with_allowed_flags/config.yaml +++ b/python/api_v2/tests/bundles/cluster_with_allowed_flags/config.yaml @@ -3,7 +3,8 @@ name: cluster_with_allowed_flags version: '1.0' edition: community - allow_flags: true + flag_autogeneration: + enable_outdated_config: true config: - name: string type: string diff --git a/python/api_v2/tests/test_concerns.py b/python/api_v2/tests/test_concerns.py index 0d0f386a6c..00b96f9d41 100644 --- a/python/api_v2/tests/test_concerns.py +++ b/python/api_v2/tests/test_concerns.py @@ -118,8 +118,7 @@ def test_required_hc_concern(self): def test_outdated_config_flag(self): cluster = self.add_cluster(bundle=self.config_flag_bundle, name="config_flag_cluster") expected_concern_reason = { - # todo fix expectations, because it was expected CONFIG_FLAG - "message": ConcernMessage.FLAG.template.message, + "message": f"{ConcernMessage.FLAG.template.message}outdated config", "placeholder": {"source": {"name": cluster.name, "params": {"clusterId": cluster.pk}, "type": "cluster"}}, } diff --git a/python/cm/adcm_schema.yaml b/python/cm/adcm_schema.yaml index 0f2ae0777b..751771524d 100644 --- a/python/cm/adcm_schema.yaml +++ b/python/cm/adcm_schema.yaml @@ -34,12 +34,17 @@ common_object: &common_object config: config_obj actions: actions_dict venv: string - allow_flags: boolean + flag_autogeneration: flag_autogeneration_object required_items: - type - name - version +flag_autogeneration_object: + match: dict + items: + enable_outdated_config: boolean + service_object: <<: *common_object items: @@ -117,7 +122,7 @@ component_dict: config: config_obj actions: actions_dict config_group_customization: boolean - allow_flags: boolean + flag_autogeneration: flag_autogeneration_object venv: string comp_req_list: diff --git a/python/cm/api.py b/python/cm/api.py index 2719ec5215..1e54b17f01 100644 --- a/python/cm/api.py +++ b/python/cm/api.py @@ -15,6 +15,7 @@ import json from adcm_version import compare_prototype_versions +from core.types import CoreObjectDescriptor from django.contrib.contenttypes.models import ContentType from django.core.exceptions import MultipleObjectsReturned from django.db.transaction import atomic, on_commit @@ -29,8 +30,8 @@ ) from cm.adcm_config.utils import proto_ref from cm.api_context import CTX +from cm.converters import orm_object_to_core_type from cm.errors import AdcmEx, raise_adcm_ex -from cm.flag import update_object_flag from cm.issue import ( add_concern_to_object, check_bound_components, @@ -47,6 +48,7 @@ Cluster, ClusterBind, ClusterObject, + ConcernItem, ConcernType, ConfigLog, GroupConfig, @@ -61,6 +63,7 @@ ServiceComponent, TaskLog, ) +from cm.services.concern.flags import BuiltInFlag, raise_flag, update_hierarchy from cm.services.status.notify import reset_hc_map, reset_objects_in_mm from cm.status_api import ( send_config_creation_event, @@ -458,7 +461,19 @@ def update_obj_config(obj_conf: ObjectConfig, config: dict, attr: dict, descript with atomic(): config_log = save_object_config(object_config=obj_conf, config=new_conf, attr=attr, description=description) update_hierarchy_issues(obj=obj) - update_object_flag(obj=obj) + + if obj.prototype.flag_autogeneration.get("enable_outdated_config", False): + # todo implement it in a better way + flag = BuiltInFlag.ADCM_OUTDATED_CONFIG.value + flag_exists = obj.concerns.filter(name=flag.name, type=ConcernType.FLAG).exists() + raise_flag(flag=flag, on_objects=[CoreObjectDescriptor(id=obj.id, type=orm_object_to_core_type(obj))]) + if not flag_exists: + update_hierarchy( + concern=ConcernItem.objects.get( + name=flag.name, type=ConcernType.FLAG, owner_id=obj.id, owner_type=obj.content_type + ) + ) + apply_policy_for_new_config(config_object=obj, config_log=config_log) send_config_creation_event(object_=obj) diff --git a/python/cm/bundle.py b/python/cm/bundle.py index 0903872fa6..98400cdb28 100644 --- a/python/cm/bundle.py +++ b/python/cm/bundle.py @@ -9,7 +9,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - +from collections import defaultdict from collections.abc import Iterable from pathlib import Path import os @@ -77,6 +77,7 @@ def prepare_bundle( re_check_actions() re_check_components() re_check_config() + propagate_flag_autogeneration(bundle_prototype) bundle = copy_stage( bundle_hash=bundle_hash, bundle_proto=bundle_prototype, verification_status=verification_status @@ -97,6 +98,42 @@ def prepare_bundle( raise error +def propagate_flag_autogeneration(bundle_main_prototype: StagePrototype): + # note that more complex `flag_autogeneration` structure will require + # much more complex work here + + if bundle_main_prototype.flag_autogeneration == {}: + bundle_main_prototype.flag_autogeneration = {"enable_outdated_config": False} + bundle_main_prototype.save(update_fields=["flag_autogeneration"]) + + parent_value = bundle_main_prototype.flag_autogeneration["enable_outdated_config"] + + if bundle_main_prototype.type == ObjectType.PROVIDER: + StagePrototype.objects.filter(type=ObjectType.HOST, flag_autogeneration={}).update( + flag_autogeneration={"enable_outdated_config": parent_value} + ) + return + + StagePrototype.objects.filter(type=ObjectType.SERVICE, flag_autogeneration={}).update( + flag_autogeneration={"enable_outdated_config": parent_value} + ) + + service_component_map = defaultdict(set) + for component_proto_id, service_proto_id in StagePrototype.objects.values_list("id", "parent_id").filter( + type=ObjectType.COMPONENT, flag_autogeneration={} + ): + service_component_map[service_proto_id].add(component_proto_id) + + for service_proto_id, components_proto_ids in service_component_map.items(): + StagePrototype.objects.filter(id__in=components_proto_ids).update( + flag_autogeneration={ + "enable_outdated_config": StagePrototype.objects.values_list("flag_autogeneration", flat=True).get( + id=service_proto_id + )["enable_outdated_config"] + } + ) + + def load_bundle(bundle_file: str) -> Bundle: logger.info('loading bundle file "%s" ...', bundle_file) bundle_hash, path = process_file(bundle_file=bundle_file) @@ -673,7 +710,7 @@ def copy_stage_prototype(stage_prototypes, bundle): "venv", "config_group_customization", "allow_maintenance_mode", - "allow_flags", + "flag_autogeneration", ), ) if proto.license_path: @@ -825,7 +862,7 @@ def copy_stage_component(stage_components, stage_proto, prototype, bundle): "description", "adcm_min_version", "config_group_customization", - "allow_flags", + "flag_autogeneration", "venv", ), ) @@ -948,9 +985,7 @@ def copy_stage(bundle_hash: str, bundle_proto, verification_status: SignatureSta return bundle -def update_bundle_from_stage( - bundle, -): +def update_bundle_from_stage(bundle): for stage_prototype in StagePrototype.objects.order_by("id"): try: prototype = Prototype.objects.get( @@ -970,7 +1005,7 @@ def update_bundle_from_stage( prototype.venv = stage_prototype.venv prototype.config_group_customization = stage_prototype.config_group_customization prototype.allow_maintenance_mode = stage_prototype.allow_maintenance_mode - prototype.allow_flags = stage_prototype.allow_flags + prototype.flag_autogeneration = stage_prototype.flag_autogeneration except Prototype.DoesNotExist: prototype = copy_obj( stage_prototype, @@ -992,7 +1027,7 @@ def update_bundle_from_stage( "venv", "config_group_customization", "allow_maintenance_mode", - "allow_flags", + "flag_autogeneration", ), ) prototype.bundle = bundle diff --git a/python/cm/converters.py b/python/cm/converters.py index aa6e0da533..93559f39bc 100644 --- a/python/cm/converters.py +++ b/python/cm/converters.py @@ -9,8 +9,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - from core.types import ADCMCoreType +from django.db.models import Model from cm.models import ADCM, Cluster, ClusterObject, Host, HostProvider, ServiceComponent @@ -75,3 +75,11 @@ def model_name_to_core_type(model_name: str) -> ADCMCoreType: return ADCMCoreType.COMPONENT raise + + +def model_to_core_type(model: type[Model]) -> ADCMCoreType: + return model_name_to_core_type(model_name=model.__name__.lower()) + + +def orm_object_to_core_type(object_: Cluster | ClusterObject | ServiceComponent | HostProvider | Host) -> ADCMCoreType: + return model_to_core_type(model=object_.__class__) diff --git a/python/cm/flag.py b/python/cm/flag.py deleted file mode 100644 index fce6f8225a..0000000000 --- a/python/cm/flag.py +++ /dev/null @@ -1,97 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from cm.hierarchy import Tree -from cm.issue import add_concern_to_object, remove_concern_from_object -from cm.models import ADCMEntity, ConcernCause, ConcernItem, ConcernType -from cm.services.concern.messages import ConcernMessage, PlaceholderObjectsDTO, build_concern_reason - - -def get_flag_name(obj: ADCMEntity, msg: str = "") -> str: - # todo it should be changed (adjusted to one format) - # after plugin is reworked - name = f"{obj} has an outdated configuration" - if msg: - name = f"{name}: {msg}" - - return name - - -def create_flag(obj: ADCMEntity, msg: str = "") -> ConcernItem: - # todo make correct message preparation - reason = build_concern_reason( - concern_message=ConcernMessage.FLAG, placeholder_objects=PlaceholderObjectsDTO(source=obj) - ) - if msg: - # todo it should be changed (adjusted to one format) - # after plugin is reworked - reason["message"] = f"{reason['message']}: {msg}" - - return ConcernItem.objects.create( - type=ConcernType.FLAG, - name=get_flag_name(obj, msg), - reason=reason, - owner=obj, - cause=ConcernCause.CONFIG, - blocking=False, - ) - - -def remove_flag(obj: ADCMEntity, msg: str = "") -> None: - flag = get_own_flag(owner=obj, msg=msg) - if not flag: - return - - flag.delete() - - -def get_own_flag(owner: ADCMEntity, msg: str) -> ConcernItem: - return ConcernItem.objects.filter( - type=ConcernType.FLAG, owner_id=owner.pk, owner_type=owner.content_type, name=get_flag_name(owner, msg) - ).first() - - -def update_hierarchy(concern: ConcernItem) -> None: - tree = Tree(obj=concern.owner) - - related = set(concern.related_objects) - affected = {node.value for node in tree.get_directly_affected(node=tree.built_from)} - - if related == affected: - return - - for object_moved_out_hierarchy in related.difference(affected): - remove_concern_from_object(object_=object_moved_out_hierarchy, concern=concern) - - for new_object in affected.difference(related): - add_concern_to_object(object_=new_object, concern=concern) - - -def update_flags() -> None: - for flag in ConcernItem.objects.filter(type=ConcernType.FLAG): - if flag.owner is None: - flag.delete() - continue - - update_hierarchy(concern=flag) - - -def update_object_flag(obj: ADCMEntity, msg: str = "") -> None: - if not obj.prototype.allow_flags: - return - - flag = get_own_flag(owner=obj, msg=msg) - - if not flag: - flag = create_flag(obj=obj, msg=msg) - - update_hierarchy(concern=flag) diff --git a/python/cm/issue.py b/python/cm/issue.py index ca0a122313..234c8329cc 100755 --- a/python/cm/issue.py +++ b/python/cm/issue.py @@ -425,7 +425,9 @@ def _get_kwargs_for_issue(concern_name: ConcernMessage, source: ADCMEntity) -> d def create_issue(obj: ADCMEntity, issue_cause: ConcernCause) -> ConcernItem: concern_message = _issue_template_map[issue_cause] kwargs = _get_kwargs_for_issue(concern_name=concern_message, source=obj) - reason = build_concern_reason(concern_message=concern_message, placeholder_objects=PlaceholderObjectsDTO(**kwargs)) + reason = build_concern_reason( + template=concern_message.template, placeholder_objects=PlaceholderObjectsDTO(**kwargs) + ) type_: str = ConcernType.ISSUE.value cause: str = issue_cause.value return ConcernItem.objects.create( @@ -555,7 +557,7 @@ def create_lock(owner: ADCMEntity, job: JobLog): type=type_, name=f"{cause or ''}_{type_}".strip("_"), reason=build_concern_reason( - ConcernMessage.LOCKED_BY_JOB, placeholder_objects=PlaceholderObjectsDTO(job=job, target=owner) + ConcernMessage.LOCKED_BY_JOB.template, placeholder_objects=PlaceholderObjectsDTO(job=job, target=owner) ), blocking=True, owner=owner, @@ -565,7 +567,7 @@ def create_lock(owner: ADCMEntity, job: JobLog): def update_job_in_lock_reason(lock: ConcernItem, job: JobLog) -> ConcernItem: lock.reason = build_concern_reason( - ConcernMessage.LOCKED_BY_JOB, placeholder_objects=PlaceholderObjectsDTO(job=job, target=lock.owner) + ConcernMessage.LOCKED_BY_JOB.template, placeholder_objects=PlaceholderObjectsDTO(job=job, target=lock.owner) ) lock.save(update_fields=["reason"]) return lock diff --git a/python/cm/migrations/0116_delete_MessageTemplate.py b/python/cm/migrations/0119_delete_MessageTemplate.py similarity index 93% rename from python/cm/migrations/0116_delete_MessageTemplate.py rename to python/cm/migrations/0119_delete_MessageTemplate.py index 46d5701186..79f42922a6 100644 --- a/python/cm/migrations/0116_delete_MessageTemplate.py +++ b/python/cm/migrations/0119_delete_MessageTemplate.py @@ -17,7 +17,7 @@ class Migration(migrations.Migration): dependencies = [ - ("cm", "0115_auto_20231025_1823"), + ("cm", "0118_extract_sub_actions_from_actions"), ] operations = [migrations.DeleteModel(name="MessageTemplate")] diff --git a/python/cm/migrations/0117_change_concern_item.py b/python/cm/migrations/0120_change_concern_item.py similarity index 94% rename from python/cm/migrations/0117_change_concern_item.py rename to python/cm/migrations/0120_change_concern_item.py index 002b568e4a..70362b9595 100644 --- a/python/cm/migrations/0117_change_concern_item.py +++ b/python/cm/migrations/0120_change_concern_item.py @@ -27,7 +27,7 @@ def remove_unlinked_concerns(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ ("contenttypes", "0002_remove_content_type_name"), - ("cm", "0116_delete_MessageTemplate"), + ("cm", "0119_delete_MessageTemplate"), ] operations = [ @@ -73,7 +73,7 @@ class Migration(migrations.Migration): migrations.AddConstraint( model_name="concernitem", constraint=models.UniqueConstraint( - fields=("name", "owner_id", "owner_type"), name="cm_concernitem_name_owner_uc" + fields=("name", "owner_id", "owner_type", "type"), name="cm_concernitem_name_owner_uc" ), ), ] diff --git a/python/cm/migrations/0121_flag_autogeneration_object.py b/python/cm/migrations/0121_flag_autogeneration_object.py new file mode 100644 index 0000000000..44e991e6ad --- /dev/null +++ b/python/cm/migrations/0121_flag_autogeneration_object.py @@ -0,0 +1,43 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated by Django 3.2.23 on 2024-04-01 07:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cm', '0120_change_concern_item'), + ] + + operations = [ + migrations.RemoveField( + model_name='prototype', + name='allow_flags', + ), + migrations.RemoveField( + model_name='stageprototype', + name='allow_flags', + ), + migrations.AddField( + model_name='prototype', + name='flag_autogeneration', + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name='stageprototype', + name='flag_autogeneration', + field=models.JSONField(default=dict), + ), + ] diff --git a/python/cm/models.py b/python/cm/models.py index 58ef19e9ce..5dc10c36cb 100644 --- a/python/cm/models.py +++ b/python/cm/models.py @@ -247,7 +247,7 @@ class Prototype(ADCMModel): config_group_customization = models.BooleanField(default=False) venv = models.CharField(default="default", max_length=1000, blank=False) allow_maintenance_mode = models.BooleanField(default=False) - allow_flags = models.BooleanField(default=False) + flag_autogeneration = models.JSONField(default=dict) __error_code__ = "PROTOTYPE_NOT_FOUND" @@ -1508,7 +1508,7 @@ class StagePrototype(ADCMModel): config_group_customization = models.BooleanField(default=False) venv = models.CharField(default="default", max_length=1000, blank=False) allow_maintenance_mode = models.BooleanField(default=False) - allow_flags = models.BooleanField(default=False) + flag_autogeneration = models.JSONField(default=dict) __error_code__ = "PROTOTYPE_NOT_FOUND" @@ -1625,7 +1625,9 @@ class ConcernItem(ADCMModel): class Meta: constraints = [ - models.UniqueConstraint(name="cm_concernitem_name_owner_uc", fields=("name", "owner_id", "owner_type")) + models.UniqueConstraint( + name="cm_concernitem_name_owner_uc", fields=("name", "owner_id", "owner_type", "type") + ) ] @property diff --git a/python/cm/services/concern/flags.py b/python/cm/services/concern/flags.py new file mode 100644 index 0000000000..1024f3972d --- /dev/null +++ b/python/cm/services/concern/flags.py @@ -0,0 +1,157 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections import defaultdict +from dataclasses import dataclass +from enum import Enum +from functools import reduce +from itertools import chain +from operator import or_ +from typing import Collection + +from core.types import CoreObjectDescriptor +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q + +from cm.converters import core_type_to_model +from cm.hierarchy import Tree +from cm.issue import add_concern_to_object, remove_concern_from_object +from cm.models import ADCMEntity, ConcernCause, ConcernItem, ConcernType +from cm.services.concern.messages import ( + ADCM_ENTITY_AS_PLACEHOLDERS, + ConcernMessageTemplate, + PlaceholderObjectsDTO, + build_concern_reason, +) + + +@dataclass(slots=True, frozen=True) +class ConcernFlag: + name: str + message: str + cause: ConcernCause | None = None + + +class BuiltInFlag(Enum): + ADCM_OUTDATED_CONFIG = ConcernFlag( + name="adcm_outdated_config", message="outdated config", cause=ConcernCause.CONFIG + ) + + +def raise_flag(flag: ConcernFlag, on_objects: Collection[CoreObjectDescriptor]) -> None: + message_template = ConcernMessageTemplate( + message=f"${{source}} has a flag: {flag.message}".rstrip(), placeholders=ADCM_ENTITY_AS_PLACEHOLDERS + ) + + content_type_id_map = _get_owner_ids_grouped_by_content_type(objects=on_objects) + + existing_concerns = ConcernItem.objects.filter( + Q(name=flag.name) & _get_filter_for_flags_of_objects(content_type_id_map=content_type_id_map) + ) + + processed_objects: dict[ContentType, set[int]] = {content_type: set() for content_type in content_type_id_map} + for concern in existing_concerns: + concern.reason["message"] = message_template.message + processed_objects[concern.owner_type].add(concern.owner_id) + + if processed_objects: + ConcernItem.objects.bulk_update(objs=existing_concerns, fields=["reason"]) + + objects_without_flags: tuple[ADCMEntity, ...] = tuple( + chain.from_iterable( + content_type.model_class().objects.filter(id__in=requested_ids - processed_objects[content_type]) + for content_type, requested_ids in content_type_id_map.items() + ) + ) + + if not objects_without_flags: + return + + ConcernItem.objects.bulk_create( + objs=( + ConcernItem( + owner=object_, + type=ConcernType.FLAG, + cause=flag.cause, + name=flag.name, + blocking=False, + reason=build_concern_reason( + template=message_template, placeholder_objects=PlaceholderObjectsDTO(source=object_) + ), + ) + for object_ in objects_without_flags + ) + ) + + +def lower_flag(name: str, on_objects: Collection[CoreObjectDescriptor]) -> None: + ConcernItem.objects.filter( + Q(name=name) + & _get_filter_for_flags_of_objects( + content_type_id_map=_get_owner_ids_grouped_by_content_type(objects=on_objects) + ) + ).delete() + + +def lower_all_flags(on_objects: Collection[CoreObjectDescriptor]) -> None: + ConcernItem.objects.filter( + _get_filter_for_flags_of_objects(content_type_id_map=_get_owner_ids_grouped_by_content_type(objects=on_objects)) + ).delete() + + +def update_hierarchy(concern: ConcernItem) -> None: + tree = Tree(obj=concern.owner) + + related = set(concern.related_objects) + affected = {node.value for node in tree.get_directly_affected(node=tree.built_from)} + + if related == affected: + return + + for object_moved_out_hierarchy in related.difference(affected): + remove_concern_from_object(object_=object_moved_out_hierarchy, concern=concern) + + for new_object in affected.difference(related): + add_concern_to_object(object_=new_object, concern=concern) + + +# todo check if it's really should be a separate function in the place where it's called +def update_flags() -> None: + for flag in ConcernItem.objects.filter(type=ConcernType.FLAG): + update_hierarchy(concern=flag) + + +def _get_filter_for_flags_of_objects(content_type_id_map: dict[ContentType, set[int]]) -> Q: + return Q(type=ConcernType.FLAG) & reduce( + or_, + ( + Q(owner_type=content_type, owner_id__in=object_ids) + for content_type, object_ids in content_type_id_map.items() + ), + ) + + +def _get_owner_ids_grouped_by_content_type(objects: Collection[CoreObjectDescriptor]) -> dict[ContentType, set[int]]: + core_to_model_map = {object_.type: core_type_to_model(object_.type) for object_ in objects} + model_content_type_map = { + content_type.model: content_type + for content_type in ContentType.objects.filter( + app_label="cm", model__in={model.__name__.lower() for model in core_to_model_map.values()} + ) + } + + result = defaultdict(set) + for object_ in objects: + model = core_to_model_map[object_.type] + result[model_content_type_map[model.__name__.lower()]].add(object_.id) + + return result diff --git a/python/cm/services/concern/messages.py b/python/cm/services/concern/messages.py index 1d2f5d7ab5..ee152322d1 100644 --- a/python/cm/services/concern/messages.py +++ b/python/cm/services/concern/messages.py @@ -77,30 +77,26 @@ def _retrieve_placeholder_from_prototype(entity: Prototype) -> dict: def _retrieve_placeholder_from_job(entity: JobLog) -> dict: - # todo should be updated after task rework feature branch is merged - # name should be taken from `entity.task.display_name` - action = entity.sub_action or entity.action - return { "type": "job", - "name": action.display_name or action.name, + "name": entity.display_name or entity.name, # todo should it be job id or task id? "params": {"job_id": entity.task.id}, } -ADCM_ENTITY_SOURCE_RESOLVER = Placeholders(retrieve_source=_retrieve_placeholder_from_adcm_entity) +ADCM_ENTITY_AS_PLACEHOLDERS = Placeholders(retrieve_source=_retrieve_placeholder_from_adcm_entity) class ConcernMessage(Enum): CONFIG_ISSUE = ConcernMessageTemplate( - message="${source} has an issue with its config", placeholders=ADCM_ENTITY_SOURCE_RESOLVER + message="${source} has an issue with its config", placeholders=ADCM_ENTITY_AS_PLACEHOLDERS ) HOST_COMPONENT_ISSUE = ConcernMessageTemplate( - message="${source} has an issue with host-component mapping", placeholders=ADCM_ENTITY_SOURCE_RESOLVER + message="${source} has an issue with host-component mapping", placeholders=ADCM_ENTITY_AS_PLACEHOLDERS ) REQUIRED_IMPORT_ISSUE = ConcernMessageTemplate( - message="${source} has an issue with required import", placeholders=ADCM_ENTITY_SOURCE_RESOLVER + message="${source} has an issue with required import", placeholders=ADCM_ENTITY_AS_PLACEHOLDERS ) REQUIRED_SERVICE_ISSUE = ConcernMessageTemplate( message="${source} require service ${target} to be installed", @@ -122,17 +118,13 @@ class ConcernMessage(Enum): ), ) # todo update message and naming here - FLAG = ConcernMessageTemplate( - message="${source} has an outdated configuration", placeholders=ADCM_ENTITY_SOURCE_RESOLVER - ) + FLAG = ConcernMessageTemplate(message="${source} has a flag: ", placeholders=ADCM_ENTITY_AS_PLACEHOLDERS) def __init__(self, template: ConcernMessageTemplate): - self.template = template - + self.template: ConcernMessageTemplate = template -def build_concern_reason(concern_message: ConcernMessage, placeholder_objects: PlaceholderObjectsDTO) -> dict: - template = concern_message.template +def build_concern_reason(template: ConcernMessageTemplate, placeholder_objects: PlaceholderObjectsDTO) -> dict: resolved_placeholders = {} for placeholder_name in ("source", "target", "job"): placeholder: Placeholder = getattr(template.placeholders, placeholder_name) @@ -142,7 +134,7 @@ def build_concern_reason(concern_message: ConcernMessage, placeholder_objects: P entity = getattr(placeholder_objects, placeholder_name) if entity is None: # todo if there will be cases when those can be null, set placeholder to `{}` instead of error - message = f"Concern message {concern_message.name} requires `{placeholder_name}` to fill placeholders" + message = f"Concern message '{template.message}' requires `{placeholder_name}` to fill placeholders" raise RuntimeError(message) resolved_placeholders[placeholder_name] = placeholder.retrieve(entity) diff --git a/python/cm/services/job/run/_task_finalizers.py b/python/cm/services/job/run/_task_finalizers.py index db68f792d0..1dd2ba7840 100644 --- a/python/cm/services/job/run/_task_finalizers.py +++ b/python/cm/services/job/run/_task_finalizers.py @@ -21,6 +21,7 @@ from cm.converters import core_type_to_model from cm.issue import unlock_affected_objects, update_hierarchy_issues from cm.models import ClusterObject, Host, JobLog, MaintenanceMode, ServiceComponent, TaskLog, get_object_cluster +from cm.services.concern.messages import ConcernMessage, PlaceholderObjectsDTO, build_concern_reason from cm.status_api import send_object_update_event # todo "unwrap" these functions to use repo without directly calling ORM, @@ -31,7 +32,10 @@ def set_job_lock(job_id: int) -> None: job = JobLog.objects.select_related("task").get(pk=job_id) if job.task.lock and job.task.task_object: - job.task.lock.reason = job.cook_reason() + job.task.lock.reason = build_concern_reason( + ConcernMessage.LOCKED_BY_JOB.template, + placeholder_objects=PlaceholderObjectsDTO(job=job, target=job.task.task_object), + ) job.task.lock.save(update_fields=["reason"]) diff --git a/python/cm/services/maintenance_mode.py b/python/cm/services/maintenance_mode.py index d4916ec0f3..df60aa725b 100644 --- a/python/cm/services/maintenance_mode.py +++ b/python/cm/services/maintenance_mode.py @@ -15,9 +15,9 @@ from rest_framework.serializers import Serializer from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_409_CONFLICT -from cm.flag import update_flags from cm.issue import update_hierarchy_issues, update_issue_after_deleting from cm.models import Action, ClusterObject, Host, HostComponent, MaintenanceMode, Prototype, ServiceComponent +from cm.services.concern.flags import update_flags from cm.services.job.action import ActionRunPayload, run_action from cm.services.status.notify import reset_objects_in_mm from cm.status_api import send_object_update_event diff --git a/python/cm/stack.py b/python/cm/stack.py index 9e9a16ce8a..e32dafc0c2 100644 --- a/python/cm/stack.py +++ b/python/cm/stack.py @@ -281,7 +281,7 @@ def save_prototype( dict_to_obj(dictionary=conf, key="config_group_customization", obj=prototype) dict_to_obj(dictionary=conf, key="allow_maintenance_mode", obj=prototype) - dict_to_obj(dictionary=conf, key="allow_flags", obj=prototype) + dict_to_obj(dictionary=conf, key="flag_autogeneration", obj=prototype) fix_display_name(conf=conf, obj=prototype) license_hash = get_license_hash(proto=prototype, conf=conf, bundle_hash=bundle_hash) @@ -360,11 +360,12 @@ def save_components(proto: StagePrototype, conf: dict, bundle_hash: str) -> None dict_to_obj(dictionary=component_conf, key="requires", obj=component) dict_to_obj(dictionary=component_conf, key="venv", obj=component) dict_to_obj(dictionary=component_conf, key="bound_to", obj=component) + dict_to_obj(dictionary=component_conf, key="flag_autogeneration", obj=component) process_config_group_customization(actual_config=component_conf, obj=component) dict_to_obj(dictionary=component_conf, key="config_group_customization", obj=component) - dict_to_obj(dictionary=component_conf, key="allow_flags", obj=component) + dict_to_obj(dictionary=component_conf, key="enable_outdated_config", obj=component) component.save() diff --git a/python/cm/tests/bundles/flag_autogeneration/cluster_true/config.yaml b/python/cm/tests/bundles/flag_autogeneration/cluster_true/config.yaml new file mode 100644 index 0000000000..ad30ff862d --- /dev/null +++ b/python/cm/tests/bundles/flag_autogeneration/cluster_true/config.yaml @@ -0,0 +1,39 @@ +- type: cluster + name: cluster_auto_true + version: 1 + + flag_autogeneration: + enable_outdated_config: true + +- type: service + name: not_defined + version: 2 + + components: &components + not_defined: + + defined_true: + flag_autogeneration: + enable_outdated_config: true + + defined_false: + flag_autogeneration: + enable_outdated_config: false + +- type: service + name: defined_true + version: 3 + + flag_autogeneration: + enable_outdated_config: true + + components: *components + +- type: service + name: defined_false + version: 4 + + flag_autogeneration: + enable_outdated_config: false + + components: *components diff --git a/python/cm/tests/bundles/flag_autogeneration/cluster_undefined/config.yaml b/python/cm/tests/bundles/flag_autogeneration/cluster_undefined/config.yaml new file mode 100644 index 0000000000..334d862ba4 --- /dev/null +++ b/python/cm/tests/bundles/flag_autogeneration/cluster_undefined/config.yaml @@ -0,0 +1,36 @@ +- type: cluster + name: cluster_auto_undefined + version: 1 + +- type: service + name: not_defined + version: 2 + + components: &components + not_defined: + + defined_true: + flag_autogeneration: + enable_outdated_config: true + + defined_false: + flag_autogeneration: + enable_outdated_config: false + +- type: service + name: defined_true + version: 3 + + flag_autogeneration: + enable_outdated_config: true + + components: *components + +- type: service + name: defined_false + version: 4 + + flag_autogeneration: + enable_outdated_config: false + + components: *components diff --git a/python/cm/tests/bundles/flag_autogeneration/provider_false_host_true/config.yaml b/python/cm/tests/bundles/flag_autogeneration/provider_false_host_true/config.yaml new file mode 100644 index 0000000000..ec76df0c91 --- /dev/null +++ b/python/cm/tests/bundles/flag_autogeneration/provider_false_host_true/config.yaml @@ -0,0 +1,10 @@ +- type: provider + name: provider_auto_undefined + version: 1 + +- type: host + name: not_defined + version: 2 + + flag_autogeneration: + enable_outdated_config: true diff --git a/python/cm/tests/bundles/flag_autogeneration/provider_true_host_undefined/config.yaml b/python/cm/tests/bundles/flag_autogeneration/provider_true_host_undefined/config.yaml new file mode 100644 index 0000000000..3fc45465b5 --- /dev/null +++ b/python/cm/tests/bundles/flag_autogeneration/provider_true_host_undefined/config.yaml @@ -0,0 +1,10 @@ +- type: provider + name: provider_auto_true + version: 1 + + flag_autogeneration: + enable_outdated_config: true + +- type: host + name: not_defined + version: 2 diff --git a/python/cm/tests/test_bundle.py b/python/cm/tests/test_bundle.py index 990b23be3d..72d09b37fc 100644 --- a/python/cm/tests/test_bundle.py +++ b/python/cm/tests/test_bundle.py @@ -13,7 +13,7 @@ from pathlib import Path import json -from adcm.tests.base import APPLICATION_JSON, BaseTestCase, BundleLogicMixin +from adcm.tests.base import APPLICATION_JSON, BaseTestCase, BundleLogicMixin, BusinessLogicMixin from django.conf import settings from django.db import IntegrityError from django.urls import reverse @@ -29,7 +29,7 @@ from cm.api import delete_host_provider from cm.bundle import delete_bundle from cm.errors import AdcmEx -from cm.models import Bundle, ConfigLog, SubAction +from cm.models import ADCMEntity, Bundle, ClusterObject, ConfigLog, ServiceComponent, SubAction from cm.services.bundle import detect_path_for_file_in_bundle from cm.tests.test_upgrade import ( cook_cluster, @@ -39,12 +39,18 @@ ) -class TestBundle(BaseTestCase): +class TestBundle(BaseTestCase, BusinessLogicMixin): def setUp(self) -> None: super().setUp() self.test_files_dir = self.base_dir / "python" / "cm" / "tests" / "files" + def get_component(self, service: ClusterObject, component_name: str) -> ServiceComponent: + return ServiceComponent.objects.get(service=service, prototype__name=component_name) + + def enable_outdated_config_is(self, entity: ADCMEntity, expected_value: bool): + self.assertEqual(entity.prototype.flag_autogeneration["enable_outdated_config"], expected_value) + def test_path_resolution(self) -> None: bundle_root = Path(__file__).parent / "files" / "files_with_symlinks" inner_dir = Path("inside") @@ -64,6 +70,40 @@ def test_path_resolution(self) -> None: result = detect_path_for_file_in_bundle(bundle_root=bundle_root, config_yaml_dir=Path(), file="./another_link") self.assertEqual(result, bundle_root / "another_link") + def test_flag_autogeneration_inheritance(self) -> None: + directory = Path(__file__).parent / "bundles" / "flag_autogeneration" + + for bundle_name, cluster_flag_value in (("cluster_true", True), ("cluster_undefined", False)): + bundle = self.add_bundle(source_dir=directory / bundle_name) + cluster = self.add_cluster(bundle=bundle, name=f"Cluster {cluster_flag_value}") + defined_false, defined_true, not_defined = self.add_services_to_cluster( + ["defined_false", "defined_true", "not_defined"], cluster=cluster + ).order_by("prototype__name") + + self.enable_outdated_config_is(cluster, cluster_flag_value) + self.enable_outdated_config_is(not_defined, cluster_flag_value) + self.enable_outdated_config_is(defined_true, True) + self.enable_outdated_config_is(defined_false, False) + for service in not_defined, defined_true, defined_false: + parent_value = service.prototype.flag_autogeneration["enable_outdated_config"] + self.enable_outdated_config_is(self.get_component(service, "not_defined"), parent_value) + self.enable_outdated_config_is(self.get_component(service, "defined_true"), True) + self.enable_outdated_config_is(self.get_component(service, "defined_false"), False) + + bundle = self.add_bundle(source_dir=directory / "provider_false_host_true") + provider = self.add_provider(bundle=bundle, name="Provider False") + host = self.add_host(bundle=bundle, provider=provider, fqdn="host-true") + + self.enable_outdated_config_is(provider, False) + self.enable_outdated_config_is(host, True) + + bundle = self.add_bundle(source_dir=directory / "provider_true_host_undefined") + provider = self.add_provider(bundle=bundle, name="Provider True") + host = self.add_host(bundle=bundle, provider=provider, fqdn="host-undef") + + self.enable_outdated_config_is(provider, True) + self.enable_outdated_config_is(host, True) + def test_bundle_upload_duplicate_upgrade_fail(self): with self.assertRaises(IntegrityError): self.upload_and_load_bundle(path=Path(self.test_files_dir, "test_upgrade_duplicated.tar")) diff --git a/python/cm/tests/test_flag.py b/python/cm/tests/test_flag.py index ec50b8a4c7..9a9f2b1dc1 100644 --- a/python/cm/tests/test_flag.py +++ b/python/cm/tests/test_flag.py @@ -9,71 +9,214 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +from pathlib import Path +import random -from adcm.tests.base import BaseTestCase +from adcm.tests.base import BaseTestCase, BusinessLogicMixin +from core.types import ADCMCoreType, CoreObjectDescriptor -from cm.flag import create_flag, get_flag_name, remove_flag, update_object_flag -from cm.hierarchy import Tree -from cm.models import ConcernCause, ConcernItem, ConcernType -from cm.tests.utils import generate_hierarchy +from cm.converters import orm_object_to_core_type +from cm.issue import create_issue, create_lock +from cm.models import ( + ADCM, + Cluster, + ClusterObject, + ConcernCause, + ConcernItem, + ConcernType, + Host, + HostProvider, + JobLog, + ServiceComponent, + TaskLog, +) +from cm.services.concern.flags import BuiltInFlag, ConcernFlag, lower_all_flags, lower_flag, raise_flag -class FlagTest(BaseTestCase): - """Tests for `cm.issue.create_issues()`""" - +class TestFlag(BaseTestCase, BusinessLogicMixin): def setUp(self) -> None: super().setUp() - self.hierarchy = generate_hierarchy() - self.cluster = self.hierarchy["cluster"] - self.cluster.prototype.allow_flags = True - self.cluster.prototype.save(update_fields=["allow_flags"]) - self.tree = Tree(self.cluster) - - def test_create_flag(self): - create_flag(obj=self.cluster) - flag = ConcernItem.objects.filter(type=ConcernType.FLAG, name=get_flag_name(obj=self.cluster)).first() - - self.assertIsNotNone(flag) - self.assertEqual(flag.owner, self.cluster) - reason = { - "message": "${source} has an outdated configuration", - "placeholder": { - "source": {"type": "cluster", "name": self.cluster.name, "params": {"cluster_id": self.cluster.id}} - }, - } - self.assertEqual(flag.reason, reason) - self.assertEqual(flag.cause, ConcernCause.CONFIG) - - def test_update_flags(self): - update_object_flag(obj=self.cluster) - for node in self.tree.get_directly_affected(self.tree.built_from): - concerns = node.value.concerns.all() - self.assertEqual(concerns.count(), 1) - self.assertEqual(ConcernType.FLAG.value, concerns.first().type) - - def test_unique_flag_name(self): - msg = "Test message" - update_object_flag(obj=self.cluster) - update_object_flag(obj=self.cluster, msg=msg) - concerns = self.cluster.concerns.all() - self.assertEqual(concerns.count(), 2) - - # test what flag with the same name will not create and not apply second time - update_object_flag(obj=self.cluster) - for node in self.tree.get_directly_affected(self.tree.built_from): - concerns = node.value.concerns.all() - self.assertEqual(concerns.count(), 2) - self.assertEqual(ConcernType.FLAG.value, concerns.first().type) - - def test_delete_flag_success(self): - msg = "Test message" - update_object_flag(obj=self.cluster) - update_object_flag(obj=self.cluster, msg=msg) - - remove_flag(obj=self.cluster) - for node in self.tree.get_directly_affected(self.tree.built_from): - concerns = node.value.concerns.all() - self.assertEqual(concerns.count(), 1) - self.assertEqual(ConcernType.FLAG.value, concerns.first().type) - self.assertEqual(concerns.first().name, get_flag_name(obj=self.cluster, msg=msg)) + self.change_configuration( + target=ADCM.objects.get(), config_diff={"global": {"adcm_url": "http://localhost:8080"}} + ) + + bundles_dir = Path(__file__).parent / "bundles" + cluster_bundle = self.add_bundle(bundles_dir / "cluster_1") + provider_bundle = self.add_bundle(bundles_dir / "provider") + + clusters = [self.add_cluster(bundle=cluster_bundle, name=f"Cluster {i}") for i in range(3)] + providers = [self.add_provider(bundle=provider_bundle, name=f"Provider {i}") for i in range(3)] + + for cluster in clusters: + self.add_services_to_cluster(["service_two_components", "another_service_two_components"], cluster=cluster) + + for provider in providers: + for i in range(4): + self.add_host(bundle=provider.prototype.bundle, provider=provider, fqdn=f"{provider.name}-host-{i}") + + def test_raise_lower_flag_on_one_object_success(self) -> None: + expected_name = BuiltInFlag.ADCM_OUTDATED_CONFIG.value.name + expected_message = "${source} has a flag: " + BuiltInFlag.ADCM_OUTDATED_CONFIG.value.message + + for object_model in (Cluster, ClusterObject, ServiceComponent, HostProvider, Host): + target = object_model.objects.all()[1] + self.assertEqual(ConcernItem.objects.count(), 0) + + on_objects = [CoreObjectDescriptor(id=target.id, type=orm_object_to_core_type(target))] + + raise_flag(flag=BuiltInFlag.ADCM_OUTDATED_CONFIG.value, on_objects=on_objects) + self.assertEqual(ConcernItem.objects.count(), 1) + + concern = ConcernItem.objects.get() + self.assertEqual(concern.type, ConcernType.FLAG) + self.assertEqual(concern.owner, target) + self.assertEqual(concern.name, expected_name) + self.assertEqual(concern.reason["message"], expected_message) + self.assertFalse(concern.blocking) + self.assertEqual(concern.cause, ConcernCause.CONFIG) + + lower_flag(name=BuiltInFlag.ADCM_OUTDATED_CONFIG.value.name, on_objects=on_objects) + self.assertEqual(ConcernItem.objects.count(), 0) + + def test_raise_lower_flag_on_many_objects_success(self) -> None: + flag = ConcernFlag(name="custom_name", message="hi, I'm glad to see you {", cause=None) + expected_name = flag.name + expected_message = "${source} has a flag: " + flag.message + + clusters = random.sample(tuple(Cluster.objects.all()), k=1) + services = random.sample(tuple(ClusterObject.objects.all()), k=2) + components = random.sample(tuple(ServiceComponent.objects.all()), k=3) + providers = random.sample(tuple(HostProvider.objects.all()), k=2) + hosts = random.sample(tuple(Host.objects.all()), k=1) + + targets = (*clusters, *services, *components, *providers, *hosts) + self.assertEqual(ConcernItem.objects.count(), 0) + + on_objects = [CoreObjectDescriptor(id=target.id, type=orm_object_to_core_type(target)) for target in targets] + + raise_flag(flag=flag, on_objects=on_objects) + self.assertEqual(ConcernItem.objects.count(), 9) + + for concern in ConcernItem.objects.all(): + self.assertEqual(concern.type, ConcernType.FLAG) + self.assertEqual(concern.name, expected_name) + self.assertEqual(concern.reason["message"], expected_message) + self.assertFalse(concern.blocking) + self.assertEqual(concern.cause, None) + + for target in targets: + concern = ConcernItem.objects.get(owner_id=target.id, owner_type=target.content_type) + self.assertEqual(concern.owner, target) + self.assertEqual( + concern.reason["placeholder"], + { + "source": { + "type": target.prototype.type, + "name": target.display_name, + "params": target.get_id_chain(), + } + }, + ) + + lower_flag(name=flag.name, on_objects=on_objects) + self.assertEqual(ConcernItem.objects.count(), 0) + + def test_raise_on_objects_some_has_flags_success(self) -> None: + flag_1 = ConcernFlag(name="awesome name", message="hi, I'm glad to see you ()}", cause=None) + flag_2 = BuiltInFlag.ADCM_OUTDATED_CONFIG.value + + clusters = cluster_1, cluster_2 = random.sample(tuple(Cluster.objects.all()), k=2) + services = service_1, service_2 = random.sample(tuple(ClusterObject.objects.all()), k=2) + components = component_1, component_2 = random.sample(tuple(ServiceComponent.objects.all()), k=2) + providers = provider_1, provider_2 = random.sample(tuple(HostProvider.objects.all()), k=2) + hosts = host_1, host_2 = random.sample(tuple(Host.objects.all()), k=2) + + self.assertEqual(ConcernItem.objects.count(), 0) + targets = (cluster_1, service_1, service_2, component_2, provider_1, host_1) + on_objects = [CoreObjectDescriptor(id=target.id, type=orm_object_to_core_type(target)) for target in targets] + + raise_flag(flag=flag_1, on_objects=on_objects) + + self.assertEqual(ConcernItem.objects.count(), 6) + + raise_flag(flag=flag_2, on_objects=on_objects) + + self.assertEqual(ConcernItem.objects.count(), 12) + self.assertEqual(ConcernItem.objects.filter(name=flag_1.name).count(), 6) + self.assertEqual(ConcernItem.objects.filter(name=flag_2.name).count(), 6) + component_2_concern_names = ConcernItem.objects.values_list("name", flat=True).filter( + owner_id=component_2.id, owner_type=component_2.content_type + ) + self.assertListEqual(sorted((flag_1.name, flag_2.name)), sorted(component_2_concern_names)) + + targets = (*clusters, *services, *components, *providers, *hosts) + on_objects = [CoreObjectDescriptor(id=target.id, type=orm_object_to_core_type(target)) for target in targets] + + raise_flag(flag=flag_1, on_objects=on_objects) + self.assertEqual(ConcernItem.objects.count(), 16) + self.assertEqual(ConcernItem.objects.filter(name=flag_1.name).count(), 10) + self.assertEqual(ConcernItem.objects.filter(name=flag_2.name).count(), 6) + + def test_lower_flag_does_not_interfere_with_other_concerns_success(self) -> None: + clusters = cluster_1, cluster_2 = random.sample(tuple(Cluster.objects.all()), k=2) + components = component_1, component_2 = random.sample(tuple(ServiceComponent.objects.all()), k=2) + hosts = host_1, host_2 = random.sample(tuple(Host.objects.all()), k=2) + + dummy_job = JobLog(name="cool", task=TaskLog(id=10)) + for object_ in (*clusters, *components, *hosts): + create_issue(obj=object_, issue_cause=ConcernCause.CONFIG) + create_lock(owner=object_, job=dummy_job) + self.assertEqual(ConcernItem.objects.count(), 12) + self.assertEqual(ConcernItem.objects.filter(type=ConcernType.FLAG).count(), 0) + + issue_like_flag = ConcernFlag( + name=ConcernItem.objects.filter(type=ConcernType.ISSUE).first().name, message="imitate issue", cause=None + ) + lock_like_flag = ConcernFlag( + name=ConcernItem.objects.filter(type=ConcernType.LOCK).first().name, message="imitate lock", cause=None + ) + + targets = (*clusters, *components, *hosts) + on_objects = [CoreObjectDescriptor(id=target.id, type=orm_object_to_core_type(target)) for target in targets] + + lower_flag(name=issue_like_flag.name, on_objects=on_objects) + self.assertEqual(ConcernItem.objects.count(), 12) + self.assertEqual(ConcernItem.objects.filter(type=ConcernType.FLAG).count(), 0) + + lower_flag(name=lock_like_flag.name, on_objects=on_objects) + self.assertEqual(ConcernItem.objects.count(), 12) + self.assertEqual(ConcernItem.objects.filter(type=ConcernType.FLAG).count(), 0) + + lower_all_flags(on_objects=on_objects) + self.assertEqual(ConcernItem.objects.count(), 12) + self.assertEqual(ConcernItem.objects.filter(type=ConcernType.FLAG).count(), 0) + + targets = (cluster_1, component_2, host_1) + on_objects = [CoreObjectDescriptor(id=target.id, type=orm_object_to_core_type(target)) for target in targets] + + raise_flag(flag=issue_like_flag, on_objects=on_objects) + self.assertEqual(ConcernItem.objects.count(), 15) + self.assertEqual(ConcernItem.objects.filter(type=ConcernType.FLAG).count(), 3) + + raise_flag(flag=lock_like_flag, on_objects=on_objects) + self.assertEqual(ConcernItem.objects.count(), 18) + self.assertEqual(ConcernItem.objects.filter(type=ConcernType.FLAG, name=issue_like_flag.name).count(), 3) + self.assertEqual(ConcernItem.objects.filter(type=ConcernType.FLAG, name=lock_like_flag.name).count(), 3) + + targets = (cluster_1, host_1) + on_objects = [CoreObjectDescriptor(id=target.id, type=orm_object_to_core_type(target)) for target in targets] + + lower_flag(name=issue_like_flag.name, on_objects=on_objects) + self.assertEqual(ConcernItem.objects.count(), 16) + self.assertEqual(ConcernItem.objects.filter(type=ConcernType.FLAG, name=issue_like_flag.name).count(), 1) + self.assertEqual(ConcernItem.objects.filter(type=ConcernType.FLAG, name=lock_like_flag.name).count(), 3) + + lower_flag(name=lock_like_flag.name, on_objects=on_objects) + self.assertEqual(ConcernItem.objects.count(), 14) + self.assertEqual(ConcernItem.objects.filter(type=ConcernType.FLAG, name=issue_like_flag.name).count(), 1) + self.assertEqual(ConcernItem.objects.filter(type=ConcernType.FLAG, name=lock_like_flag.name).count(), 1) + + lower_all_flags(on_objects=[CoreObjectDescriptor(id=component_2.id, type=ADCMCoreType.COMPONENT)]) + self.assertEqual(ConcernItem.objects.count(), 12) + self.assertEqual(ConcernItem.objects.filter(type=ConcernType.FLAG).count(), 0) diff --git a/python/cm/tests/test_inventory/base.py b/python/cm/tests/test_inventory/base.py index d84038ee03..407c290228 100644 --- a/python/cm/tests/test_inventory/base.py +++ b/python/cm/tests/test_inventory/base.py @@ -11,17 +11,16 @@ # limitations under the License. from functools import reduce from pathlib import Path -from typing import Any, Callable, Iterable, Literal, Mapping, TypeAlias +from typing import Any, Iterable, Literal, Mapping, TypeAlias import json from adcm.tests.base import BaseTestCase, BusinessLogicMixin -from api_v2.config.utils import convert_adcm_meta_to_attr, convert_attr_to_adcm_meta from core.types import CoreObjectDescriptor from django.contrib.contenttypes.models import ContentType from jinja2 import Template from cm.adcm_config.ansible import ansible_decrypt -from cm.api import add_hc, update_obj_config +from cm.api import add_hc from cm.converters import model_name_to_core_type from cm.models import ( Action, @@ -29,7 +28,6 @@ ADCMModel, Cluster, ClusterObject, - ConfigLog, GroupConfig, Host, HostComponent, @@ -38,7 +36,6 @@ ) from cm.services.job.inventory import get_inventory_data from cm.services.job.types import HcAclAction -from cm.utils import deep_merge TemplatesData: TypeAlias = Mapping[tuple[str, ...], tuple[Path, Mapping[str, Any]]] MappingEntry: TypeAlias = dict[Literal["host_id", "component_id", "service_id"], int] @@ -95,30 +92,6 @@ def set_hostcomponent(cluster: Cluster, entries: Iterable[tuple[Host, ServiceCom ], ) - @staticmethod - def change_configuration( - target: ADCMModel | GroupConfig, - config_diff: dict, - meta_diff: dict | None = None, - preprocess_config: Callable[[dict], dict] = lambda x: x, - ) -> ConfigLog: - meta = meta_diff or {} - - target.refresh_from_db() - current_config = ConfigLog.objects.get(id=target.config.current) - - updated = update_obj_config( - obj_conf=target.config, - config=deep_merge(origin=preprocess_config(current_config.config), renovator=config_diff), - attr=convert_adcm_meta_to_attr( - deep_merge(origin=convert_attr_to_adcm_meta(current_config.attr), renovator=meta) - ), - description="", - ) - target.refresh_from_db() - - return updated - def check_data_by_template(self, data: Mapping[str, dict], templates_data: TemplatesData) -> None: for key_chain, template_data in templates_data.items(): template_path, kwargs = template_data diff --git a/python/cm/tests/test_job.py b/python/cm/tests/test_job.py index 9560cfa0a0..027188b476 100644 --- a/python/cm/tests/test_job.py +++ b/python/cm/tests/test_job.py @@ -18,21 +18,17 @@ from adcm.tests.base import APPLICATION_JSON, BaseTestCase from django.conf import settings from django.urls import reverse -from django.utils import timezone from init_db import init from rest_framework.response import Response from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_409_CONFLICT -from cm.issue import lock_affected_objects from cm.models import ( Action, Bundle, JobLog, JobStatus, Prototype, - TaskLog, ) -from cm.tests.utils import gen_cluster def get_bundle_root(action: Action) -> str: @@ -106,39 +102,6 @@ def run_action_get_target_job( return response, target_job - def test_set_job_status(self): - bundle = Bundle.objects.create() - prototype = Prototype.objects.create(bundle=bundle) - action = Action.objects.create(prototype=prototype, name="action_name", display_name="Test Action") - cluster = gen_cluster(prototype=prototype) - task = TaskLog.objects.create( - task_object=cluster, - action=action, - object_id=1, - start_date=timezone.now(), - finish_date=timezone.now(), - ) - job = JobLog.objects.create(task=task, start_date=timezone.now(), finish_date=timezone.now()) - lock_affected_objects(task=task, objects=[cluster]) - status = JobStatus.RUNNING - pid = 10 - - job = JobLog.objects.get(id=job.id) - job.status = JobStatus.RUNNING - job.start_date = timezone.now() - job.pid = pid - job.save(update_fields=["status", "start_date", "pid"]) - - if job.task.lock and job.task.task_object: - job.task.lock.reason = job.cook_reason() - job.task.lock.save(update_fields=["reason"]) - - job = JobLog.objects.get(id=job.id) - - self.assertEqual(job.status, status) - self.assertEqual(job.pid, pid) - self.assertEqual(task.lock.reason["placeholder"]["job"]["name"], action.display_name) - def test_get_bundle_root(self): bundle = Bundle.objects.create() prototype = Prototype.objects.create(bundle=bundle) From a8a505d6fae7965b817a5b66315add2e680e6102 Mon Sep 17 00:00:00 2001 From: Egor Araslanov Date: Tue, 2 Apr 2024 13:53:07 +0500 Subject: [PATCH 034/208] ADCM-5442 Restore HC only if action has `hc_acl` on task failure --- python/cm/services/job/run/runners.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/cm/services/job/run/runners.py b/python/cm/services/job/run/runners.py index fb7da7f534..4f97f2205d 100644 --- a/python/cm/services/job/run/runners.py +++ b/python/cm/services/job/run/runners.py @@ -269,7 +269,7 @@ def _update_owner_object(self, owner: CoreObjectDescriptor, finished_task: Task, if ( self._runtime.status in {ExecutionStatus.FAILED, ExecutionStatus.ABORTED, ExecutionStatus.BROKEN} - and finished_task.hostcomponent.saved is not None + and finished_task.action.hc_acl and finished_task.hostcomponent.restore_on_fail ): set_hostcomponent(task=finished_task, logger=self._logger) From c87d21d81b5b003c7380f2f410133123a62e31ee Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Wed, 3 Apr 2024 07:32:22 +0000 Subject: [PATCH 035/208] ADCM-5399 Rework API v2 schema for `/hosts` endpoints --- python/api_v2/api_schema.py | 6 + python/api_v2/host/serializers.py | 2 + python/api_v2/host/views.py | 229 +++++++++++++++++++++++++++++- 3 files changed, 233 insertions(+), 4 deletions(-) diff --git a/python/api_v2/api_schema.py b/python/api_v2/api_schema.py index 3880525062..32949dbac1 100644 --- a/python/api_v2/api_schema.py +++ b/python/api_v2/api_schema.py @@ -11,6 +11,7 @@ # limitations under the License. from adcm.serializers import EmptySerializer +from drf_spectacular.utils import OpenApiParameter from rest_framework.fields import CharField @@ -18,3 +19,8 @@ class ErrorSerializer(EmptySerializer): code = CharField() level = CharField() desc = CharField() + + +class DefaultParams: + LIMIT = OpenApiParameter(name="limit", description="Number of records included in the selection.", type=int) + OFFSET = OpenApiParameter(name="offset", description="Record number from which the selection starts.", type=int) diff --git a/python/api_v2/host/serializers.py b/python/api_v2/host/serializers.py index 4e159f1bf4..08868843d8 100644 --- a/python/api_v2/host/serializers.py +++ b/python/api_v2/host/serializers.py @@ -14,6 +14,7 @@ from adcm.serializers import EmptySerializer from cm.models import Cluster, Host, HostComponent, HostProvider, MaintenanceMode, ServiceComponent from cm.validators import HostUniqueValidator, StartMidEndValidator +from drf_spectacular.utils import extend_schema_field from rest_framework.serializers import ( CharField, ChoiceField, @@ -87,6 +88,7 @@ class Meta: ] @staticmethod + @extend_schema_field(field=HCComponentNameSerializer) def get_components(instance: Host) -> list[dict]: return HCComponentNameSerializer( instance=[hc.component for hc in instance.hostcomponent_set.all()], many=True diff --git a/python/api_v2/host/views.py b/python/api_v2/host/views.py index ec04ee43b3..36e8bb4c15 100644 --- a/python/api_v2/host/views.py +++ b/python/api_v2/host/views.py @@ -34,6 +34,7 @@ HostDoesNotExistError, ) from django_filters.rest_framework.backends import DjangoFilterBackend +from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from guardian.mixins import PermissionListMixin from guardian.shortcuts import get_objects_for_user from rest_framework.decorators import action @@ -44,9 +45,13 @@ HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT, + HTTP_400_BAD_REQUEST, + HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, + HTTP_409_CONFLICT, ) +from api_v2.api_schema import DefaultParams, ErrorSerializer from api_v2.config.utils import ConfigSchemaMixin from api_v2.host.filters import HostClusterFilter, HostFilter from api_v2.host.permissions import ( @@ -71,6 +76,111 @@ ) +@extend_schema_view( + list=extend_schema( + operation_id="getHosts", + description="Get a list of all hosts.", + summary="GET hosts", + parameters=[ + OpenApiParameter(name="name", description="Case insensitive and partial filter by host name."), + DefaultParams.LIMIT, + DefaultParams.OFFSET, + OpenApiParameter( + name="ordering", + description='Field to sort by. To sort in descending order, precede the attribute name with a "-".', + type=str, + enum=("name", "-name", "id", "-id"), + default="name", + ), + ], + responses={ + HTTP_200_OK: HostSerializer(many=True), + }, + ), + create=extend_schema( + operation_id="postHosts", + description="Create a new hosts.", + summary="POST hosts", + responses={ + HTTP_201_CREATED: HostSerializer, + **{err_code: ErrorSerializer for err_code in (HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN, HTTP_409_CONFLICT)}, + }, + ), + retrieve=extend_schema( + operation_id="getHost", + description="Get information about a specific host.", + summary="GET host", + parameters=[ + OpenApiParameter( + name="id", + type=int, + location=OpenApiParameter.PATH, + description="Host id.", + ), + ], + responses={ + HTTP_200_OK: HostSerializer, + HTTP_404_NOT_FOUND: ErrorSerializer, + }, + ), + destroy=extend_schema( + operation_id="deleteHost", + description="Delete host from ADCM.", + summary="DELETE host", + parameters=[ + OpenApiParameter( + name="id", + type=int, + location=OpenApiParameter.PATH, + description="Host id.", + ), + ], + responses={ + HTTP_204_NO_CONTENT: None, + **{err_code: ErrorSerializer for err_code in (HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT)}, + }, + ), + partial_update=extend_schema( + operation_id="patchHost", + description="Change host Information.", + summary="PATCH host", + parameters=[ + OpenApiParameter( + name="id", + type=int, + location=OpenApiParameter.PATH, + description="Host id.", + ), + ], + responses={ + HTTP_200_OK: HostSerializer, + **{ + err_code: ErrorSerializer + for err_code in (HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT) + }, + }, + ), + maintenance_mode=extend_schema( + operation_id="postHostMaintenanceMode", + description="Turn on/off maintenance mode on the host.", + summary="POST host maintenance-mode", + parameters=[ + OpenApiParameter( + name="id", + type=int, + location=OpenApiParameter.PATH, + description="Host id.", + ), + ], + responses={ + HTTP_200_OK: HostChangeMaintenanceModeSerializer, + **{ + err_code: ErrorSerializer + for err_code in (HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT) + }, + }, + ), +) class HostViewSet(PermissionListMixin, ConfigSchemaMixin, ObjectWithStatusViewMixin, CamelCaseModelViewSet): queryset = ( Host.objects.select_related("provider", "cluster", "cluster__prototype", "prototype") @@ -81,6 +191,7 @@ class HostViewSet(PermissionListMixin, ConfigSchemaMixin, ObjectWithStatusViewMi permission_classes = [HostsPermissions] filterset_class = HostFilter filter_backends = (DjangoFilterBackend,) + http_method_names = ["get", "post", "delete", "patch"] def get_serializer_class(self): if self.action == "create": @@ -125,13 +236,11 @@ def destroy(self, request, *args, **kwargs): # noqa: ARG002 return Response(status=HTTP_204_NO_CONTENT) @audit - def update(self, request, *args, **kwargs): # noqa: ARG002 - partial = kwargs.pop("partial", False) - + def partial_update(self, request, *args, **kwargs): # noqa: ARG002 instance = self.get_object() check_custom_perm(request.user, "change", "host", instance) - serializer = self.get_serializer(instance=instance, data=request.data, partial=partial) + serializer = self.get_serializer(instance=instance, data=request.data, partial=True) serializer.is_valid(raise_exception=True) valid = serializer.validated_data @@ -154,6 +263,118 @@ def maintenance_mode(self, request: Request, *args, **kwargs) -> Response: # no return maintenance_mode(request=request, host=self.get_object()) +@extend_schema_view( + list=extend_schema( + operation_id="getClusterHosts", + description="Get a list of all cluster hosts.", + summary="GET cluster hosts", + parameters=[ + OpenApiParameter(name="name", description="Case insensitive and partial filter by host name."), + DefaultParams.LIMIT, + DefaultParams.OFFSET, + OpenApiParameter( + name="ordering", + description='Field to sort by. To sort in descending order, precede the attribute name with a "-".', + type=str, + enum=("name", "-name", "id", "-id"), + default="name", + ), + OpenApiParameter(name="search", exclude=True), + ], + responses={ + HTTP_200_OK: HostSerializer, + HTTP_404_NOT_FOUND: ErrorSerializer, + }, + ), + create=extend_schema( + operation_id="postCusterHosts", + description="Add a new hosts to cluster.", + summary="POST cluster hosts", + request=HostAddSerializer, + responses={ + HTTP_201_CREATED: HostSerializer, + **{ + err_code: ErrorSerializer + for err_code in (HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT) + }, + }, + ), + retrieve=extend_schema( + operation_id="getClusterHost", + description="Get information about a specific cluster host.", + summary="GET cluster host", + parameters=[ + OpenApiParameter( + name="id", + type=int, + location=OpenApiParameter.PATH, + description="Host id.", + ), + ], + responses={ + HTTP_200_OK: HostSerializer, + HTTP_403_FORBIDDEN: ErrorSerializer, + HTTP_404_NOT_FOUND: ErrorSerializer, + }, + ), + destroy=extend_schema( + operation_id="deleteClusterHost", + description="Unlink host from cluster.", + summary="DELETE cluster host", + parameters=[ + OpenApiParameter( + name="id", + type=int, + location=OpenApiParameter.PATH, + description="Host id.", + ), + ], + responses={ + HTTP_204_NO_CONTENT: None, + HTTP_403_FORBIDDEN: ErrorSerializer, + HTTP_404_NOT_FOUND: ErrorSerializer, + HTTP_409_CONFLICT: ErrorSerializer, + }, + ), + maintenance_mode=extend_schema( + operation_id="postClusterHostMaintenanceMode", + description="Turn on/off maintenance mode on the cluster host.", + summary="POST cluster host maintenance-mode", + parameters=[ + OpenApiParameter( + name="id", + type=int, + location=OpenApiParameter.PATH, + description="Host id.", + ), + ], + responses={ + HTTP_200_OK: HostChangeMaintenanceModeSerializer, + **{ + err_code: ErrorSerializer + for err_code in (HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT) + }, + }, + ), + statuses=extend_schema( + operation_id="getHostStatuses", + description="Get information about cluster host status.", + summary="GET host status", + parameters=[ + OpenApiParameter( + name="id", + type=int, + location=OpenApiParameter.PATH, + description="Host id.", + ), + ], + responses={ + HTTP_200_OK: ClusterHostStatusSerializer, + HTTP_403_FORBIDDEN: ErrorSerializer, + HTTP_404_NOT_FOUND: ErrorSerializer, + }, + ), +) class HostClusterViewSet(PermissionListMixin, CamelCaseReadOnlyModelViewSet, ObjectWithStatusViewMixin): permission_required = [VIEW_HOST_PERM] permission_classes = [HostsClusterPermissions] From 6c69311dbef8fc4f620a024a4477528d3ef4fa9b Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Fri, 5 Apr 2024 12:47:52 +0000 Subject: [PATCH 036/208] ADCM-5412 Concept of new plugin calling + `adcm_change_flag` raw implementation --- python/adcm/tests/ansible.py | 117 ++++++ python/adcm/tests/base.py | 31 +- .../plugins/action/adcm_change_flag.py | 220 +++-------- python/ansible/plugins/action/adcm_config.py | 2 +- python/ansible_plugin/base.py | 359 ++++++++++++++++++ python/ansible_plugin/errors.py | 31 ++ python/ansible_plugin/executors/__init__.py | 12 + .../ansible_plugin/executors/change_flag.py | 133 +++++++ python/ansible_plugin/tests/__init__.py | 12 + .../tests/bundles/cluster/config.yaml | 32 ++ .../tests/bundles/provider/config.yaml | 16 + .../tests/test_adcm_change_flag.py | 242 ++++++++++++ .../tests/test_targets_extraction.py | 229 +++++++++++ python/api_v2/tests/test_actions.py | 4 +- python/cm/services/job/run/repo.py | 2 +- python/cm/tests/mocks/task_runner.py | 80 ++-- .../bundles/cluster/config.yaml | 6 +- .../test_task_runner/test_plugin_effects.py | 2 +- 18 files changed, 1306 insertions(+), 224 deletions(-) create mode 100644 python/adcm/tests/ansible.py create mode 100644 python/ansible_plugin/base.py create mode 100644 python/ansible_plugin/errors.py create mode 100644 python/ansible_plugin/executors/__init__.py create mode 100644 python/ansible_plugin/executors/change_flag.py create mode 100644 python/ansible_plugin/tests/__init__.py create mode 100644 python/ansible_plugin/tests/bundles/cluster/config.yaml create mode 100644 python/ansible_plugin/tests/bundles/provider/config.yaml create mode 100644 python/ansible_plugin/tests/test_adcm_change_flag.py create mode 100644 python/ansible_plugin/tests/test_targets_extraction.py diff --git a/python/adcm/tests/ansible.py b/python/adcm/tests/ansible.py new file mode 100644 index 0000000000..c0f2774246 --- /dev/null +++ b/python/adcm/tests/ansible.py @@ -0,0 +1,117 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from typing import Any, Callable, Collection, NamedTuple, TypeVar +import json + +from ansible_plugin.base import ( + ADCMAnsiblePluginExecutor, + AnsibleJobContext, + CallArguments, + CallResult, + PluginExecutorConfig, +) +from cm.models import JobLog +from cm.services.job.run._target_factories import prepare_ansible_job_config +from cm.services.job.run.repo import JobRepoImpl +from core.job.executors import Executor as JobExecutor +from core.job.runners import ADCMSettings, AnsibleSettings, ExternalSettings, IntegrationsSettings +from core.job.types import Job +from core.types import CoreObjectDescriptor +from django.conf import settings +import yaml + +Executor = TypeVar("Executor", bound=ADCMAnsiblePluginExecutor) + + +class ADCMAnsiblePluginTestMixin: + def prepare_executor( + self, executor_type: type[Executor], call_arguments: dict | str, call_context: dict | JobLog | Job | int + ) -> Executor: + """ + Prepare plugin executor more or less like it will be created inside Ansible plugin call + + You can specify `call_arguments` as dict, then it'll be passed right into executor's init function + or write it as plain yaml string (that'll be evaluated to dict) to "imitate" ansible plugin call description + (note that it should be inner section of plugin (without name). + If it is a string, it'll be parsed with `yaml` (so no ansible filters or environment will be there). + + `call_context` can be either a context dict (with `type` and `*_id` fields) + or a job (`Job`, `JobLog` or job's id as `int`) based on which this function will build context. + """ + + arguments = call_arguments + if isinstance(arguments, str): + arguments = yaml.safe_load(arguments) + + context = call_context + if not isinstance(call_context, dict): + configuration = ExternalSettings( + adcm=ADCMSettings(code_root_dir=settings.CODE_DIR, run_dir=settings.RUN_DIR, log_dir=settings.LOG_DIR), + ansible=AnsibleSettings(ansible_secret_script=settings.CODE_DIR / "ansible_secret.py"), + integrations=IntegrationsSettings(status_server_token=settings.STATUS_SECRET_KEY), + ) + + job_id = call_context if isinstance(call_context, int) else call_context.id + task_id = JobLog.objects.values_list("task_id", flat=True).get(id=job_id) + + job_ansible_config = prepare_ansible_job_config( + task=JobRepoImpl.get_task(id=task_id), job=JobRepoImpl.get_job(id=job_id), configuration=configuration + ) + context = job_ansible_config["context"] + + return executor_type(arguments=arguments, context=context) + + def build_executor_call( + self, + arguments: dict | str, + executor_type: type[ADCMAnsiblePluginExecutor], + ) -> Callable[[JobExecutor], Any]: + def _executor_func(executor: JobExecutor) -> int: + context = json.loads((executor._config.work_dir / "config.json").read_text())["context"] + plugin_executor = self.prepare_executor( + executor_type=executor_type, call_arguments=arguments, call_context=context + ) + result = plugin_executor.execute() + return 0 if result.error is None else 1 + + return _executor_func + + +class PassedArguments(NamedTuple): + targets: Collection[CoreObjectDescriptor] + arguments: CallArguments + context_owner: CoreObjectDescriptor + context: AnsibleJobContext + + +def DummyExecutor( # noqa: N802 + config: PluginExecutorConfig[CallArguments], +) -> type[ADCMAnsiblePluginExecutor[CallArguments, PassedArguments]]: + class DummyExecutorWithConfig(ADCMAnsiblePluginExecutor): + _config: PluginExecutorConfig[CallArguments] = config + + def __call__( + self, + targets: Collection[CoreObjectDescriptor], + arguments: CallArguments, + context_owner: CoreObjectDescriptor, + context: AnsibleJobContext, + ) -> CallResult[PassedArguments]: + return CallResult( + value=PassedArguments( + targets=targets, arguments=arguments, context_owner=context_owner, context=context + ), + changed=True, + error=None, + ) + + return DummyExecutorWithConfig diff --git a/python/adcm/tests/base.py b/python/adcm/tests/base.py index 0c8384f0b0..b67f8ca7b1 100644 --- a/python/adcm/tests/base.py +++ b/python/adcm/tests/base.py @@ -25,8 +25,10 @@ from api_v2.service.utils import bulk_add_services_to_cluster from cm.api import add_cluster, add_hc, add_host, add_host_provider, add_host_to_cluster, update_obj_config from cm.bundle import prepare_bundle, process_file +from cm.converters import orm_object_to_core_type from cm.models import ( ADCM, + Action, ADCMEntity, ADCMModel, Bundle, @@ -41,8 +43,12 @@ Prototype, ServiceComponent, ) +from cm.services.job.prepare import prepare_task_for_action from cm.utils import deep_merge +from core.job.dto import TaskPayloadDTO +from core.job.types import Task from core.rbac.dto import UserCreateDTO +from core.types import ADCMCoreType, CoreObjectDescriptor from django.conf import settings from django.db.models import QuerySet from django.test import Client, TestCase, override_settings @@ -459,9 +465,14 @@ def add_provider(bundle: Bundle, name: str, description: str = "") -> HostProvid return add_host_provider(prototype=prototype, name=name, description=description) def add_host( - self, bundle: Bundle, provider: HostProvider, fqdn: str, description: str = "", cluster: Cluster | None = None + self, + provider: HostProvider, + fqdn: str, + description: str = "", + cluster: Cluster | None = None, + bundle: Bundle | None = None, ) -> Host: - prototype = Prototype.objects.filter(bundle=bundle, type=ObjectType.HOST).first() + prototype = Prototype.objects.filter(bundle=bundle or provider.prototype.bundle, type=ObjectType.HOST).first() host = add_host(prototype=prototype, provider=provider, fqdn=fqdn, description=description) if cluster is not None: self.add_host_to_cluster(cluster=cluster, host=host) @@ -565,3 +576,19 @@ def change_configuration( target.refresh_from_db() return updated + + +class TaskTestMixin: + def prepare_task( + self, + owner: ADCM | Cluster | ClusterObject | ServiceComponent | HostProvider | Host, + payload: TaskPayloadDTO | None = None, + host: Host | None = None, + **action_search_kwargs, + ) -> Task: + owner_descriptor = CoreObjectDescriptor(id=owner.id, type=orm_object_to_core_type(owner)) + action = Action.objects.get(prototype_id=owner.prototype_id, **action_search_kwargs) + target = owner_descriptor if not host else CoreObjectDescriptor(id=host.id, type=ADCMCoreType.HOST) + return prepare_task_for_action( + target=target, owner=owner_descriptor, action=action.id, payload=payload or TaskPayloadDTO() + ) diff --git a/python/ansible/plugins/action/adcm_change_flag.py b/python/ansible/plugins/action/adcm_change_flag.py index a2accbfb14..00ada9136f 100644 --- a/python/ansible/plugins/action/adcm_change_flag.py +++ b/python/ansible/plugins/action/adcm_change_flag.py @@ -10,12 +10,19 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys + +sys.path.append("/adcm/python") + +import adcm.init_django # noqa: F401, isort:skip +from ansible_plugin.base import ADCMAnsiblePlugin +from ansible_plugin.executors.change_flag import ADCMChangeFlagPluginExecutor + DOCUMENTATION = """ ---- module: adcm_change_flag -short_description: Raise or Lower flags on Host, Service, Component or Cluster +short_description: Raise or Lower flags on Cluster, Service, Component, Hostprovider or Host description: - - The C(adcm_change_flag) module is intended to raise or lower on Host, Service, Component. + - The C(adcm_change_flag) module is intended to raise or lower on Cluster, Service, Component, Hostprovider or Host. options: operation: description: Operation over flag. @@ -23,189 +30,62 @@ choices: - up - down + name: + description: | + Internal flag name. Used for managing flags, including embedded ones. + If not specified in case of "down" operation all object's flags will be lowered. + required: False + type: string msg: - description: Additional flag message, to use in pattern " has an outdated configuration: ". - It might be used if you want several different flags in the same objects. In case of down operation, - if message specified then down only flag with specified message. + description: | + Additional flag message, to use in pattern " has a flag: ". + It might be used if you want several different flags on the same object. + In case of embedded flags management will overwrite the default message. required: False type: string objects: - description: List of Services or Components on which you need to raise/lower the flag. - If this parameter not specified raise or lower flag on action context object. - If you want to raise or lower flag on cluster you needed action in cluster context. + description: | + List of Services or Components on which you need to raise/lower the flag. + If this parameter is not specified, flag on action context object will be raised or lowered. + If you want to raise or lower flag on cluster you can add `- type: cluster` entry. required: False type: list elements: dict sample: - - type: service - service_name: hdfs - - type: component - service_name: service - component_name: component - type: cluster """ -EXAMPLES = r""" -- adcm_change_flag: - operation: up - objects: - - type: service - service_name: hdfs - - type: component - service_name: service - component_name: component - - type: cluster +EXAMPLES = """ +# raise / up - adcm_change_flag: - operation: down - objects: - - type: provider - - type: host - name: host_name -""" -import sys - -from ansible.errors import AnsibleError -from ansible.plugins.action import ActionBase -from cm.converters import orm_object_to_core_type -from cm.services.concern.flags import BuiltInFlag, lower_flag, raise_flag -from core.types import CoreObjectDescriptor - -sys.path.append("/adcm/python") - -import adcm.init_django # noqa: F401, isort:skip - -from ansible_plugin.utils import check_context_type, get_context_object -from cm.logger import logger -from cm.models import ( - ADCMEntity, - ADCMEntityStatus, - ClusterObject, - Host, - HostProvider, - ServiceComponent, - get_object_cluster, -) -from cm.status_api import send_object_update_event - -cluster_context_type = ("cluster", "service", "component") - - -class ActionModule(ActionBase): - TRANSFERS_FILES = False - _VALID_ARGS = frozenset(("operation", "msg", "objects")) - - def _check_args(self): - if "operation" not in self._task.args: - raise AnsibleError("'Operation' is mandatory args of adcm_change_flag") - - if self._task.args["operation"] not in ("up", "down"): - raise AnsibleError(f"'Operation' value must be 'up' or 'down', not {self._task.args['operation']}") - - if "objects" in self._task.args: - if not isinstance(self._task.args["objects"], list): - raise AnsibleError("'Objects' value should be list of objects") - - if not self._task.args["objects"]: - raise AnsibleError("'Objects' value should not be empty") - - for item in self._task.args["objects"]: - item_type = item.get("type") - if not item_type: - raise AnsibleError(message="'type' argument is mandatory for all items in 'objects'") - - if item_type == "component" and ("service_name" not in item or "component_name" not in item): - raise AnsibleError(message="'service_name' and 'component_name' is mandatory for type 'component'") - if item_type == "service" and "service_name" not in item: - raise AnsibleError(message="'service_name' is mandatory for type 'service'") - - def _process_objects(self, task_vars: dict, objects: list, context_obj: ADCMEntity) -> None: - err_msg = "Type {} should be used in {} context only" - cluster = get_object_cluster(obj=context_obj) - - for item in self._task.args["objects"]: - obj = None - item_type = item.get("type") - - if item_type == "component": - check_context_type( - task_vars=task_vars, - context_types=cluster_context_type, - err_msg=err_msg.format(item_type, cluster_context_type), - ) - - obj = ServiceComponent.objects.filter( - cluster=cluster, - prototype__name=item["component_name"], - service__prototype__name=item["service_name"], - ).first() - elif item_type == "service": - check_context_type( - task_vars=task_vars, - context_types=cluster_context_type, - err_msg=err_msg.format(item_type, cluster_context_type), - ) - - obj = ClusterObject.objects.filter(cluster=cluster, prototype__name=item["service_name"]).first() - elif item_type == "cluster": - check_context_type( - task_vars=task_vars, - context_types=cluster_context_type, - err_msg=err_msg.format(item_type, cluster_context_type), - ) - - obj = cluster - elif item_type == "provider": - check_context_type( - task_vars=task_vars, - context_types=("provider", "host"), - err_msg=err_msg.format(item_type, ("provider", "host")), - ) - - if isinstance(context_obj, HostProvider): - obj = context_obj - elif isinstance(context_obj, Host): - obj = context_obj.provider - - elif item_type == "host": - check_context_type( - task_vars=task_vars, - context_types=("host",), - err_msg=err_msg.format(item_type, "host"), - ) - - obj = context_obj - - if not obj: - logger.error("Object %s not found", item) - continue + operation: up + name: my_custom_flag + objects: + - type: component + service_name: kafka + component_name: kafka_broker - objects.append(obj) +- adcm_change_flag: + operation: up + name: adcm_outdated_config + objects: + - type: component + service_name: kafka + component_name: kafka_broker - def run(self, tmp=None, task_vars=None): - super().run(tmp, task_vars) - self._check_args() +# lower / down - # msg = "" - # if "msg" in self._task.args: - # msg = str(self._task.args["msg"]) +- adcm_change_flag: + name: my_custom_flag + operation: down - objects = [] - context_obj = get_context_object(task_vars=task_vars) - if "objects" in self._task.args: - self._process_objects(objects=objects, context_obj=context_obj, task_vars=task_vars) - else: - objects.append(context_obj) +- adcm_change_flag: + operation: down + objects: + - type: service +""" - for obj in objects: - target = CoreObjectDescriptor(id=obj.id, type=orm_object_to_core_type(obj)) - if self._task.args["operation"] == "up": - # todo rework - raise_flag(flag=BuiltInFlag.ADCM_OUTDATED_CONFIG.value, on_objects=[target]) - send_object_update_event(object_=obj, changes={"status": ADCMEntityStatus.UP.value}) - elif self._task.args["operation"] == "down": - # todo rework - lower_flag(name=BuiltInFlag.ADCM_OUTDATED_CONFIG.value.name, on_objects=[target]) - send_object_update_event(object_=obj, changes={"status": ADCMEntityStatus.DOWN.value}) - return {"failed": False, "changed": True} +class ActionModule(ADCMAnsiblePlugin): + executor_class = ADCMChangeFlagPluginExecutor diff --git a/python/ansible/plugins/action/adcm_config.py b/python/ansible/plugins/action/adcm_config.py index 135edaa19a..3ed6e0ac93 100644 --- a/python/ansible/plugins/action/adcm_config.py +++ b/python/ansible/plugins/action/adcm_config.py @@ -43,7 +43,7 @@ options: - option-name: type required: true - choises: + choices: - cluster - service - host diff --git a/python/ansible_plugin/base.py b/python/ansible_plugin/base.py new file mode 100644 index 0000000000..b75b53797b --- /dev/null +++ b/python/ansible_plugin/base.py @@ -0,0 +1,359 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from abc import abstractmethod +from dataclasses import dataclass +from typing import Collection, Generic, Literal, Protocol, TypeVar +import fcntl + +from ansible.errors import AnsibleActionFail +from ansible.module_utils._text import to_native +from ansible.plugins.action import ActionBase +from cm.models import ClusterObject, ServiceComponent +from core.types import ADCMCoreType, CoreObjectDescriptor +from django.conf import settings +from pydantic import BaseModel, ValidationError + +from ansible_plugin.errors import ADCMPluginError, PluginRuntimeError, PluginTargetDetectionError, PluginValidationError + +# Input + + +TargetTypeLiteral = Literal["cluster", "service", "component", "provider", "host"] + + +class AnsibleJobContext(BaseModel): + """Context from `config.json`'s `context` section""" + + type: TargetTypeLiteral + cluster_id: int | None = None + service_id: int | None = None + component_id: int | None = None + provider_id: int | None = None + host_id: int | None = None + + +# Target + + +class TargetDetector(Protocol): + def __call__( + self, context_owner: CoreObjectDescriptor, context: AnsibleJobContext, raw_arguments: dict + ) -> tuple[CoreObjectDescriptor, ...]: + ... + + +class CoreObjectTargetDescription(BaseModel): + type: TargetTypeLiteral + + service_name: str | None = None + component_name: str | None = None + + +def from_objects( + context_owner: CoreObjectDescriptor, # noqa: ARG001 + context: AnsibleJobContext, + raw_arguments: dict, +) -> tuple[CoreObjectDescriptor, ...]: + if not isinstance(objects := raw_arguments.get("objects"), list): + return () + + targets = [] + + for target_description in (CoreObjectTargetDescription(**entry) for entry in objects): + match target_description.type: + case "cluster": + if context.cluster_id: + targets.append(CoreObjectDescriptor(id=context.cluster_id, type=ADCMCoreType.CLUSTER)) + else: + message = "Can't identify cluster from context" + raise PluginRuntimeError(message=message, original_error=None) + case "service": + if target_description.service_name: + if not context.cluster_id: + message = "Can't identify service by name without `cluster_id` in context" + raise PluginRuntimeError(message=message, original_error=None) + + targets.append( + CoreObjectDescriptor( + id=ClusterObject.objects.values_list("id", flat=True).get( + cluster_id=context.cluster_id, prototype__name=target_description.service_name + ), + type=ADCMCoreType.SERVICE, + ) + ) + elif context.service_id: + targets.append(CoreObjectDescriptor(id=context.service_id, type=ADCMCoreType.SERVICE)) + else: + message = f"Can't identify service based on {target_description=}" + raise PluginRuntimeError(message=message, original_error=None) + case "component": + if target_description.component_name: + if not context.cluster_id: + message = "Can't identify component by name without `cluster_id` in context" + raise PluginRuntimeError(message=message, original_error=None) + + component_id_qs = ServiceComponent.objects.values_list("id", flat=True) + kwargs = {"cluster_id": context.cluster_id, "prototype__name": target_description.component_name} + if target_description.service_name: + targets.append( + CoreObjectDescriptor( + id=component_id_qs.get( + **kwargs, service__prototype__name=target_description.service_name + ), + type=ADCMCoreType.COMPONENT, + ) + ) + elif context.service_id: + targets.append( + CoreObjectDescriptor( + id=component_id_qs.get(**kwargs, service_id=context.service_id), + type=ADCMCoreType.COMPONENT, + ) + ) + else: + message = "Can't identify component by name without `service_name` or out of service context" + raise PluginRuntimeError(message=message, original_error=None) + + elif context.component_id: + targets.append(CoreObjectDescriptor(id=context.component_id, type=ADCMCoreType.COMPONENT)) + else: + message = f"Can't identify component based on {target_description=}" + raise PluginRuntimeError(message=message, original_error=None) + case "provider": + if context.provider_id: + targets.append(CoreObjectDescriptor(id=context.provider_id, type=ADCMCoreType.HOSTPROVIDER)) + else: + message = "Can't identify hostprovider from context" + raise PluginRuntimeError(message=message, original_error=None) + case "host": + if context.host_id: + targets.append(CoreObjectDescriptor(id=context.host_id, type=ADCMCoreType.HOST)) + else: + message = "Can't identify host from context" + raise PluginRuntimeError(message=message, original_error=None) + + return tuple(targets) + + +def from_context( + context_owner: CoreObjectDescriptor, + context: AnsibleJobContext, # noqa: ARG001 + raw_arguments: dict, # noqa: ARG001 +) -> tuple[CoreObjectDescriptor, ...]: + return (context_owner,) + + +# Plugin + +CallArguments = TypeVar("CallArguments", bound=BaseModel) +ReturnValue = TypeVar("ReturnValue") + + +@dataclass(frozen=True, slots=True) +class CallResult(Generic[ReturnValue]): + value: ReturnValue + changed: bool + error: ADCMPluginError | None + + +class ArgumentsValidator(Protocol[CallArguments]): + def __call__(self, arguments: CallArguments) -> PluginValidationError | None: + ... + + +@dataclass(frozen=True, slots=True) +class ArgumentsConfig(Generic[CallArguments]): + represent_as: type[CallArguments] + # post parsing validators + validators: Collection[ArgumentsValidator[CallArguments]] = () + + +class TargetValidator(Protocol): + def __call__( + self, context_owner: CoreObjectDescriptor, context: AnsibleJobContext, raw_arguments: dict + ) -> PluginValidationError | None: + ... + + +@dataclass(frozen=True, slots=True) +class TargetConfig: + """ + `Target` is an object for which plugin should be "performed". + In most cases it will be object-owner of action. + + `detectors` contain callables that tries to extract targets ordered by priority + (next in line won't be evaluated if previous returned non-empty result). + They shouldn't contain any logic, only extraction. + Sanity of evaluated targets should be checked either in `validators` or executor itself. + + `validators` are designed to be evaluated before `detectors` + and check whether there are any conflicts specific to current plugin. + """ + + detectors: tuple[TargetDetector, ...] + # pre-detection validators + validators: Collection[TargetValidator] = () + + +@dataclass(frozen=True, slots=True) +class PluginExecutorConfig(Generic[CallArguments]): + arguments: ArgumentsConfig[CallArguments] + target: TargetConfig + + +class ADCMAnsiblePluginExecutor(Generic[CallArguments, ReturnValue]): + """ + Class to define operation associated with Ansible plugin without direct bounds to Ansible runtime. + + To create your own executor, you have to define configuration via `_config` and implement `__call__` method. + Input validation and execution target detection will be performed according to the configuration. + If input is valid and targets are detected, instance of executor will be called. + + Try to avoid overriding other methods whenever possible (and adequate). + Override only when you find achieving your goals with configuration too tricky + and `__call__` is "too late" for the things you require. + """ + + _config: PluginExecutorConfig[CallArguments] + + def __init__(self, arguments: dict, context: dict): + self._raw_arguments = arguments + self._raw_context = context + + @abstractmethod + def __call__( + self, + targets: Collection[CoreObjectDescriptor], + arguments: CallArguments, + context_owner: CoreObjectDescriptor, + context: AnsibleJobContext, + ) -> CallResult[ReturnValue]: + """ + Perform plugin operation. + + Mostly designed to call business functions based on parsed arguments and targets. + Input sanity checks may be performed here if there's not enough information for them on previous steps. + Examples of such checks are: + - ensuring no duplicates in targets + - checking for context owner - targets contradiction + + May raise exceptions, yet try to do so only in extraordinary cases. + May access arguments (config, etc.) through `self`, but is generally discouraged. + """ + + def execute(self) -> CallResult[ReturnValue]: + """ + Process arguments and context based on configuration, then perform operation on processed data. + Shouldn't raise errors. + """ + + try: + call_arguments, call_context = self._validate_inputs() + owner_from_context = CoreObjectDescriptor( + id=getattr(call_context, f"{call_context.type}_id"), + type=ADCMCoreType(call_context.type) if call_context.type != "provider" else ADCMCoreType.HOSTPROVIDER, + ) + self._validate_targets(context_owner=owner_from_context, context=call_context) + targets = self._detect_targets(context_owner=owner_from_context, context=call_context) + result = self( + context_owner=owner_from_context, targets=targets, arguments=call_arguments, context=call_context + ) + except ADCMPluginError as err: + return CallResult(value=None, changed=False, error=err) + except Exception as err: # noqa: BLE001 + message = f"Unhandled exception occurred during {self.__class__.__name__} call: {err}" + return CallResult(value=None, changed=False, error=PluginRuntimeError(message=message, original_error=err)) + + return result + + def _validate_inputs(self) -> tuple[CallArguments, AnsibleJobContext]: + try: + arguments = self._config.arguments.represent_as(**self._raw_arguments) + except ValidationError as err: + message = f"Arguments doesn't match expected schema:\n{err}" + raise PluginValidationError(message=message) from err + + for validator in self._config.arguments.validators: + error = validator(arguments) + if error: + raise error + + try: + context = AnsibleJobContext(**self._raw_context) + except ValidationError as err: + message = f"Context doesn't match expected schema:\n{err}" + raise PluginValidationError(message=message) from err + + return arguments, context + + def _validate_targets(self, context_owner: CoreObjectDescriptor, context: AnsibleJobContext) -> None: + for validator in self._config.target.validators: + error = validator(context_owner=context_owner, context=context, raw_arguments=self._raw_arguments) + if error: + raise error + + def _detect_targets( + self, context_owner: CoreObjectDescriptor, context: AnsibleJobContext + ) -> tuple[CoreObjectDescriptor, ...]: + for detector in self._config.target.detectors: + result = detector(context_owner=context_owner, context=context, raw_arguments=self._raw_arguments) + if result: + return result + + message = "Failed to detect target from arguments of context" + raise PluginTargetDetectionError(message) + + +class ADCMAnsiblePlugin(ActionBase): + """ + Base class for custom `ActionModule`'s + + Descendants shouldn't contain logic defined directly in them. + In most cases you'll just define in your module: + + ``` + class ActionModule(ADCMAnsiblePlugin): + executor_class = PluginExecutorDescendantClass + ``` + + The only time you'll need to override `run` is when more ansible runtime context awareness is required. + """ + + executor_class: type[ADCMAnsiblePluginExecutor] + + def run(self, tmp=None, task_vars=None): + super().run(tmp=tmp, task_vars=task_vars) + + # Acquiring blocking lock on job's `config.json` file. + # + # Re-implemented from `job_lock` and similar functions, + # skipping exception catching to avoid hiding actual errors under `AdcmEx`/`AnsibleActionFail` facade. + # It looks like unpredicted situation, it should behave like one. + # + # Thou full motivation for performing such lock is unknown, + # it looks like a way of preventing parallel execution of plugins(check out `flock` man for more info). + # For example, parallel execution may be invoked when `run_once` isn't used in playbook + # and target ansible host group isn't `localhost`. + # It's also likely that such lock was somehow required for SQLite support, + # when we can rely on transactions correctness using PostgreSQL. + # + # Ergo this behavior and its motivation should be revisioned, alternative solutions discovered. + with (settings.RUN_DIR / str(task_vars["job"]["id"]) / "config.json").open(encoding="utf-8") as file: + fcntl.flock(file.fileno(), fcntl.LOCK_EX) + + executor = self.executor_class(arguments=self._task.args, context=task_vars.get("context", {})) + execution_result = executor.execute() + + if execution_result.error: + raise AnsibleActionFail(message=to_native(execution_result.error.message)) from execution_result.error + + return {"changed": execution_result.changed, "value": execution_result.value} diff --git a/python/ansible_plugin/errors.py b/python/ansible_plugin/errors.py new file mode 100644 index 0000000000..09de17a5bc --- /dev/null +++ b/python/ansible_plugin/errors.py @@ -0,0 +1,31 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +class ADCMPluginError(Exception): + def __init__(self, message: str): + super().__init__(message) + self.message = message + + +class PluginRuntimeError(ADCMPluginError): + def __init__(self, message: str, original_error: Exception | None): + super().__init__(message=message) + self.original_error = original_error + + +class PluginTargetDetectionError(ADCMPluginError): + ... + + +class PluginValidationError(ADCMPluginError): + ... diff --git a/python/ansible_plugin/executors/__init__.py b/python/ansible_plugin/executors/__init__.py new file mode 100644 index 0000000000..f3e59d81d8 --- /dev/null +++ b/python/ansible_plugin/executors/__init__.py @@ -0,0 +1,12 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/python/ansible_plugin/executors/change_flag.py b/python/ansible_plugin/executors/change_flag.py new file mode 100644 index 0000000000..ca757362ee --- /dev/null +++ b/python/ansible_plugin/executors/change_flag.py @@ -0,0 +1,133 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from enum import Enum +from typing import Collection + +from cm.services.concern.flags import ( + BuiltInFlag, + ConcernFlag, + lower_all_flags, + lower_flag, + raise_flag, +) +from core.types import ADCMCoreType, CoreObjectDescriptor +from django.db.transaction import atomic +from pydantic import BaseModel, validator + +from ansible_plugin.base import ( + ADCMAnsiblePluginExecutor, + AnsibleJobContext, + ArgumentsConfig, + CallResult, + PluginExecutorConfig, + ReturnValue, + TargetConfig, + from_context, + from_objects, +) +from ansible_plugin.errors import PluginRuntimeError, PluginValidationError + + +class ChangeFlagOperation(str, Enum): + UP = "up" + DOWN = "down" + + +class ChangeFlagArguments(BaseModel): + operation: ChangeFlagOperation + name: str | None = None + msg: str = "" + + @validator("name") + def check_name_length(cls, v: str | None) -> str | None: # noqa: N805 + if v is None: + return v + + if len(v) < 1: + message = "`name` should be at least 1 symbol" + raise ValueError(message) + + return v + + +def validate_objects( + context_owner: CoreObjectDescriptor, + context: AnsibleJobContext, # noqa: ARG001 + raw_arguments: dict, +) -> PluginValidationError | None: + match context_owner.type: + case ADCMCoreType.HOSTPROVIDER | ADCMCoreType.HOST: + if "objects" in raw_arguments: + return PluginValidationError(message=f"`objects` shouldn't be specified for {context_owner.type}") + case ADCMCoreType.CLUSTER | ADCMCoreType.SERVICE | ADCMCoreType.COMPONENT: + objects_ = raw_arguments.get("objects") + if objects_ is not None: + if not isinstance(objects_, list) or not all(isinstance(entry, dict) for entry in objects_): + return PluginValidationError(message="`objects` should be of `list[dict]` type") + + allowed_types = ("cluster", "service", "component") + if any(entry.get("type") not in allowed_types for entry in objects_): + return PluginValidationError(message=f"`objects` can only be one of: {', '.join(allowed_types)}") + + return None + + +def validate_name_and_message_correct_for_operation(arguments: ChangeFlagArguments) -> PluginValidationError | None: + if arguments.operation == ChangeFlagOperation.DOWN: + return None + + if not arguments.name: + return PluginValidationError(f"`name` should be specified for `{ChangeFlagOperation.UP.value}` operation") + + +class ADCMChangeFlagPluginExecutor(ADCMAnsiblePluginExecutor[ChangeFlagArguments, None]): + _config = PluginExecutorConfig( + arguments=ArgumentsConfig( + represent_as=ChangeFlagArguments, validators=(validate_name_and_message_correct_for_operation,) + ), + target=TargetConfig(detectors=(from_objects, from_context), validators=(validate_objects,)), + ) + + @atomic() + def __call__( + self, + targets: Collection[CoreObjectDescriptor], + arguments: ChangeFlagArguments, + context_owner: CoreObjectDescriptor, + context: AnsibleJobContext, + ) -> CallResult[ReturnValue]: + _ = context, context_owner + + match arguments.operation: + case ChangeFlagOperation.UP: + built_in_flag = BuiltInFlag.__members__.get(arguments.name.upper()) + if built_in_flag: + flag = built_in_flag.value + if arguments.msg: + flag = ConcernFlag(name=flag.name, message=arguments.msg, cause=flag.cause) + else: + flag = ConcernFlag(name=arguments.name.lower(), message=arguments.msg, cause=None) + + raise_flag(flag=flag, on_objects=targets) + # todo add updating concerns hierarchy + case ChangeFlagOperation.DOWN: + # todo add tests on lower_flag (that related concerns are deleted) + if arguments.name: + lower_flag(name=arguments.name.lower(), on_objects=targets) + else: + lower_all_flags(on_objects=targets) + case _: + message = f"Can't handle operation {arguments.operation}" + raise PluginRuntimeError(message=message, original_error=None) + + return CallResult(value=None, changed=True, error=None) diff --git a/python/ansible_plugin/tests/__init__.py b/python/ansible_plugin/tests/__init__.py new file mode 100644 index 0000000000..f3e59d81d8 --- /dev/null +++ b/python/ansible_plugin/tests/__init__.py @@ -0,0 +1,12 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/python/ansible_plugin/tests/bundles/cluster/config.yaml b/python/ansible_plugin/tests/bundles/cluster/config.yaml new file mode 100644 index 0000000000..4f1a09494f --- /dev/null +++ b/python/ansible_plugin/tests/bundles/cluster/config.yaml @@ -0,0 +1,32 @@ +- type: cluster + name: simple_cluster + version: 3 + + actions: &actions + dummy: &action + type: job + script_type: ansible + script: ./playbook.yaml + masking: + + on_host: + <<: *action + host_action: true + +- &service + type: service + name: service_1 + version: 2 + + actions: *actions + + components: + component_1: + actions: *actions + + component_2: + actions: *actions + +- <<: *service + name: service_2 + diff --git a/python/ansible_plugin/tests/bundles/provider/config.yaml b/python/ansible_plugin/tests/bundles/provider/config.yaml new file mode 100644 index 0000000000..0c954415c6 --- /dev/null +++ b/python/ansible_plugin/tests/bundles/provider/config.yaml @@ -0,0 +1,16 @@ +- type: provider + name: simple_provider + version: 3 + + actions: &actions + dummy: + type: job + script_type: ansible + script: ./playbook.yaml + masking: + +- type: host + name: host + version: 2 + + actions: *actions diff --git a/python/ansible_plugin/tests/test_adcm_change_flag.py b/python/ansible_plugin/tests/test_adcm_change_flag.py new file mode 100644 index 0000000000..5f102b1671 --- /dev/null +++ b/python/ansible_plugin/tests/test_adcm_change_flag.py @@ -0,0 +1,242 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path +from typing import Collection +from unittest.mock import patch + +from adcm.tests.ansible import ADCMAnsiblePluginTestMixin +from adcm.tests.base import BusinessLogicMixin, ParallelReadyTestCase, TaskTestMixin, TestCaseWithCommonSetUpTearDown +from cm.models import ConcernItem, ServiceComponent +from cm.services.concern.flags import BuiltInFlag, ConcernFlag +from cm.services.job.run.repo import JobRepoImpl +from core.job.types import Task +from core.types import ADCMCoreType, CoreObjectDescriptor + +from ansible_plugin.executors.change_flag import ADCMChangeFlagPluginExecutor + +EXECUTOR_MODULE = "ansible_plugin.executors.change_flag" + + +class TestEffectsOfADCMAnsiblePlugins( + TestCaseWithCommonSetUpTearDown, + ParallelReadyTestCase, + BusinessLogicMixin, + ADCMAnsiblePluginTestMixin, + TaskTestMixin, +): + def setUp(self) -> None: + super().setUp() + + ConcernItem.objects.all().delete() + + self.bundles_dir = Path(__file__).parent / "bundles" + + cluster_bundle = self.add_bundle(self.bundles_dir / "cluster") + provider_bundle = self.add_bundle(self.bundles_dir / "provider") + + self.cluster = self.add_cluster(bundle=cluster_bundle, name="Just Cluster") + + self.provider = self.add_provider(bundle=provider_bundle, name="Just HP") + self.host_1 = self.add_host(provider=self.provider, fqdn="host-1") + self.host_2 = self.add_host(provider=self.provider, fqdn="host-2") + + self.service_1, self.service_2 = self.add_services_to_cluster( + ["service_1", "service_2"], cluster=self.cluster + ).order_by("prototype__name") + self.component_1, self.component_2 = ( + ServiceComponent.objects.filter(service=self.service_1).order_by("prototype__name").all() + ) + + self.add_host_to_cluster(cluster=self.cluster, host=self.host_1) + self.add_host_to_cluster(cluster=self.cluster, host=self.host_2) + + self.set_hostcomponent( + cluster=self.cluster, + entries=((self.host_1, self.component_1), (self.host_1, self.component_2), (self.host_2, self.component_1)), + ) + + def execute_plugin_patched(self, task, arguments): + job, *_ = JobRepoImpl.get_task_jobs(task.id) + executor = self.prepare_executor( + executor_type=ADCMChangeFlagPluginExecutor, call_arguments=arguments, call_context=job + ) + + with ( + patch(f"{EXECUTOR_MODULE}.raise_flag") as raise_flag_mock, + patch(f"{EXECUTOR_MODULE}.lower_flag") as lower_flag_mock, + patch(f"{EXECUTOR_MODULE}.lower_all_flags") as lower_all_flags_mock, + ): + result = executor.execute() + + self.assertIsNone(result.error) + + return raise_flag_mock, lower_flag_mock, lower_all_flags_mock + + def check_raise_called( + self, flag: ConcernFlag, task: Task, arguments: str | dict, targets: Collection[CoreObjectDescriptor] + ) -> None: + raise_flag_mock, lower_flag_mock, lower_all_flags_mock = self.execute_plugin_patched(task, arguments) + + raise_flag_mock.assert_called_once_with( + flag=flag, + on_objects=targets, + ) + lower_flag_mock.assert_not_called() + lower_all_flags_mock.assert_not_called() + # todo add check for update hierarchy call + # and in check_lower* too + + def check_lower_called( + self, flag_name: str, task: Task, arguments: str | dict, targets: Collection[CoreObjectDescriptor] + ) -> None: + raise_flag_mock, lower_flag_mock, lower_all_flags_mock = self.execute_plugin_patched(task, arguments) + + raise_flag_mock.assert_not_called() + lower_flag_mock.assert_called_once_with(name=flag_name, on_objects=targets) + lower_all_flags_mock.assert_not_called() + + def check_lower_all_called( + self, task: Task, arguments: str | dict, targets: Collection[CoreObjectDescriptor] + ) -> None: + raise_flag_mock, lower_flag_mock, lower_all_flags_mock = self.execute_plugin_patched(task, arguments) + + raise_flag_mock.assert_not_called() + lower_flag_mock.assert_not_called() + lower_all_flags_mock.assert_called_once_with(on_objects=targets) + + def test_raise_flag_on_objects_success(self) -> None: + flag = ConcernFlag(name="need_restart", message='You need to run action "Restart"') + + self.check_raise_called( + flag=flag, + task=self.prepare_task(owner=self.cluster, name="dummy"), + arguments=f""" + operation: up + name: {flag.name} + msg: '{flag.message}' + objects: + - type: service + service_name: service_2 + - type: component + service_name: service_1 + component_name: component_2 + """, + targets=( + CoreObjectDescriptor(id=self.service_2.id, type=ADCMCoreType.SERVICE), + CoreObjectDescriptor(id=self.component_2.id, type=ADCMCoreType.COMPONENT), + ), + ) + + def test_raise_default_flag_on_provider_from_context_success(self) -> None: + flag = BuiltInFlag.ADCM_OUTDATED_CONFIG.value + + self.check_raise_called( + flag=flag, + task=self.prepare_task(owner=self.provider, name="dummy"), + arguments=""" + operation: up + name: adcm_outdated_config + """, + targets=(CoreObjectDescriptor(id=self.provider.id, type=ADCMCoreType.HOSTPROVIDER),), + ) + + def test_raise_default_flag_changed_message_on_component_from_context_success(self) -> None: + default_flag = BuiltInFlag.ADCM_OUTDATED_CONFIG.value + flag = ConcernFlag( + name=default_flag.name, message="Your config is in pretty bad shape", cause=default_flag.cause + ) + + self.check_raise_called( + flag=flag, + task=self.prepare_task(owner=self.host_2, name="dummy"), + arguments=f""" + operation: up + name: adcm_outdated_config + msg: {flag.message} + """, + targets=(CoreObjectDescriptor(id=self.host_2.id, type=ADCMCoreType.HOST),), + ) + + def test_lower_with_name_component_host_action_from_context_success(self) -> None: + flag_name = "some_flag" + + self.check_lower_called( + flag_name=flag_name, + task=self.prepare_task(owner=self.component_2, name="on_host", host=self.host_1), + arguments=f""" + operation: down + name: {flag_name} + """, + targets=(CoreObjectDescriptor(id=self.component_2.id, type=ADCMCoreType.COMPONENT),), + ) + + def test_lower_all_service_context_from_objects_success(self) -> None: + component = ServiceComponent.objects.get(prototype__name="component_2", service=self.service_2) + + self.check_lower_all_called( + task=self.prepare_task(owner=self.service_2, name="dummy"), + arguments=""" + operation: down + objects: + - type: cluster + - type: service + - type: component + component_name: component_2 + """, + targets=( + CoreObjectDescriptor(id=self.cluster.id, type=ADCMCoreType.CLUSTER), + CoreObjectDescriptor(id=self.service_2.id, type=ADCMCoreType.SERVICE), + CoreObjectDescriptor(id=component.id, type=ADCMCoreType.COMPONENT), + ), + ) + + def test_lower_all_cluster_context_from_objects_success(self) -> None: + self.check_lower_all_called( + task=self.prepare_task(owner=self.cluster, name="dummy"), + arguments=""" + operation: down + objects: + - type: cluster + """, + targets=(CoreObjectDescriptor(id=self.cluster.id, type=ADCMCoreType.CLUSTER),), + ) + + def test_incorrect_name_fail(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + executor = self.prepare_executor( + executor_type=ADCMChangeFlagPluginExecutor, call_arguments={"operation": "up"}, call_context=job + ) + result = executor.execute() + self.assertIsNotNone(result.error) + self.assertEqual("`name` should be specified for `up` operation", result.error.message) + + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + executor = self.prepare_executor( + executor_type=ADCMChangeFlagPluginExecutor, call_arguments={"operation": "up", "name": ""}, call_context=job + ) + result = executor.execute() + self.assertIsNotNone(result.error) + self.assertIn("`name` should be at least 1 symbol", result.error.message) + + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + executor = self.prepare_executor( + executor_type=ADCMChangeFlagPluginExecutor, + call_arguments={"operation": "down", "name": ""}, + call_context=job, + ) + result = executor.execute() + self.assertIsNotNone(result.error) + self.assertIn("`name` should be at least 1 symbol", result.error.message) diff --git a/python/ansible_plugin/tests/test_targets_extraction.py b/python/ansible_plugin/tests/test_targets_extraction.py new file mode 100644 index 0000000000..ab5a4b956d --- /dev/null +++ b/python/ansible_plugin/tests/test_targets_extraction.py @@ -0,0 +1,229 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path + +from adcm.tests.ansible import ADCMAnsiblePluginTestMixin, DummyExecutor +from adcm.tests.base import BaseTestCase, BusinessLogicMixin, TaskTestMixin +from cm.models import ClusterObject, ServiceComponent +from cm.services.job.run.repo import JobRepoImpl +from core.job.types import Task +from core.types import ADCMCoreType, CoreObjectDescriptor +from pydantic import BaseModel + +from ansible_plugin.base import ArgumentsConfig, PluginExecutorConfig, TargetConfig, from_objects + + +class EmptyArguments(BaseModel): + ... + + +class TestObjectsTargetsExtraction(BaseTestCase, BusinessLogicMixin, ADCMAnsiblePluginTestMixin, TaskTestMixin): + def setUp(self): + super().setUp() + + self.targets_from_objects_executor = DummyExecutor( + config=PluginExecutorConfig( + arguments=ArgumentsConfig(represent_as=EmptyArguments), + target=TargetConfig(detectors=(from_objects,)), + ) + ) + + self.bundles_dir = Path(__file__).parent / "bundles" + + cluster_bundle = self.add_bundle(self.bundles_dir / "cluster") + provider_bundle = self.add_bundle(self.bundles_dir / "provider") + + self.cluster_1 = self.add_cluster(bundle=cluster_bundle, name="Cluster 1") + self.cluster_2 = self.add_cluster(bundle=cluster_bundle, name="Cluster 2") + + for cluster in (self.cluster_1, self.cluster_2): + self.add_services_to_cluster(["service_1", "service_2"], cluster=cluster) + + self.provider_1 = self.add_provider(bundle=provider_bundle, name="Provider 1") + self.provider_2 = self.add_provider(bundle=provider_bundle, name="Provider 2") + + self.host_1 = self.add_host(provider=self.provider_1, fqdn="provider-1-host-1") + self.host_2 = self.add_host(provider=self.provider_1, fqdn="provider-1-host-2") + self.host_3 = self.add_host(provider=self.provider_2, fqdn="provider-2-host-1") + self.host_4 = self.add_host(provider=self.provider_2, fqdn="provider-2-host-2") + + def test_full_info_objects_of_cluster_context_success(self) -> None: + arguments = """ + objects: + - type: service + service_name: service_2 + - type: cluster + - type: component + service_name: service_1 + component_name: component_2 + """ + + expected_cluster = self.cluster_1 + expected_service = ClusterObject.objects.get(prototype__name="service_2", cluster=self.cluster_1) + expected_component = ServiceComponent.objects.get( + prototype__name="component_2", service__prototype__name="service_1", cluster=self.cluster_1 + ) + + another_service = ClusterObject.objects.get(prototype__name="service_1", cluster=self.cluster_1) + another_component = ServiceComponent.objects.get( + prototype__name="component_2", service__prototype__name="service_1", cluster=self.cluster_1 + ) + + for action_owner in (expected_cluster, another_service, another_component): + with self.subTest(f"Action context from {action_owner.__class__.__name__}"): + self.check_target_detection( + arguments=arguments, + task=self.prepare_task(owner=action_owner, name="dummy"), + expected_targets=[ + CoreObjectDescriptor(id=expected_service.id, type=ADCMCoreType.SERVICE), + CoreObjectDescriptor(id=expected_cluster.id, type=ADCMCoreType.CLUSTER), + CoreObjectDescriptor(id=expected_component.id, type=ADCMCoreType.COMPONENT), + ], + ) + + def test_service_context_success(self) -> None: + arguments = { + "objects": [ + {"type": "service"}, + # another service + {"type": "service", "service_name": "service_1"}, + {"type": "cluster"}, + # component of this service + {"type": "component", "component_name": "component_1"}, + # component of another service + {"type": "component", "service_name": "service_1", "component_name": "component_1"}, + # this service, but by name + {"type": "service", "service_name": "service_2"}, + ] + } + + parent_cluster = self.cluster_2 + context_service = ClusterObject.objects.get(prototype__name="service_2", cluster=parent_cluster) + another_service = ClusterObject.objects.get(prototype__name="service_1", cluster=parent_cluster) + child_component = ServiceComponent.objects.get(service=context_service, prototype__name="component_1") + another_service_component = ServiceComponent.objects.get(service=another_service, prototype__name="component_1") + + self.check_target_detection( + arguments=arguments, + task=self.prepare_task(owner=context_service, name="dummy"), + expected_targets=[ + CoreObjectDescriptor(id=context_service.id, type=ADCMCoreType.SERVICE), + CoreObjectDescriptor(id=another_service.id, type=ADCMCoreType.SERVICE), + CoreObjectDescriptor(id=parent_cluster.id, type=ADCMCoreType.CLUSTER), + CoreObjectDescriptor(id=child_component.id, type=ADCMCoreType.COMPONENT), + CoreObjectDescriptor(id=another_service_component.id, type=ADCMCoreType.COMPONENT), + CoreObjectDescriptor(id=context_service.id, type=ADCMCoreType.SERVICE), + ], + ) + + def test_component_context_success(self) -> None: + arguments = { + "objects": [ + # parent service + {"type": "service"}, + # another service + {"type": "service", "service_name": "service_1"}, + {"type": "cluster"}, + # this component + {"type": "component", "component_name": "component_1"}, + # component of another service + {"type": "component", "service_name": "service_1", "component_name": "component_1"}, + # parent service, but by name + {"type": "service", "service_name": "service_2"}, + # this component + {"type": "component"}, + # another component of this service + {"type": "component", "component_name": "component_2"}, + ] + } + + parent_cluster = self.cluster_2 + context_service = ClusterObject.objects.get(prototype__name="service_2", cluster=parent_cluster) + another_service = ClusterObject.objects.get(prototype__name="service_1", cluster=parent_cluster) + context_component = ServiceComponent.objects.get(service=context_service, prototype__name="component_1") + another_component_of_same_service = ServiceComponent.objects.get( + service=context_service, prototype__name="component_2" + ) + another_service_component = ServiceComponent.objects.get(service=another_service, prototype__name="component_1") + + self.check_target_detection( + arguments=arguments, + task=self.prepare_task(owner=context_component, name="dummy"), + expected_targets=[ + CoreObjectDescriptor(id=context_service.id, type=ADCMCoreType.SERVICE), + CoreObjectDescriptor(id=another_service.id, type=ADCMCoreType.SERVICE), + CoreObjectDescriptor(id=parent_cluster.id, type=ADCMCoreType.CLUSTER), + CoreObjectDescriptor(id=context_component.id, type=ADCMCoreType.COMPONENT), + CoreObjectDescriptor(id=another_service_component.id, type=ADCMCoreType.COMPONENT), + CoreObjectDescriptor(id=context_service.id, type=ADCMCoreType.SERVICE), + CoreObjectDescriptor(id=context_component.id, type=ADCMCoreType.COMPONENT), + CoreObjectDescriptor(id=another_component_of_same_service.id, type=ADCMCoreType.COMPONENT), + ], + ) + + def test_host_context_success(self) -> None: + arguments = """ + objects: + - type: "host" + - type: "provider" + """ + + host = self.host_3 + provider = host.provider + + self.check_target_detection( + arguments=arguments, + task=self.prepare_task(owner=host, name="dummy"), + expected_targets=[ + CoreObjectDescriptor(id=host.id, type=ADCMCoreType.HOST), + CoreObjectDescriptor(id=provider.id, type=ADCMCoreType.HOSTPROVIDER), + ], + ) + + def test_component_host_action_context_success(self): + arguments = {"objects": [{"type": "service"}, {"type": "cluster"}, {"type": "component"}, {"type": "host"}]} + + host = self.host_2 + parent_cluster = self.cluster_1 + component = ServiceComponent.objects.filter(cluster=parent_cluster).first() + + self.add_host_to_cluster(cluster_pk=parent_cluster.pk, host_pk=host.pk) + self.set_hostcomponent(cluster=parent_cluster, entries=[(host, component)]) + + self.check_target_detection( + arguments=arguments, + task=self.prepare_task(owner=component, host=host, name="on_host"), + expected_targets=[ + CoreObjectDescriptor(id=component.service.id, type=ADCMCoreType.SERVICE), + CoreObjectDescriptor(id=parent_cluster.id, type=ADCMCoreType.CLUSTER), + CoreObjectDescriptor(id=component.id, type=ADCMCoreType.COMPONENT), + CoreObjectDescriptor(id=host.id, type=ADCMCoreType.HOST), + ], + ) + + def check_target_detection( + self, task: Task, arguments: dict | str, expected_targets: list[CoreObjectDescriptor] + ) -> None: + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=self.targets_from_objects_executor, call_arguments=arguments, call_context=job + ) + + result = executor.execute() + self.assertIsNone(result.error, result.error.message if result.error else "") + + self.assertListEqual( + list(result.value.targets), + expected_targets, + ) diff --git a/python/api_v2/tests/test_actions.py b/python/api_v2/tests/test_actions.py index 0d0e1f84af..d67f9ebe2d 100644 --- a/python/api_v2/tests/test_actions.py +++ b/python/api_v2/tests/test_actions.py @@ -71,8 +71,8 @@ def setUp(self) -> None: provider_bundle = self.add_bundle(self.test_bundles_dir / "provider_actions") self.hostprovider = self.add_provider(provider_bundle, "Provider with Actions") - self.host_1 = self.add_host(provider_bundle, self.hostprovider, "host-1") - self.host_2 = self.add_host(provider_bundle, self.hostprovider, "host-2") + self.host_1 = self.add_host(provider=self.hostprovider, fqdn="host-1") + self.host_2 = self.add_host(provider=self.hostprovider, fqdn="host-2") self.available_at_any = ["state_any"] common_at_created = [*self.available_at_any, "state_created", "state_created_masking"] diff --git a/python/cm/services/job/run/repo.py b/python/cm/services/job/run/repo.py index 4e0f5e3ca7..13c9ea7417 100644 --- a/python/cm/services/job/run/repo.py +++ b/python/cm/services/job/run/repo.py @@ -341,7 +341,7 @@ def _get_host_related_selector(cls, host_id: HostID, action_owner: PrototypeDesc ClusterObject.objects.values(**cls._selector_fields_map[ClusterObject]).filter(id=service_id) ) query = query.union( - ServiceComponent.objects.values(**cls._selector_fields_map[ServiceComponent]).filter(id=service_id) + ServiceComponent.objects.values(**cls._selector_fields_map[ServiceComponent]).filter(id=component_id) ) return query diff --git a/python/cm/tests/mocks/task_runner.py b/python/cm/tests/mocks/task_runner.py index f8c3b01e9e..e0c14974ce 100644 --- a/python/cm/tests/mocks/task_runner.py +++ b/python/cm/tests/mocks/task_runner.py @@ -9,7 +9,6 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from abc import ABC from datetime import datetime from functools import partial from typing import Any, Callable, Generator, Iterable, NamedTuple @@ -26,7 +25,7 @@ from cm.services.job.run._target_factories import ExecutionTargetFactory -def do_nothing(): +def do_nothing(*_, **__): return None @@ -40,8 +39,12 @@ class FailedJobInfo(NamedTuple): class JobImitator(NamedTuple): - call: Callable[[], Any] = do_nothing + call: Callable[[Executor], Any] = do_nothing return_code: int = 0 + use_call_return_code: bool = False + + +default_imitator = JobImitator() # ExecutionTarget Factories @@ -66,16 +69,15 @@ def __call__( executor = InternalExecutorMock(config=ExecutorConfig(work_dir=work_dir), script=script) else: - executor_class = SuccessExecutorMock executor_kwargs = {} if self._failed_job is not None and job_num == self._failed_job.position: - executor_class = FailExecutorMock executor_kwargs = {"return_code": self._failed_job.return_code} - executor = executor_class( + job_imitator = JobImitator(**executor_kwargs) + executor = MockExecutor( script_type=job.script, + imitator=job_imitator, config=ExecutorConfig(work_dir=configuration.adcm.run_dir / str(job.id)), - **executor_kwargs, ) yield ExecutionTarget( @@ -91,7 +93,7 @@ def __init__(self, change_jobs: dict[int, JobImitator] | None = None): super().__init__() self.imitators = change_jobs or {} - self.default_imitator = JobImitator() + self.default_imitator = default_imitator def __call__( self, task: Task, jobs: Iterable[Job], configuration: ExternalSettings @@ -102,15 +104,10 @@ def __call__( continue imitator = self.imitators.get(i, self.default_imitator) - common_kwargs = { - "script_type": target.executor.script_type, - "config": ExecutorConfig(work_dir=configuration.adcm.run_dir / str(target.job.id)), - "on_execute_": imitator.call, - } - executor = ( - SuccessExecutorMock(**common_kwargs) - if imitator.return_code == 0 - else FailExecutorMock(**common_kwargs, return_code=imitator.return_code) + executor = MockExecutor( + script_type=target.executor.script_type, + config=ExecutorConfig(work_dir=configuration.adcm.run_dir / str(target.job.id)), + imitator=imitator, ) yield ExecutionTarget( @@ -124,18 +121,29 @@ def __call__( # Executors -class MockExecutor(Executor, ABC): - def __init__(self, *args, on_execute_: Callable[[], Any] = lambda: None, **kwargs): +class MockExecutor(Executor): + def __init__(self, *args, script_type: str = "ansible", imitator: JobImitator = default_imitator, **kwargs): super().__init__(*args, **kwargs) - self._on_execute = on_execute_ + self._script_type = script_type + + if imitator.return_code < 0: + message = "Only integers >= 0 are allowed as return code" + raise ValueError(message) + + self._imitator = imitator + + @property + def script_type(self) -> str: + return self._script_type def execute(self) -> Self: - self._on_execute() + result = self._imitator.call(self) + code = result if self._imitator.use_call_return_code else self._imitator.return_code + self._result = ExecutionResult(code=code) return self def wait_finished(self) -> Self: - self._result = ExecutionResult(code=0) return self @@ -147,30 +155,7 @@ def __init__(self, config: ExecutorConfig, script: Callable[[], int]): self._script = script def execute(self) -> Self: - return self._script() - - -class SuccessExecutorMock(MockExecutor): - def __init__(self, *args, script_type: str, **kwargs): - super().__init__(*args, **kwargs) - self._script_type = script_type - - @property - def script_type(self) -> str: - return self._script_type - - -class FailExecutorMock(SuccessExecutorMock): - def __init__(self, *args, return_code: int, **kwargs): - super().__init__(*args, **kwargs) - - if return_code <= 0: - raise ValueError("Only positive integers allowed") - - self._return_code = return_code - - def wait_finished(self) -> Self: - self._result = ExecutionResult(code=self._return_code) + self._result = ExecutionResult(code=self._script()) return self @@ -217,3 +202,6 @@ def __exit__(self, exc_type, exc_val, exc_tb): self._run_patch = None return return_ + + def run(self): + return self.runner.run(task_id=self.target_task.pk) diff --git a/python/cm/tests/test_task_runner/bundles/cluster/config.yaml b/python/cm/tests/test_task_runner/bundles/cluster/config.yaml index 8b152ff18c..7ef93dabfc 100644 --- a/python/cm/tests/test_task_runner/bundles/cluster/config.yaml +++ b/python/cm/tests/test_task_runner/bundles/cluster/config.yaml @@ -14,10 +14,14 @@ script_type: ansible script: ./actions.yaml -- type: service +- &service + type: service name: simple version: 1 components: part_1: part_2: + +- <<: *service + name: second diff --git a/python/cm/tests/test_task_runner/test_plugin_effects.py b/python/cm/tests/test_task_runner/test_plugin_effects.py index 955b80e9b7..02ad05d850 100644 --- a/python/cm/tests/test_task_runner/test_plugin_effects.py +++ b/python/cm/tests/test_task_runner/test_plugin_effects.py @@ -54,7 +54,7 @@ def test_adcm_hc_should_not_cause_hc_acl_effect(self) -> None: with RunTaskMock( execution_target_factory=ETFMockWithEnvPreparation( - change_jobs={0: JobImitator(call=lambda: change_hc(1, self.cluster.id, operations))} + change_jobs={0: JobImitator(call=lambda _: change_hc(1, self.cluster.id, operations))} ) ) as run_task: run_action( From 7e7de00c5c050b74883b40669d911489337a754d Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Mon, 8 Apr 2024 06:36:26 +0000 Subject: [PATCH 037/208] ADCM-5457 Consider raise/lower result for change output in plugin, update hierarchy on raise and todos resolution ADCM-5457 Consider raise/lower result for `change` output in plugin, update hierarchy on raise and todos resolution --- .../ansible_plugin/executors/change_flag.py | 12 +- .../tests/test_adcm_change_flag.py | 171 ++++++++++++++-- python/api_v2/tests/test_service.py | 9 +- python/cm/api.py | 38 ++-- python/cm/issue.py | 28 +-- .../cm/migrations/0120_change_concern_item.py | 1 - .../0121_flag_autogeneration_object.py | 7 + python/cm/services/concern/flags.py | 47 +++-- python/cm/services/concern/messages.py | 18 +- python/cm/services/maintenance_mode.py | 21 +- .../cm/tests/test_migrations/test_120_121.py | 186 ++++++++++++++++++ 11 files changed, 452 insertions(+), 86 deletions(-) create mode 100644 python/cm/tests/test_migrations/test_120_121.py diff --git a/python/ansible_plugin/executors/change_flag.py b/python/ansible_plugin/executors/change_flag.py index ca757362ee..4123ee5809 100644 --- a/python/ansible_plugin/executors/change_flag.py +++ b/python/ansible_plugin/executors/change_flag.py @@ -19,6 +19,7 @@ lower_all_flags, lower_flag, raise_flag, + update_hierarchy_for_flag, ) from core.types import ADCMCoreType, CoreObjectDescriptor from django.db.transaction import atomic @@ -118,16 +119,15 @@ def __call__( else: flag = ConcernFlag(name=arguments.name.lower(), message=arguments.msg, cause=None) - raise_flag(flag=flag, on_objects=targets) - # todo add updating concerns hierarchy + changed = raise_flag(flag=flag, on_objects=targets) + update_hierarchy_for_flag(flag=flag, on_objects=targets) case ChangeFlagOperation.DOWN: - # todo add tests on lower_flag (that related concerns are deleted) if arguments.name: - lower_flag(name=arguments.name.lower(), on_objects=targets) + changed = lower_flag(name=arguments.name.lower(), on_objects=targets) else: - lower_all_flags(on_objects=targets) + changed = lower_all_flags(on_objects=targets) case _: message = f"Can't handle operation {arguments.operation}" raise PluginRuntimeError(message=message, original_error=None) - return CallResult(value=None, changed=True, error=None) + return CallResult(value=None, changed=changed, error=None) diff --git a/python/ansible_plugin/tests/test_adcm_change_flag.py b/python/ansible_plugin/tests/test_adcm_change_flag.py index 5f102b1671..4511b68cf5 100644 --- a/python/ansible_plugin/tests/test_adcm_change_flag.py +++ b/python/ansible_plugin/tests/test_adcm_change_flag.py @@ -17,7 +17,7 @@ from adcm.tests.ansible import ADCMAnsiblePluginTestMixin from adcm.tests.base import BusinessLogicMixin, ParallelReadyTestCase, TaskTestMixin, TestCaseWithCommonSetUpTearDown from cm.models import ConcernItem, ServiceComponent -from cm.services.concern.flags import BuiltInFlag, ConcernFlag +from cm.services.concern.flags import BuiltInFlag, ConcernFlag, lower_all_flags, raise_flag from cm.services.job.run.repo import JobRepoImpl from core.job.types import Task from core.types import ADCMCoreType, CoreObjectDescriptor @@ -75,42 +75,56 @@ def execute_plugin_patched(self, task, arguments): patch(f"{EXECUTOR_MODULE}.raise_flag") as raise_flag_mock, patch(f"{EXECUTOR_MODULE}.lower_flag") as lower_flag_mock, patch(f"{EXECUTOR_MODULE}.lower_all_flags") as lower_all_flags_mock, + patch(f"{EXECUTOR_MODULE}.update_hierarchy_for_flag") as update_hierarchy_for_flag_mock, ): result = executor.execute() self.assertIsNone(result.error) - return raise_flag_mock, lower_flag_mock, lower_all_flags_mock + return raise_flag_mock, lower_flag_mock, lower_all_flags_mock, update_hierarchy_for_flag_mock def check_raise_called( self, flag: ConcernFlag, task: Task, arguments: str | dict, targets: Collection[CoreObjectDescriptor] ) -> None: - raise_flag_mock, lower_flag_mock, lower_all_flags_mock = self.execute_plugin_patched(task, arguments) - - raise_flag_mock.assert_called_once_with( - flag=flag, - on_objects=targets, - ) + ( + raise_flag_mock, + lower_flag_mock, + lower_all_flags_mock, + update_hierarchy_for_flag_mock, + ) = self.execute_plugin_patched(task, arguments) + + raise_flag_mock.assert_called_once_with(flag=flag, on_objects=targets) + update_hierarchy_for_flag_mock.assert_called_once_with(flag=flag, on_objects=targets) lower_flag_mock.assert_not_called() lower_all_flags_mock.assert_not_called() - # todo add check for update hierarchy call - # and in check_lower* too def check_lower_called( self, flag_name: str, task: Task, arguments: str | dict, targets: Collection[CoreObjectDescriptor] ) -> None: - raise_flag_mock, lower_flag_mock, lower_all_flags_mock = self.execute_plugin_patched(task, arguments) + ( + raise_flag_mock, + lower_flag_mock, + lower_all_flags_mock, + update_hierarchy_for_flag_mock, + ) = self.execute_plugin_patched(task, arguments) raise_flag_mock.assert_not_called() + update_hierarchy_for_flag_mock.assert_not_called() lower_flag_mock.assert_called_once_with(name=flag_name, on_objects=targets) lower_all_flags_mock.assert_not_called() def check_lower_all_called( self, task: Task, arguments: str | dict, targets: Collection[CoreObjectDescriptor] ) -> None: - raise_flag_mock, lower_flag_mock, lower_all_flags_mock = self.execute_plugin_patched(task, arguments) + ( + raise_flag_mock, + lower_flag_mock, + lower_all_flags_mock, + update_hierarchy_for_flag_mock, + ) = self.execute_plugin_patched(task, arguments) raise_flag_mock.assert_not_called() + update_hierarchy_for_flag_mock.assert_not_called() lower_flag_mock.assert_not_called() lower_all_flags_mock.assert_called_once_with(on_objects=targets) @@ -240,3 +254,136 @@ def test_incorrect_name_fail(self) -> None: result = executor.execute() self.assertIsNotNone(result.error) self.assertIn("`name` should be at least 1 symbol", result.error.message) + + def test_hierarchy_is_updated_on_raise(self) -> None: + flag_name = "custom" + + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + executor = self.prepare_executor( + executor_type=ADCMChangeFlagPluginExecutor, + call_arguments={ + "operation": "up", + "name": flag_name, + "objects": [ + {"type": "component", "service_name": self.service_1.name, "component_name": self.component_2.name}, + ], + }, + call_context=job, + ) + + result = executor.execute() + self.assertIsNone(result.error) + + self.assertEqual(self.cluster.concerns.count(), 1) + self.assertTrue( + self.cluster.concerns.filter( + owner_id=self.component_2.id, owner_type=self.component_2.content_type + ).exists() + ) + + self.assertEqual(self.service_1.concerns.count(), 1) + self.assertTrue( + self.service_1.concerns.filter( + owner_id=self.component_2.id, owner_type=self.component_2.content_type + ).exists() + ) + + self.assertEqual(self.service_2.concerns.count(), 0) + + def test_changed_on_raise(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + executor = self.prepare_executor( + executor_type=ADCMChangeFlagPluginExecutor, + call_arguments={ + "operation": "up", + "name": "adcm_outdated_config", + "objects": [ + {"type": "component", "service_name": self.service_1.name, "component_name": self.component_2.name}, + ], + }, + call_context=job, + ) + + result = executor.execute() + self.assertIsNone(result.error) + self.assertTrue(result.changed) + + result = executor.execute() + self.assertIsNone(result.error) + self.assertFalse(result.changed) + + result = executor.execute() + self.assertIsNone(result.error) + self.assertFalse(result.changed) + + lower_all_flags(on_objects=[CoreObjectDescriptor(id=self.component_2.id, type=ADCMCoreType.COMPONENT)]) + + result = executor.execute() + self.assertIsNone(result.error) + self.assertTrue(result.changed) + + def test_changed_on_lower(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + executor = self.prepare_executor( + executor_type=ADCMChangeFlagPluginExecutor, + call_arguments={ + "operation": "down", + "name": "adcm_outdated_config", + "objects": [{"type": "cluster"}, {"type": "service", "service_name": self.service_1.name}], + }, + call_context=job, + ) + + result = executor.execute() + self.assertIsNone(result.error) + self.assertFalse(result.changed) + + raise_flag( + flag=BuiltInFlag.ADCM_OUTDATED_CONFIG.value, + on_objects=( + CoreObjectDescriptor(id=self.cluster.id, type=ADCMCoreType.CLUSTER), + CoreObjectDescriptor(id=self.service_2.id, type=ADCMCoreType.SERVICE), + ), + ) + + result = executor.execute() + self.assertIsNone(result.error) + self.assertTrue(result.changed) + + result = executor.execute() + self.assertIsNone(result.error) + self.assertFalse(result.changed) + + def test_changed_on_lower_all(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + executor = self.prepare_executor( + executor_type=ADCMChangeFlagPluginExecutor, + call_arguments={ + "operation": "down", + "objects": [ + {"type": "cluster"}, + ], + }, + call_context=job, + ) + + result = executor.execute() + self.assertIsNone(result.error) + self.assertFalse(result.changed) + + raise_flag( + flag=BuiltInFlag.ADCM_OUTDATED_CONFIG.value, + on_objects=(CoreObjectDescriptor(id=self.cluster.id, type=ADCMCoreType.CLUSTER),), + ) + + result = executor.execute() + self.assertIsNone(result.error) + self.assertTrue(result.changed) + + result = executor.execute() + self.assertIsNone(result.error) + self.assertFalse(result.changed) diff --git a/python/api_v2/tests/test_service.py b/python/api_v2/tests/test_service.py index 9ab0c4db3e..2078299521 100644 --- a/python/api_v2/tests/test_service.py +++ b/python/api_v2/tests/test_service.py @@ -321,10 +321,11 @@ def test_delete_service_abort_own_actions_success(self) -> None: ) ) self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) - self.assertEqual( - "adcm_delete_service", - self.service_to_delete.concerns.get(type=ConcernType.LOCK).reason["placeholder"]["job"]["name"], - ) + + service_concerns_qs = self.service_to_delete.concerns.filter(type=ConcernType.LOCK) + # one for old job, one for delete job + self.assertEqual(service_concerns_qs.count(), 2) + self.assertTrue(service_concerns_qs.filter(name="adcm_delete_service").exists()) @staticmethod def imitate_task_running(action: Action, object_: Cluster | ClusterObject) -> TaskLog: diff --git a/python/cm/api.py b/python/cm/api.py index 1e54b17f01..bd860bae9e 100644 --- a/python/cm/api.py +++ b/python/cm/api.py @@ -44,6 +44,7 @@ ) from cm.logger import logger from cm.models import ( + ADCM, ADCMEntity, Cluster, ClusterBind, @@ -55,6 +56,7 @@ Host, HostComponent, HostProvider, + MainObject, MaintenanceMode, ObjectConfig, Prototype, @@ -436,7 +438,7 @@ def update_obj_config(obj_conf: ObjectConfig, config: dict, attr: dict, descript message = f"Both `config` and `attr` should be of `dict` type, not {type(config)} and {type(attr)} respectively" raise TypeError(message) - obj: ADCMEntity = obj_conf.object + obj: MainObject | ADCM | GroupConfig = obj_conf.object if obj is None: message = "Can't update configuration that have no linked object" raise ValueError(message) @@ -444,7 +446,7 @@ def update_obj_config(obj_conf: ObjectConfig, config: dict, attr: dict, descript group = None if isinstance(obj, GroupConfig): group = obj - obj = group.object + obj: MainObject = group.object proto = obj.prototype else: proto = obj.prototype @@ -461,19 +463,9 @@ def update_obj_config(obj_conf: ObjectConfig, config: dict, attr: dict, descript with atomic(): config_log = save_object_config(object_config=obj_conf, config=new_conf, attr=attr, description=description) update_hierarchy_issues(obj=obj) - - if obj.prototype.flag_autogeneration.get("enable_outdated_config", False): - # todo implement it in a better way - flag = BuiltInFlag.ADCM_OUTDATED_CONFIG.value - flag_exists = obj.concerns.filter(name=flag.name, type=ConcernType.FLAG).exists() - raise_flag(flag=flag, on_objects=[CoreObjectDescriptor(id=obj.id, type=orm_object_to_core_type(obj))]) - if not flag_exists: - update_hierarchy( - concern=ConcernItem.objects.get( - name=flag.name, type=ConcernType.FLAG, owner_id=obj.id, owner_type=obj.content_type - ) - ) - + # flag on ADCM can't be raised (only objects of `ADCMCoreType` are supported) + if not isinstance(obj, ADCM): + raise_outdated_config_flag_if_required(object_=obj) apply_policy_for_new_config(config_object=obj, config_log=config_log) send_config_creation_event(object_=obj) @@ -481,6 +473,22 @@ def update_obj_config(obj_conf: ObjectConfig, config: dict, attr: dict, descript return config_log +def raise_outdated_config_flag_if_required(object_: MainObject): + if not object_.prototype.flag_autogeneration.get("enable_outdated_config", False): + return + + flag = BuiltInFlag.ADCM_OUTDATED_CONFIG.value + flag_exists = object_.concerns.filter(name=flag.name, type=ConcernType.FLAG).exists() + # raise unconditionally here, because message should be from "default" flag + raise_flag(flag=flag, on_objects=[CoreObjectDescriptor(id=object_.id, type=orm_object_to_core_type(object_))]) + if not flag_exists: + update_hierarchy( + concern=ConcernItem.objects.get( + name=flag.name, type=ConcernType.FLAG, owner_id=object_.id, owner_type=object_.content_type + ) + ) + + def set_object_config_with_plugin(obj: ADCMEntity, config: dict, attr: dict) -> ConfigLog: new_conf = process_json_config(prototype=obj.prototype, obj=obj, new_config=config, new_attr=attr) diff --git a/python/cm/issue.py b/python/cm/issue.py index 234c8329cc..d767d19c48 100755 --- a/python/cm/issue.py +++ b/python/cm/issue.py @@ -13,6 +13,7 @@ from typing import Iterable from api_v2.concern.serializers import ConcernSerializer +from django.conf import settings from django.db.transaction import on_commit from djangorestframework_camel_case.util import camelize @@ -439,10 +440,6 @@ def add_issue_on_linked_objects(obj: ADCMEntity, issue_cause: ConcernCause) -> N """Create newly discovered issue and add it to linked objects concerns""" issue = obj.get_own_issue(cause=issue_cause) or create_issue(obj=obj, issue_cause=issue_cause) - # todo here was a code that was re-creating issue if it has different name - # but can't see why it may be needed, just re-check it. - # Since we've got the `issue` by cause, I see no way it'll have different cause. - tree = Tree(obj) affected_nodes = tree.get_directly_affected(node=tree.built_from) @@ -525,37 +522,24 @@ def lock_affected_objects(task: TaskLog, objects: Iterable[ADCMEntity]) -> None: if task.lock: return - # fixme It is possible that lock exists, but is not bound to task. - # Not it's done for case like `test_delete_service_abort_own_actions_success` - # thou it's not proven that it can happen in real world (the opposite wasn't proven either). - # Most likely relation to task should be improved somehow (not just "let's check if it's None"), - # but can't think of good and universal solution at the moment. - # Problem with this fix is that such concern "may" be deleted during task cancellation, - # so it'll be removed from this task too. owner: ADCMEntity = task.task_object first_job = JobLog.obj.filter(task=task).order_by("id").first() - existing_lock = ConcernItem.objects.filter( - owner_id=owner.pk, owner_type=owner.content_type, type=ConcernType.LOCK - ).first() - if existing_lock: - # it may be `lock` from another job - # (case: delete service when another action on service is running) - task.lock = update_job_in_lock_reason(lock=existing_lock, job=first_job) - else: - task.lock = create_lock(owner=owner, job=first_job) + delete_service_action = settings.ADCM_DELETE_SERVICE_ACTION_NAME + custom_name = delete_service_action if task.action.name == delete_service_action else "" + task.lock = create_lock(owner=owner, job=first_job, custom_name=custom_name) task.save(update_fields=["lock"]) for obj in objects: add_concern_to_object(object_=obj, concern=task.lock) -def create_lock(owner: ADCMEntity, job: JobLog): +def create_lock(owner: ADCMEntity, job: JobLog, custom_name: str = ""): type_: str = ConcernType.LOCK.value cause: str = ConcernCause.JOB.value return ConcernItem.objects.create( type=type_, - name=f"{cause or ''}_{type_}".strip("_"), + name=custom_name or f"{cause or ''}_{type_}".strip("_"), reason=build_concern_reason( ConcernMessage.LOCKED_BY_JOB.template, placeholder_objects=PlaceholderObjectsDTO(job=job, target=owner) ), diff --git a/python/cm/migrations/0120_change_concern_item.py b/python/cm/migrations/0120_change_concern_item.py index 70362b9595..91b2c6f850 100644 --- a/python/cm/migrations/0120_change_concern_item.py +++ b/python/cm/migrations/0120_change_concern_item.py @@ -20,7 +20,6 @@ def remove_unlinked_concerns(apps, schema_editor): ConcernItem = apps.get_model("cm", "ConcernItem") - # todo add test on it ConcernItem.objects.filter(Q(owner_type__isnull=True) | Q(owner_id__isnull=True)).delete() diff --git a/python/cm/migrations/0121_flag_autogeneration_object.py b/python/cm/migrations/0121_flag_autogeneration_object.py index 44e991e6ad..72634e0c88 100644 --- a/python/cm/migrations/0121_flag_autogeneration_object.py +++ b/python/cm/migrations/0121_flag_autogeneration_object.py @@ -15,6 +15,12 @@ from django.db import migrations, models +def fill_default_flag_autogeneration_value(apps, schema_editor): + Prototype = apps.get_model("cm", "Prototype") + + Prototype.objects.all().update(flag_autogeneration={"adcm_outdated_config": False}) + + class Migration(migrations.Migration): dependencies = [ @@ -40,4 +46,5 @@ class Migration(migrations.Migration): name='flag_autogeneration', field=models.JSONField(default=dict), ), + migrations.RunPython(code=fill_default_flag_autogeneration_value, reverse_code=migrations.RunPython.noop), ] diff --git a/python/cm/services/concern/flags.py b/python/cm/services/concern/flags.py index 1024f3972d..e41246443f 100644 --- a/python/cm/services/concern/flags.py +++ b/python/cm/services/concern/flags.py @@ -28,6 +28,7 @@ from cm.models import ADCMEntity, ConcernCause, ConcernItem, ConcernType from cm.services.concern.messages import ( ADCM_ENTITY_AS_PLACEHOLDERS, + ConcernMessage, ConcernMessageTemplate, PlaceholderObjectsDTO, build_concern_reason, @@ -47,9 +48,11 @@ class BuiltInFlag(Enum): ) -def raise_flag(flag: ConcernFlag, on_objects: Collection[CoreObjectDescriptor]) -> None: +def raise_flag(flag: ConcernFlag, on_objects: Collection[CoreObjectDescriptor]) -> bool: + """Returns whether any objects were affected or not""" message_template = ConcernMessageTemplate( - message=f"${{source}} has a flag: {flag.message}".rstrip(), placeholders=ADCM_ENTITY_AS_PLACEHOLDERS + message=f"{ConcernMessage.FLAG.value.message}{flag.message}".rstrip(": "), + placeholders=ADCM_ENTITY_AS_PLACEHOLDERS, ) content_type_id_map = _get_owner_ids_grouped_by_content_type(objects=on_objects) @@ -59,12 +62,16 @@ def raise_flag(flag: ConcernFlag, on_objects: Collection[CoreObjectDescriptor]) ) processed_objects: dict[ContentType, set[int]] = {content_type: set() for content_type in content_type_id_map} + concerns_to_update = [] for concern in existing_concerns: - concern.reason["message"] = message_template.message + if concern.reason["message"] != message_template.message: + concern.reason["message"] = message_template.message + concerns_to_update.append(concern) + processed_objects[concern.owner_type].add(concern.owner_id) - if processed_objects: - ConcernItem.objects.bulk_update(objs=existing_concerns, fields=["reason"]) + if concerns_to_update: + ConcernItem.objects.bulk_update(objs=concerns_to_update, fields=["reason"]) objects_without_flags: tuple[ADCMEntity, ...] = tuple( chain.from_iterable( @@ -74,7 +81,7 @@ def raise_flag(flag: ConcernFlag, on_objects: Collection[CoreObjectDescriptor]) ) if not objects_without_flags: - return + return bool(concerns_to_update) ConcernItem.objects.bulk_create( objs=( @@ -92,20 +99,34 @@ def raise_flag(flag: ConcernFlag, on_objects: Collection[CoreObjectDescriptor]) ) ) + return bool(concerns_to_update or objects_without_flags) + -def lower_flag(name: str, on_objects: Collection[CoreObjectDescriptor]) -> None: - ConcernItem.objects.filter( +def lower_flag(name: str, on_objects: Collection[CoreObjectDescriptor]) -> bool: + deleted_count, _ = ConcernItem.objects.filter( Q(name=name) & _get_filter_for_flags_of_objects( content_type_id_map=_get_owner_ids_grouped_by_content_type(objects=on_objects) ) ).delete() + return bool(deleted_count) -def lower_all_flags(on_objects: Collection[CoreObjectDescriptor]) -> None: - ConcernItem.objects.filter( +def lower_all_flags(on_objects: Collection[CoreObjectDescriptor]) -> bool: + deleted_count, _ = ConcernItem.objects.filter( _get_filter_for_flags_of_objects(content_type_id_map=_get_owner_ids_grouped_by_content_type(objects=on_objects)) ).delete() + return bool(deleted_count) + + +def update_hierarchy_for_flag(flag: ConcernFlag, on_objects: Collection[CoreObjectDescriptor]) -> None: + for concern in ConcernItem.objects.filter( + Q(name=flag.name, cause=flag.cause, type=ConcernType.FLAG) + & _get_filter_for_flags_of_objects( + content_type_id_map=_get_owner_ids_grouped_by_content_type(objects=on_objects) + ) + ): + update_hierarchy(concern) def update_hierarchy(concern: ConcernItem) -> None: @@ -124,12 +145,6 @@ def update_hierarchy(concern: ConcernItem) -> None: add_concern_to_object(object_=new_object, concern=concern) -# todo check if it's really should be a separate function in the place where it's called -def update_flags() -> None: - for flag in ConcernItem.objects.filter(type=ConcernType.FLAG): - update_hierarchy(concern=flag) - - def _get_filter_for_flags_of_objects(content_type_id_map: dict[ContentType, set[int]]) -> Q: return Q(type=ConcernType.FLAG) & reduce( or_, diff --git a/python/cm/services/concern/messages.py b/python/cm/services/concern/messages.py index ee152322d1..9ea4e4c1ea 100644 --- a/python/cm/services/concern/messages.py +++ b/python/cm/services/concern/messages.py @@ -14,7 +14,7 @@ from enum import Enum from typing import Callable, Generic, NamedTuple, TypeVar -from cm.models import ADCMEntity, JobLog, Prototype +from cm.models import ADCM, ADCMEntity, Cluster, ClusterObject, Host, HostProvider, JobLog, Prototype, ServiceComponent _PlaceholderObjectT = TypeVar("_PlaceholderObjectT", bound=Callable) @@ -56,10 +56,12 @@ class ConcernMessageTemplate: placeholders: Placeholders -def _retrieve_placeholder_from_adcm_entity(entity: ADCMEntity) -> dict: +def _retrieve_placeholder_from_adcm_entity( + entity: Cluster | ClusterObject | ServiceComponent | HostProvider | Host | ADCM, +) -> dict: return { "type": entity.prototype.type, - "name": entity.display_name, # fixme only entities with display name can be here, not any ADCMEntity + "name": entity.display_name, "params": entity.get_id_chain(), } @@ -80,8 +82,8 @@ def _retrieve_placeholder_from_job(entity: JobLog) -> dict: return { "type": "job", "name": entity.display_name or entity.name, - # todo should it be job id or task id? - "params": {"job_id": entity.task.id}, + # thou it's named `job_id` it is task_id, because UI uses it in that way for routing + "params": {"job_id": entity.task_id}, } @@ -117,7 +119,8 @@ class ConcernMessage(Enum): retrieve_target=_retrieve_placeholder_from_adcm_entity, ), ) - # todo update message and naming here + # Note that flag's message in template is just "left part" + # and should be combined with actual flag's message FLAG = ConcernMessageTemplate(message="${source} has a flag: ", placeholders=ADCM_ENTITY_AS_PLACEHOLDERS) def __init__(self, template: ConcernMessageTemplate): @@ -133,7 +136,8 @@ def build_concern_reason(template: ConcernMessageTemplate, placeholder_objects: entity = getattr(placeholder_objects, placeholder_name) if entity is None: - # todo if there will be cases when those can be null, set placeholder to `{}` instead of error + # if there will be cases when those can be null, set placeholder to `{}` instead of error + # check out commit history for more info message = f"Concern message '{template.message}' requires `{placeholder_name}` to fill placeholders" raise RuntimeError(message) diff --git a/python/cm/services/maintenance_mode.py b/python/cm/services/maintenance_mode.py index df60aa725b..0ae38ee20e 100644 --- a/python/cm/services/maintenance_mode.py +++ b/python/cm/services/maintenance_mode.py @@ -16,8 +16,18 @@ from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_409_CONFLICT from cm.issue import update_hierarchy_issues, update_issue_after_deleting -from cm.models import Action, ClusterObject, Host, HostComponent, MaintenanceMode, Prototype, ServiceComponent -from cm.services.concern.flags import update_flags +from cm.models import ( + Action, + ClusterObject, + ConcernItem, + ConcernType, + Host, + HostComponent, + MaintenanceMode, + Prototype, + ServiceComponent, +) +from cm.services.concern.flags import update_hierarchy from cm.services.job.action import ActionRunPayload, run_action from cm.services.status.notify import reset_objects_in_mm from cm.status_api import send_object_update_event @@ -51,10 +61,15 @@ def _update_mm_hierarchy_issues(obj: Host | ClusterObject | ServiceComponent) -> update_hierarchy_issues(obj.cluster) update_issue_after_deleting() - update_flags() + _update_flags() reset_objects_in_mm() +def _update_flags() -> None: + for flag in ConcernItem.objects.filter(type=ConcernType.FLAG): + update_hierarchy(concern=flag) + + def get_maintenance_mode_response( obj: Host | ClusterObject | ServiceComponent, serializer: Serializer, diff --git a/python/cm/tests/test_migrations/test_120_121.py b/python/cm/tests/test_migrations/test_120_121.py new file mode 100644 index 0000000000..693fca4c8c --- /dev/null +++ b/python/cm/tests/test_migrations/test_120_121.py @@ -0,0 +1,186 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from core.job.types import ScriptType +from django_test_migrations.contrib.unittest_case import MigratorTestCase + +from cm.models import ConcernType + + +class TestDirectMigration(MigratorTestCase): + migrate_from = ("cm", "0117_post_autonomous_joblogs") + migrate_to = ("cm", "0118_extract_sub_actions_from_actions") + + def prepare(self): + Prototype = self.old_state.apps.get_model("cm", "Prototype") + Bundle = self.old_state.apps.get_model("cm", "Bundle") + bundle = Bundle.objects.create(name="cool", version="342", hash="lfj21opfijoi") + prototype = Prototype.objects.create(bundle=bundle, type="cluster", name="protoname", version="200.400") + + Action = self.old_state.apps.get_model("cm", "Action") + SubAction = self.old_state.apps.get_model("cm", "SubAction") + + self.action_1_data = { + "name": "simple_job", + "display_name": "Awesome And Simple", + "script": "path/to/script.yaml", + "script_type": ScriptType.ANSIBLE.value, + "state_on_fail": "failme", + "multi_state_on_fail_set": [], + "multi_state_on_fail_unset": ["cool"], + "params": {"ansible_tags": "some,thing,better", "jinja2_native": "yes", "custom": {"arbitrary": "stuff"}}, + } + self.action_1 = Action.objects.create( + prototype=prototype, + type="job", + state_on_success="best", + multi_state_on_success_set=["coolest"], + multi_state_on_success_unset=[], + allow_to_terminate=True, + **self.action_1_data, + ) + + self.action_2 = Action.objects.create( + prototype=prototype, + type="task", + state_on_success="best", + multi_state_on_success_set=["coolest"], + multi_state_on_success_unset=[], + **self.action_1_data | {"name": "simple_task"}, + ) + self.action_2_sub_1 = SubAction.objects.create( + action_id=self.action_2.pk, + name="step_1", + script="boom.lala", + script_type=ScriptType.ANSIBLE.value, + multi_state_on_fail_set=["heh"], + params={"another": "stuff"}, + ) + self.action_2_sub_2 = SubAction.objects.create( + action_id=self.action_2.pk, + name="step_2", + script="bundle_switch", + script_type=ScriptType.INTERNAL.value, + multi_state_on_fail_unset=["hoho"], + ) + + self.action_3_data = {"script": "./relative.py", "script_type": ScriptType.PYTHON.value, "params": {}} + self.action_3 = Action.objects.create( + prototype=prototype, + name="another_job", + type="job", + state_on_success="nothing", + multi_state_on_success_set=[], + multi_state_on_success_unset=["abs"], + allow_to_terminate=False, + **self.action_3_data, + ) + + self.sub_action_pre_migration_amount = SubAction.objects.count() + + def test_migration_0117_0118_move_data(self): + Action = self.new_state.apps.get_model("cm", "Action") + SubAction = self.new_state.apps.get_model("cm", "SubAction") + + self.assertEqual(Action.objects.count(), 3) + # 1 for each "job" typed action + self.assertEqual(SubAction.objects.count(), self.sub_action_pre_migration_amount + 2) + + new_action_1_sub = SubAction.objects.get(action_id=self.action_1.pk) + + for key, value in self.action_1_data.items(): + self.assertEqual(getattr(new_action_1_sub, key), value) + self.assertTrue(new_action_1_sub.allow_to_terminate) + + new_action_3_sub = SubAction.objects.get(action_id=self.action_3.pk) + + self.assertEqual(new_action_3_sub.name, self.action_3.name) + self.assertEqual(new_action_3_sub.display_name, self.action_3.display_name) + self.assertEqual(new_action_3_sub.script, self.action_3_data["script"]) + self.assertEqual(new_action_3_sub.script_type, self.action_3_data["script_type"]) + self.assertEqual(new_action_3_sub.params, {}) + self.assertEqual(new_action_3_sub.state_on_fail, "") + self.assertEqual(new_action_3_sub.multi_state_on_fail_set, []) + self.assertEqual(new_action_3_sub.multi_state_on_fail_unset, []) + self.assertFalse(new_action_3_sub.allow_to_terminate) + + +class TestReverseMigration(MigratorTestCase): + migrate_from = ("cm", "0119_delete_MessageTemplate") + migrate_to = ("cm", "0121_flag_autogeneration_object") + + def prepare(self): + Prototype = self.old_state.apps.get_model("cm", "Prototype") + Bundle = self.old_state.apps.get_model("cm", "Bundle") + for i in range(5): + Prototype.objects.create( + bundle=Bundle.objects.create(name=f"cool-{i}", version="342", hash="lfj21opfijoi"), + type="cluster", + name=f"protoname-{i}", + version="200.400", + ) + + ConcernItem = self.old_state.apps.get_model("cm", "ConcernItem") + ContentType = self.old_state.apps.get_model("contenttypes", "ContentType") + content_type = ContentType.objects.create(app_label="cm", model="cluster") + + self.will_stay = [ + ConcernItem.objects.create( + type=ConcernType.ISSUE, + name="issue_with_owner", + owner_id=4, + owner_type=content_type, + ), + ConcernItem.objects.create( + type=ConcernType.FLAG, + name="FLAG_with_owner", + owner_id=2, + owner_type=content_type, + ), + ConcernItem.objects.create( + type=ConcernType.LOCK, + name="lock_with_owner", + owner_id=1, + owner_type=content_type, + ), + ] + self.will_be_gone = [ + ConcernItem.objects.create( + type=ConcernType.ISSUE, + name="issue_wo_owner", + owner_id=4, + owner_type=None, + ), + ConcernItem.objects.create( + type=ConcernType.FLAG, + name="FLAG_wo_owner", + owner_id=None, + owner_type=None, + ), + ConcernItem.objects.create( + type=ConcernType.LOCK, + name="lock_wo_owner", + owner_id=None, + owner_type=content_type, + ), + ] + + def test_migration_0120_and_0121_move_data(self): + Prototype = self.new_state.apps.get_model("cm", "Prototype") + ConcernItem = self.new_state.apps.get_model("cm", "ConcernItem") + + self.assertEqual(Prototype.objects.count(), 5) + self.assertEqual(Prototype.objects.filter(flag_autogeneration={"adcm_outdated_config": False}).count(), 5) + + self.assertEqual(ConcernItem.objects.count(), len(self.will_stay)) + self.assertSetEqual( + set(ConcernItem.objects.values_list("id", flat=True)), {concern.id for concern in self.will_stay} + ) From 4a3e94674b7a62625f579fa62885b0da0fa63764 Mon Sep 17 00:00:00 2001 From: Kirill Fedorenko Date: Tue, 9 Apr 2024 06:36:29 +0000 Subject: [PATCH 038/208] ADCM-5422 [UI] Change notification for Import tab https://tracker.yandex.ru/ADCM-5422 --- .../ClusterImportServices/ClusterImportsService.tsx | 8 +++++++- .../ClusterImportServices/useClusterImportsService.ts | 1 + .../ClusterImportToolbar/ClusterImportToolbar.tsx | 11 +++++++++-- .../ClusterImportsCluster/ClusterImportsCluster.tsx | 8 +++++++- .../ClusterImportsCluster/useClusterImports.ts | 1 + .../cluster/imports/cluster/clusterImportsSlice.ts | 2 +- .../imports/service/clusterImportsServiceSlice.ts | 2 +- 7 files changed, 27 insertions(+), 6 deletions(-) diff --git a/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImportServices/ClusterImportsService.tsx b/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImportServices/ClusterImportsService.tsx index 88ccf86823..dc1008c65a 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImportServices/ClusterImportsService.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImportServices/ClusterImportsService.tsx @@ -25,6 +25,7 @@ const ClusterImportsService = () => { serviceId, handleServiceChange, totalCount, + initialImports, } = useClusterImportsService(); const dispatch = useDispatch(); @@ -46,7 +47,12 @@ const ClusterImportsService = () => { return ( <> - + 0 || initialImports.clusters.size > 0} + > - {node.data.fieldSchema.adcmMeta.isSecret ? ( + {adcmMeta.isSecret ? ( ) : ( )} diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/EditConfigurationFieldDialog/EditConfigurationFieldDialog.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/EditConfigurationFieldDialog/EditConfigurationFieldDialog.tsx index 53023ef6a3..f9972afaa2 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/EditConfigurationFieldDialog/EditConfigurationFieldDialog.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/EditConfigurationFieldDialog/EditConfigurationFieldDialog.tsx @@ -4,11 +4,11 @@ import { Node } from '@uikit/CollapseTree2/CollapseNode.types'; import { JSONPrimitive } from '@models/json'; import { ConfigurationField, ConfigurationNodeView } from '../../ConfigurationEditor.types'; import EnumControl from '../FieldControls/EnumControl'; -import StringControl from '../FieldControls/StringControl'; -import MultilineStringControl from '../FieldControls/MultilineStringControl'; +import StringControl from '../FieldControls/StringControls/StringControl'; +import MultilineStringControl from '../FieldControls/StringControls/MultilineStringControl'; import BooleanControl from '../FieldControls/BooleanControl'; import NumberControl from '../FieldControls/NumberControl'; -import SecretControl from '../FieldControls/SecretControl'; +import SecretControl from '../FieldControls/StringControls/SecretControl'; export interface ConfigurationEditInputFieldDialogProps { node: ConfigurationNodeView; diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/MultilineStringControl.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/MultilineStringControl.tsx similarity index 78% rename from adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/MultilineStringControl.tsx rename to adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/MultilineStringControl.tsx index 9f4985c469..9e094da13b 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/MultilineStringControl.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/MultilineStringControl.tsx @@ -1,10 +1,10 @@ import { useMemo, useState } from 'react'; import CodeEditor from '@uikit/CodeEditor/CodeEditor'; -import ConfigurationField from './ConfigurationField'; +import ConfigurationField from '../ConfigurationField'; import { SingleSchemaDefinition } from '@models/adcm'; import { JSONPrimitive } from '@models/json'; import { prettifyJson } from '@utils/stringUtils'; -import s from './ConfigurationField.module.scss'; +import { validate } from './StringControls.utils'; const textTransformers: { [format: string]: (value: string) => string } = { json: prettifyJson, @@ -28,6 +28,7 @@ const MultilineStringControl = ({ const stringValue = value?.toString() ?? ''; const format = fieldSchema.format ?? 'text'; const [isFormatted, setIsFormatted] = useState(false); + const [error, setError] = useState(undefined); const code = useMemo(() => { if (!isFormatted) { @@ -43,13 +44,20 @@ const MultilineStringControl = ({ }, [stringValue]); const handleChange = (code: string) => { + const error = validate(code, fieldSchema); + setError(error); onChange(code); }; return ( - + (undefined); + const [confirmError, setConfirmError] = useState(undefined); const handleSecretChange = (event: React.ChangeEvent) => { + const error = validate(event.target.value, fieldSchema); + setError(error); setSecret(event.target.value); }; @@ -30,7 +34,7 @@ const SecretControl = ({ fieldName, fieldSchema, value, isReadonly, onChange }: useEffect(() => { const areEqual = secret === confirm; onChange(secret, areEqual); - setError(!areEqual ? mismatchErrorText : undefined); + setConfirmError(!areEqual ? mismatchErrorText : undefined); }, [confirm, onChange, secret]); const handleResetToDefault = (defaultValue: JSONPrimitive) => { @@ -44,6 +48,7 @@ const SecretControl = ({ fieldName, fieldSchema, value, isReadonly, onChange }: @@ -53,7 +58,7 @@ const SecretControl = ({ fieldName, fieldSchema, value, isReadonly, onChange }: diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControl.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/StringControl.tsx similarity index 64% rename from adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControl.tsx rename to adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/StringControl.tsx index fbeb2218a5..ca30b711dc 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControl.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/StringControl.tsx @@ -1,26 +1,39 @@ +import { useState } from 'react'; import Input from '@uikit/Input/Input'; import InputWithAutocomplete from '@uikit/InputWithAutocomplete/InputWithAutocomplete'; -import ConfigurationField from './ConfigurationField'; +import ConfigurationField from '../ConfigurationField'; import { SingleSchemaDefinition } from '@models/adcm'; import { JSONPrimitive } from '@models/json'; +import { validate } from './StringControls.utils'; export interface StringControlProps { fieldName: string; value: JSONPrimitive; fieldSchema: SingleSchemaDefinition; isReadonly: boolean; - onChange: (value: JSONPrimitive) => void; + onChange: (value: JSONPrimitive, isValid?: boolean) => void; } const StringControl = ({ fieldName, value, fieldSchema, isReadonly, onChange }: StringControlProps) => { + const [error, setError] = useState(undefined); + const handleChange = (event: React.ChangeEvent) => { - onChange(event.target.value); + const error = validate(event.target.value, fieldSchema); + + setError(error); + onChange(event.target.value, error === undefined); }; const stringValue = value as string; return ( - + {fieldSchema.adcmMeta.stringExtra?.suggestions ? ( { + let error = undefined; + if (fieldSchema.pattern) { + const re = new RegExp(fieldSchema.pattern); + if (!re.test(value)) { + error = getPatternErrorMessage(fieldSchema.pattern); + } + } + + return error; +}; diff --git a/adcm-web/app/src/utils/jsonSchemaUtils.ts b/adcm-web/app/src/utils/jsonSchemaUtils.ts index 36acf1143a..c5b2a3f969 100644 --- a/adcm-web/app/src/utils/jsonSchemaUtils.ts +++ b/adcm-web/app/src/utils/jsonSchemaUtils.ts @@ -46,6 +46,10 @@ const getAllErrorInstancePaths = (errors: ErrorObject[] | undefined | null) => { let instancePath = error.instancePath; let errorMessage = error.message; + if (result[instancePath]) { + continue; + } + // extend error from structure to field if (error.keyword === 'required') { instancePath += `/${error.params.missingProperty}`; @@ -60,6 +64,7 @@ const getAllErrorInstancePaths = (errors: ErrorObject[] | undefined | null) => { result[path] = true; } } + result[instancePath] = errorMessage ?? ''; } @@ -87,3 +92,5 @@ export const generateFromSchema = (schema: Schema): T | null => { }; export { Schema }; + +export const getPatternErrorMessage = (pattern: string) => `The value must match pattern: ${pattern}`; From 3b2370993872ad0d6a2fcd94f3f74c34c4366adc Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Thu, 16 May 2024 09:54:02 +0000 Subject: [PATCH 103/208] ADCM-5402: `/services/components` endpoints --- python/api_v2/component/serializers.py | 1 + python/api_v2/component/views.py | 43 ++++++++++-- python/api_v2/service/views.py | 96 ++++++++++++++++++++++++-- 3 files changed, 132 insertions(+), 8 deletions(-) diff --git a/python/api_v2/component/serializers.py b/python/api_v2/component/serializers.py index 3bda825c78..5fcdfef6f3 100644 --- a/python/api_v2/component/serializers.py +++ b/python/api_v2/component/serializers.py @@ -88,6 +88,7 @@ class Meta: "main_info", ] + @extend_schema_field(field=HostShortSerializer(many=True)) def get_hosts(self, instance: ServiceComponent) -> HostShortSerializer: host_pks = set() for host_component in HostComponent.objects.filter(component=instance).select_related("host"): diff --git a/python/api_v2/component/views.py b/python/api_v2/component/views.py index 2b818042a2..71d655ac2d 100644 --- a/python/api_v2/component/views.py +++ b/python/api_v2/component/views.py @@ -32,9 +32,15 @@ from rest_framework.mixins import ListModelMixin from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.status import HTTP_200_OK +from rest_framework.status import ( + HTTP_200_OK, + HTTP_400_BAD_REQUEST, + HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, + HTTP_409_CONFLICT, +) -from api_v2.api_schema import ErrorSerializer +from api_v2.api_schema import DefaultParams, ErrorSerializer from api_v2.component.filters import ComponentFilter from api_v2.component.serializers import ( ComponentMaintenanceModeSerializer, @@ -55,7 +61,7 @@ operation_id="getHostComponentStatusesOfComponent", summary="GET host-component statuses of component on hoosts", description="Get information about component on hosts statuses.", - responses={200: ComponentStatusSerializer, 404: ErrorSerializer}, + responses={HTTP_200_OK: ComponentStatusSerializer, HTTP_404_NOT_FOUND: ErrorSerializer}, parameters=[ OpenApiParameter( name="status", @@ -87,9 +93,38 @@ ), ], ), + retrieve=extend_schema( + operation_id="getServiceComponent", + description="Get information about a specific service component.", + summary="GET service components", + responses={HTTP_200_OK: ComponentSerializer, HTTP_404_NOT_FOUND: ErrorSerializer}, + ), + list=extend_schema( + operation_id="getServiceComponents", + description="Get a list of all components of a particular service with information on them.", + summary="GET service components", + parameters=[ + DefaultParams.LIMIT, + DefaultParams.OFFSET, + DefaultParams.ordering_by("Name", "Display Name"), + ], + responses={HTTP_200_OK: ComponentSerializer(many=True), HTTP_404_NOT_FOUND: ErrorSerializer}, + ), + maintenance_mode=extend_schema( + operation_id="postComponentMaintenanceMode", + description="Turn on/off maintenance mode on the component.", + summary="POST component maintenance-mode", + responses={ + HTTP_200_OK: ComponentMaintenanceModeSerializer, + **{ + err_code: ErrorSerializer + for err_code in (HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT) + }, + }, + ), ) class ComponentViewSet( - PermissionListMixin, ConfigSchemaMixin, CamelCaseReadOnlyModelViewSet, ObjectWithStatusViewMixin + PermissionListMixin, ConfigSchemaMixin, ObjectWithStatusViewMixin, CamelCaseReadOnlyModelViewSet ): queryset = ServiceComponent.objects.select_related("cluster", "service").order_by("pk") permission_classes = [DjangoModelPermissionsAudit] diff --git a/python/api_v2/service/views.py b/python/api_v2/service/views.py index d300285f39..c548c7756f 100644 --- a/python/api_v2/service/views.py +++ b/python/api_v2/service/views.py @@ -21,7 +21,7 @@ ) from audit.utils import audit from cm.errors import AdcmEx -from cm.models import Cluster, ClusterObject +from cm.models import ADCMEntityStatus, Cluster, ClusterObject from cm.services.maintenance_mode import get_maintenance_mode_response from cm.services.service import delete_service_from_api from cm.services.status.notify import update_mm_objects @@ -37,9 +37,17 @@ ) from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_400_BAD_REQUEST, + HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, + HTTP_409_CONFLICT, +) -from api_v2.api_schema import ErrorSerializer +from api_v2.api_schema import DefaultParams, ErrorSerializer from api_v2.config.utils import ConfigSchemaMixin from api_v2.service.filters import ServiceFilter from api_v2.service.permissions import ServicePermissions @@ -57,11 +65,91 @@ @extend_schema_view( + retrieve=extend_schema( + operation_id="getClusterService", + summary="GET cluster service", + description="Get information about a specific cluster service.", + responses={ + HTTP_200_OK: ServiceRetrieveSerializer, + HTTP_403_FORBIDDEN: ErrorSerializer, + HTTP_404_NOT_FOUND: ErrorSerializer, + }, + ), + list=extend_schema( + operation_id="getClusterServices", + summary="GET cluster services", + description="Get a list of all services of a particular cluster with information on them.", + parameters=[ + DefaultParams.LIMIT, + DefaultParams.OFFSET, + DefaultParams.ordering_by("Display name"), + OpenApiParameter( + name="name", + location=OpenApiParameter.QUERY, + description="Case insensitive and partial filter by service name.", + type=str, + ), + OpenApiParameter( + name="display_name", + location=OpenApiParameter.QUERY, + description="Case insensitive and partial filter by service displayName.", + type=str, + ), + OpenApiParameter( + name="status", + location=OpenApiParameter.QUERY, + description="Filter by service status.", + enum=ADCMEntityStatus.values, + type=str, + ), + ], + responses={HTTP_200_OK: ServiceRetrieveSerializer(many=True), HTTP_404_NOT_FOUND: ErrorSerializer}, + ), + create=extend_schema( + operation_id="postClusterServices", + summary="POST cluster services", + description="Add a new cluster services.", + responses={ + HTTP_201_CREATED: ServiceRetrieveSerializer, + HTTP_400_BAD_REQUEST: ErrorSerializer, + HTTP_404_NOT_FOUND: ErrorSerializer, + HTTP_403_FORBIDDEN: ErrorSerializer, + HTTP_409_CONFLICT: ErrorSerializer, + }, + ), + destroy=extend_schema( + operation_id="deleteClusterService", + summary="DELETE cluster service", + description="Delete a specific cluster service.", + responses={ + HTTP_204_NO_CONTENT: None, + HTTP_400_BAD_REQUEST: ErrorSerializer, + HTTP_403_FORBIDDEN: ErrorSerializer, + HTTP_404_NOT_FOUND: ErrorSerializer, + HTTP_409_CONFLICT: ErrorSerializer, + }, + ), + maintenance_mode=extend_schema( + operation_id="postServiceMaintenanceMode", + summary="POST service maintenance-mode", + description="Turn on/off maintenance mode on the service.", + responses={ + HTTP_200_OK: ServiceMaintenanceModeSerializer, + HTTP_400_BAD_REQUEST: ErrorSerializer, + HTTP_403_FORBIDDEN: ErrorSerializer, + HTTP_404_NOT_FOUND: ErrorSerializer, + HTTP_409_CONFLICT: ErrorSerializer, + }, + ), statuses=extend_schema( operation_id="getServiceComponentStatuses", summary="GET service component statuses", description="Get information about service component statuses.", - responses={200: ServiceStatusSerializer, 403: ErrorSerializer, 404: ErrorSerializer}, + responses={ + HTTP_200_OK: ServiceStatusSerializer, + HTTP_403_FORBIDDEN: ErrorSerializer, + HTTP_404_NOT_FOUND: ErrorSerializer, + }, parameters=[ OpenApiParameter( name="status", From a4c5ecd7bfb9c4fbbe45c627bfeac8b409ea74c6 Mon Sep 17 00:00:00 2001 From: Alexey Latunov Date: Fri, 17 May 2024 13:56:06 +0000 Subject: [PATCH 104/208] [UI] ADCM-5560: Show tabs on imports page https://tracker.yandex.ru/ADCM-5560 --- .../pages/cluster/ClusterImport/ClusterImport.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImport.tsx b/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImport.tsx index cdf911f293..5cd4c78fb4 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImport.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImport.tsx @@ -4,13 +4,11 @@ import s from './ClusterImport.module.scss'; import { Tab, TabsBlock } from '@uikit'; import { useDispatch, useStore } from '@hooks'; import { setBreadcrumbs } from '@store/adcm/breadcrumbs/breadcrumbsSlice'; -import { RequestState } from '@models/loadState'; const ClusterImport = () => { const dispatch = useDispatch(); const cluster = useStore(({ adcm }) => adcm.cluster.cluster); - const accessCheckStatus = useStore(({ adcm }) => adcm.clusterImports.accessCheckStatus); useEffect(() => { if (cluster) { @@ -26,12 +24,10 @@ const ClusterImport = () => { return (
- {accessCheckStatus === RequestState.Completed && ( - - Cluster - Services - - )} + + Cluster + Services +
); From a72d0950f0cd5632dd40c2cd678d94c1d98b19f5 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Mon, 20 May 2024 04:32:19 +0000 Subject: [PATCH 105/208] ADCM-5430 Rework `adcm_config` plugin --- python/ansible/plugins/action/adcm_config.py | 198 ++------------ python/ansible/plugins/lookup/adcm_config.py | 139 +++++++++- python/ansible_plugin/base.py | 171 +++++++----- python/ansible_plugin/errors.py | 4 + .../ansible_plugin/executors/_validators.py | 53 ++++ python/ansible_plugin/executors/config.py | 253 ++++++++++++++++++ .../cluster_complex_config/config.yaml | 81 ++++++ .../tests/bundles/provider/config.yaml | 16 ++ .../ansible_plugin/tests/test_adcm_config.py | 220 +++++++++++++++ python/ansible_plugin/utils.py | 166 +----------- .../test_ansible_plugins/test_adcm_config.py | 155 ----------- 11 files changed, 877 insertions(+), 579 deletions(-) create mode 100644 python/ansible_plugin/executors/_validators.py create mode 100644 python/ansible_plugin/executors/config.py create mode 100644 python/ansible_plugin/tests/bundles/cluster_complex_config/config.yaml create mode 100644 python/ansible_plugin/tests/test_adcm_config.py delete mode 100644 python/cm/tests/test_ansible_plugins/test_adcm_config.py diff --git a/python/ansible/plugins/action/adcm_config.py b/python/ansible/plugins/action/adcm_config.py index 3ed6e0ac93..3051f7e8fb 100644 --- a/python/ansible/plugins/action/adcm_config.py +++ b/python/ansible/plugins/action/adcm_config.py @@ -13,22 +13,12 @@ import sys -from ansible.errors import AnsibleError - sys.path.append("/adcm/python") import adcm.init_django # noqa: F401, isort:skip -from ansible_plugin.utils import ( - ContextActionModule, - set_cluster_config, - set_component_config, - set_component_config_by_name, - set_host_config, - set_provider_config, - set_service_config, - set_service_config_by_name, -) +from ansible_plugin.base import ADCMAnsiblePlugin +from ansible_plugin.executors.config import ADCMConfigPluginExecutor ANSIBLE_METADATA = {"metadata_version": "1.1", "supported_by": "Arenadata"} DOCUMENTATION = r""" @@ -37,8 +27,8 @@ short_description: Change values in config in runtime description: - This is special ADCM only module which is useful for setting of specified config key - or set of config keys for various ADCM objects. - - There is support of cluster, service, host and providers config. + or set of config keys for various ADCM objects. + - There is support of cluster, service, component, host and providers config. - This one is allowed to be used in various execution contexts. options: - option-name: type @@ -46,6 +36,7 @@ choices: - cluster - service + - component - host - provider description: type of object which should be changed @@ -67,11 +58,18 @@ - option-name: service_name required: false type: string - description: useful in cluster context only. + description: useful in cluster and component context. In that context you are able to set a config value for a service belongs to the cluster. + - option-name: component_name + required: false + type: string + description: useful in cluster, service and component context. + In that context you are able to set a config value for a component belongs to the cluster. + notes: - - If type is 'service', there is no needs to specify service_name + - If type is "service", there is no need to specify `service_name` if config of context's service should be changed. + Same for "component" and `component_name`. """ EXAMPLES = r""" - adcm_config: @@ -107,169 +105,5 @@ """ -class ActionModule(ContextActionModule): - _VALID_ARGS = frozenset( - ("type", "key", "value", "parameters", "service_name", "component_name", "host_id", "active") - ) - _MANDATORY_ARGS = ("type",) - - def __init__(self, task, connection, play_context, loader, templar, shared_loader_obj): - super().__init__( - task=task, - connection=connection, - play_context=play_context, - loader=loader, - templar=templar, - shared_loader_obj=shared_loader_obj, - ) - is_params = self.check_params_and_keys() - self._config = self._get_config(is_params=is_params) - self._attr = self._get_attr(is_params=is_params) - - def check_params_and_keys(self) -> bool: - is_active = "active" in self._task.args - is_key = "key" in self._task.args - is_value = "value" in self._task.args - is_params = "parameters" in self._task.args - - if (is_key or is_value) and is_params: - raise AnsibleError("'Parameters' must not be use with 'key'/'value'") - - if is_key and is_active and is_value: - raise AnsibleError("'active' must not be use with 'value'") - - if not ((is_key and (is_value or is_active)) or is_params): - raise AnsibleError("'key' and 'value'/'active' or 'parameters' arguments are mandatory") - - if is_params: - for item in self._task.args["parameters"]: - if "key" not in item: - raise AnsibleError("we should use 'key' inside 'parameters") - - if not ("value" in item or "active" in item): - raise AnsibleError( - "'key' and 'value'/'active' in 'parameters' arguments are mandatory in each parameters item" - ) - if "value" in item and "active" in item: - raise AnsibleError("'active' must not be use with 'value'") - - return is_params - - def _get_config(self, is_params: bool) -> dict: - config = {} - - if is_params: - for item in self._task.args["parameters"]: - config[item["key"]] = item.get("value", {} if "active" in item else None) - else: - config[self._task.args.get("key")] = self._task.args.get( - "value", {} if "active" in self._task.args else None - ) - - return config - - def _get_attr(self, is_params: bool) -> dict: - attr = {} - - if is_params: - for item in self._task.args["parameters"]: - if "active" in item: - attr[item["key"]] = {"active": item["active"]} - else: - if "active" in self._task.args: - attr[self._task.args.get("key")] = {"active": self._task.args["active"]} - - return attr - - def _do_cluster(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - set_cluster_config, - context["cluster_id"], - self._config, - self._attr, - ) - res["value"] = self._task.args.get("value", self._config) - - return res - - def _do_service_by_name(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - set_service_config_by_name, - context["cluster_id"], - self._task.args["service_name"], - self._config, - self._attr, - ) - res["value"] = self._task.args.get("value", self._config) - - return res - - def _do_service(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - set_service_config, - context["cluster_id"], - context["service_id"], - self._config, - self._attr, - ) - res["value"] = self._task.args.get("value", self._config) - - return res - - def _do_host(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - set_host_config, - context["host_id"], - self._config, - self._attr, - ) - res["value"] = self._task.args.get("value", self._config) - - return res - - def _do_host_from_provider(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - set_host_config, - self._task.args["host_id"], - self._config, - self._attr, - ) - res["value"] = self._task.args.get("value", self._config) - - return res - - def _do_provider(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - set_provider_config, - context["provider_id"], - self._config, - self._attr, - ) - res["value"] = self._task.args.get("value", self._config) - - return res - - def _do_component_by_name(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - set_component_config_by_name, - context["cluster_id"], - context["service_id"], - self._task.args["component_name"], - self._task.args.get("service_name", None), - self._config, - self._attr, - ) - res["value"] = self._task.args.get("value", self._config) - - return res - - def _do_component(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - set_component_config, - context["component_id"], - self._config, - self._attr, - ) - res["value"] = self._task.args.get("value", self._config) - - return res +class ActionModule(ADCMAnsiblePlugin): + executor_class = ADCMConfigPluginExecutor diff --git a/python/ansible/plugins/lookup/adcm_config.py b/python/ansible/plugins/lookup/adcm_config.py index 8ee99bc10c..c9861fb6ff 100644 --- a/python/ansible/plugins/lookup/adcm_config.py +++ b/python/ansible/plugins/lookup/adcm_config.py @@ -10,6 +10,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from copy import deepcopy +from typing import NamedTuple import sys from ansible.errors import AnsibleError @@ -19,14 +21,20 @@ import adcm.init_django # noqa: F401, isort:skip -from ansible_plugin.utils import ( - set_cluster_config, - set_host_config, - set_provider_config, - set_service_config, - set_service_config_by_name, -) +from ansible_plugin.utils import cast_to_type, get_service_by_name +from cm.adcm_config.ansible import ansible_decrypt +from cm.api import set_object_config_with_plugin from cm.logger import logger +from cm.models import ( + ADCMEntity, + Cluster, + ClusterObject, + ConfigLog, + Host, + HostProvider, + PrototypeConfig, +) +from cm.status_api import send_config_creation_event DOCUMENTATION = """ lookup: file @@ -107,3 +115,120 @@ def run(self, terms, variables=None, **kwargs): ret.append(res.value) return ret + + +class PluginResult(NamedTuple): + value: dict | int | str + changed: bool + + +def update_config(obj: ADCMEntity, conf: dict, attr: dict) -> PluginResult: + config_log = ConfigLog.objects.get(id=obj.config.current) + + new_config = deepcopy(config_log.config) + new_attr = deepcopy(config_log.attr) if config_log.attr is not None else {} + + for keys, value in conf.items(): + keys_list = keys.split("/") + key = keys_list[0] + subkey = None + if len(keys_list) > 1: + subkey = keys_list[1] + + if subkey: + try: + prototype_conf = PrototypeConfig.objects.get( + name=key, subname=subkey, prototype=obj.prototype, action=None + ) + except PrototypeConfig.DoesNotExist as error: + raise AnsibleError(f"Config parameter '{key}/{subkey}' does not exist") from error + new_config[key][subkey] = cast_to_type( + field_type=prototype_conf.type, value=value, limits=prototype_conf.limits + ) + else: + try: + prototype_conf = PrototypeConfig.objects.get(name=key, subname="", prototype=obj.prototype, action=None) + except PrototypeConfig.DoesNotExist as error: + raise AnsibleError(f"Config parameter '{key}' does not exist") from error + new_config[key] = cast_to_type(field_type=prototype_conf.type, value=value, limits=prototype_conf.limits) + + if key in attr: + prototype_conf = PrototypeConfig.objects.filter( + name=key, prototype=obj.prototype, type="group", action=None + ) + + if not prototype_conf or "activatable" not in prototype_conf.first().limits: + raise AnsibleError("'active' key should be used only with activatable group") + + new_attr.update(attr) + + for key in attr: + for subkey, value in config_log.config[key].items(): + if not new_config[key] or subkey not in new_config[key]: + new_config[key][subkey] = value + + if _does_contain(base_dict=config_log.config, part=new_config) and _does_contain( + base_dict=config_log.attr, part=new_attr + ): + return PluginResult(conf, False) + + set_object_config_with_plugin(obj=obj, config=new_config, attr=new_attr) + send_config_creation_event(object_=obj) + + if len(conf) == 1: + return PluginResult(next(iter(conf.values())), True) + + return PluginResult(conf, True) + + +def set_cluster_config(cluster_id: int, config: dict, attr: dict) -> PluginResult: + obj = Cluster.obj.get(id=cluster_id) + + return update_config(obj=obj, conf=config, attr=attr) + + +def set_host_config(host_id: int, config: dict, attr: dict) -> PluginResult: + obj = Host.obj.get(id=host_id) + + return update_config(obj=obj, conf=config, attr=attr) + + +def set_provider_config(provider_id: int, config: dict, attr: dict) -> PluginResult: + obj = HostProvider.obj.get(id=provider_id) + + return update_config(obj=obj, conf=config, attr=attr) + + +def set_service_config_by_name(cluster_id: int, service_name: str, config: dict, attr: dict) -> PluginResult: + obj = get_service_by_name(cluster_id, service_name) + + return update_config(obj=obj, conf=config, attr=attr) + + +def set_service_config(cluster_id: int, service_id: int, config: dict, attr: dict) -> PluginResult: + obj = ClusterObject.obj.get(id=service_id, cluster__id=cluster_id, prototype__type="service") + + return update_config(obj=obj, conf=config, attr=attr) + + +def _does_contain(base_dict: dict, part: dict) -> bool: + """ + Check fields in `part` have the same value in `base_dict` + """ + + for key, val2 in part.items(): + if key not in base_dict: + return False + + val1 = base_dict[key] + + if isinstance(val1, dict) and isinstance(val2, dict): + if not _does_contain(val1, val2): + return False + else: + val1 = ansible_decrypt(val1) + val2 = ansible_decrypt(val2) + if val1 != val2: + return False + + return True diff --git a/python/ansible_plugin/base.py b/python/ansible_plugin/base.py index 2bc3418f67..2dce318f8b 100644 --- a/python/ansible_plugin/base.py +++ b/python/ansible_plugin/base.py @@ -69,6 +69,7 @@ class CoreObjectTargetDescription(BaseModel): service_name: str | None = None component_name: str | None = None + host_id: int | str | None = None @field_validator("type", mode="before") @classmethod @@ -85,82 +86,23 @@ def from_objects( if not isinstance(objects := raw_arguments.get("objects"), list): return () - targets = [] + return tuple( + _from_target_description(target_description=target_description, context=context) + for target_description in (CoreObjectTargetDescription(**entry) for entry in objects) + ) - for target_description in (CoreObjectTargetDescription(**entry) for entry in objects): - match target_description.type: - case "cluster": - if context.cluster_id: - targets.append(CoreObjectDescriptor(id=context.cluster_id, type=ADCMCoreType.CLUSTER)) - else: - message = "Can't identify cluster from context" - raise PluginRuntimeError(message=message) - case "service": - if target_description.service_name: - if not context.cluster_id: - message = "Can't identify service by name without `cluster_id` in context" - raise PluginRuntimeError(message=message) - - targets.append( - CoreObjectDescriptor( - id=ClusterObject.objects.values_list("id", flat=True).get( - cluster_id=context.cluster_id, prototype__name=target_description.service_name - ), - type=ADCMCoreType.SERVICE, - ) - ) - elif context.service_id: - targets.append(CoreObjectDescriptor(id=context.service_id, type=ADCMCoreType.SERVICE)) - else: - message = f"Can't identify service based on {target_description=}" - raise PluginRuntimeError(message=message) - case "component": - if target_description.component_name: - if not context.cluster_id: - message = "Can't identify component by name without `cluster_id` in context" - raise PluginRuntimeError(message=message) - - component_id_qs = ServiceComponent.objects.values_list("id", flat=True) - kwargs = {"cluster_id": context.cluster_id, "prototype__name": target_description.component_name} - if target_description.service_name: - targets.append( - CoreObjectDescriptor( - id=component_id_qs.get( - **kwargs, service__prototype__name=target_description.service_name - ), - type=ADCMCoreType.COMPONENT, - ) - ) - elif context.service_id: - targets.append( - CoreObjectDescriptor( - id=component_id_qs.get(**kwargs, service_id=context.service_id), - type=ADCMCoreType.COMPONENT, - ) - ) - else: - message = "Can't identify component by name without `service_name` or out of service context" - raise PluginRuntimeError(message=message) - - elif context.component_id: - targets.append(CoreObjectDescriptor(id=context.component_id, type=ADCMCoreType.COMPONENT)) - else: - message = f"Can't identify component based on {target_description=}" - raise PluginRuntimeError(message=message) - case "provider": - if context.provider_id: - targets.append(CoreObjectDescriptor(id=context.provider_id, type=ADCMCoreType.HOSTPROVIDER)) - else: - message = "Can't identify hostprovider from context" - raise PluginRuntimeError(message=message) - case "host": - if context.host_id: - targets.append(CoreObjectDescriptor(id=context.host_id, type=ADCMCoreType.HOST)) - else: - message = "Can't identify host from context" - raise PluginRuntimeError(message=message) - return tuple(targets) +def from_arguments_root( + context_owner: CoreObjectDescriptor, # noqa: ARG001 + context: AnsibleJobContext, + raw_arguments: dict, +) -> tuple[CoreObjectDescriptor, ...]: + try: + target = CoreObjectTargetDescription(**raw_arguments) + except ValidationError: + return () + + return (_from_target_description(target_description=target, context=context),) def from_context( @@ -171,6 +113,87 @@ def from_context( return (context_owner,) +def _from_target_description( + target_description: CoreObjectTargetDescription, context: AnsibleJobContext +) -> CoreObjectDescriptor: + match target_description.type: + case "cluster": + if context.cluster_id: + return CoreObjectDescriptor(id=context.cluster_id, type=ADCMCoreType.CLUSTER) + + message = "Can't identify cluster from context" + raise PluginRuntimeError(message=message) + + case "service": + if target_description.service_name: + if not context.cluster_id: + message = "Can't identify service by name without `cluster_id` in context" + raise PluginRuntimeError(message=message) + + return CoreObjectDescriptor( + id=ClusterObject.objects.values_list("id", flat=True).get( + cluster_id=context.cluster_id, prototype__name=target_description.service_name + ), + type=ADCMCoreType.SERVICE, + ) + + if context.service_id: + return CoreObjectDescriptor(id=context.service_id, type=ADCMCoreType.SERVICE) + + message = f"Can't identify service based on {target_description=}" + raise PluginRuntimeError(message=message) + + case "component": + if target_description.component_name: + if not context.cluster_id: + message = "Can't identify component by name without `cluster_id` in context" + raise PluginRuntimeError(message=message) + + component_id_qs = ServiceComponent.objects.values_list("id", flat=True) + kwargs = {"cluster_id": context.cluster_id, "prototype__name": target_description.component_name} + if target_description.service_name: + return CoreObjectDescriptor( + id=component_id_qs.get(**kwargs, service__prototype__name=target_description.service_name), + type=ADCMCoreType.COMPONENT, + ) + + if context.service_id: + return CoreObjectDescriptor( + id=component_id_qs.get(**kwargs, service_id=context.service_id), + type=ADCMCoreType.COMPONENT, + ) + + message = "Can't identify component by name without `service_name` or out of service context" + raise PluginRuntimeError(message=message) + + if context.component_id: + return CoreObjectDescriptor(id=context.component_id, type=ADCMCoreType.COMPONENT) + + message = f"Can't identify component based on {target_description=}" + raise PluginRuntimeError(message=message) + + case "provider": + if context.provider_id: + return CoreObjectDescriptor(id=context.provider_id, type=ADCMCoreType.HOSTPROVIDER) + + message = "Can't identify hostprovider from context" + raise PluginRuntimeError(message=message) + + case "host": + if target_description.host_id: + return CoreObjectDescriptor(id=int(target_description.host_id), type=ADCMCoreType.HOST) + + if context.host_id: + return CoreObjectDescriptor(id=context.host_id, type=ADCMCoreType.HOST) + + message = "Can't identify host from context" + raise PluginRuntimeError(message=message) + + case _: + message = f"Can't identify object of type {target_description.type}" + raise PluginRuntimeError(message=message) + + # Plugin CallArguments = TypeVar("CallArguments", bound=BaseModel) diff --git a/python/ansible_plugin/errors.py b/python/ansible_plugin/errors.py index 68c675b15a..f043e0c930 100644 --- a/python/ansible_plugin/errors.py +++ b/python/ansible_plugin/errors.py @@ -35,3 +35,7 @@ class PluginValidationError(ADCMPluginError): class PluginContextError(ADCMPluginError): ... + + +class PluginIncorrectCallError(ADCMPluginError): + ... diff --git a/python/ansible_plugin/executors/_validators.py b/python/ansible_plugin/executors/_validators.py new file mode 100644 index 0000000000..80f253d629 --- /dev/null +++ b/python/ansible_plugin/executors/_validators.py @@ -0,0 +1,53 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from operator import attrgetter + +from core.types import ADCMCoreType, CoreObjectDescriptor + +from ansible_plugin.errors import PluginTargetError + +_CLUSTER_TYPES = {ADCMCoreType.CLUSTER, ADCMCoreType.SERVICE, ADCMCoreType.COMPONENT} +_HOSTPROVIDER_TYPES = {ADCMCoreType.HOSTPROVIDER, ADCMCoreType.HOST} +_ALLOWED_CONTEXT_OWNER_MAP: dict[ADCMCoreType, set[ADCMCoreType]] = { + ADCMCoreType.CLUSTER: _CLUSTER_TYPES, + ADCMCoreType.SERVICE: _CLUSTER_TYPES, + ADCMCoreType.COMPONENT: _CLUSTER_TYPES, + ADCMCoreType.HOSTPROVIDER: _HOSTPROVIDER_TYPES, + ADCMCoreType.HOST: _HOSTPROVIDER_TYPES, +} + + +def validate_target_allowed_for_context_owner( + context_owner: CoreObjectDescriptor, target: CoreObjectDescriptor +) -> PluginTargetError | None: + """ + Some plugins allow to target not the current object or "directly related" one, + so we want to check if such mutation is allowed. + This is the main implementation of this rule, there may be others like that, + but this one is based on `adcm_config` rules. + """ + + owner_types = _ALLOWED_CONTEXT_OWNER_MAP[target.type] + if context_owner.type not in owner_types: + return PluginTargetError( + message="Wrong context. " + f"Affecting {target.type.value} isn't allowed from {context_owner.type.value}. " + f"Allowed: {', '.join(sorted(map(attrgetter('value'), owner_types)))}." + ) + + if target.type == ADCMCoreType.HOST and target.id != context_owner.id: + # only case it'll happen (in terms of plugin call): + # context is "host" AND "type" is "host" AND "host_id" is specified as not the same as one in context + return PluginTargetError(message="Wrong context. One host can't be changed from another's context.") + + return None diff --git a/python/ansible_plugin/executors/config.py b/python/ansible_plugin/executors/config.py new file mode 100644 index 0000000000..1bd3cbb811 --- /dev/null +++ b/python/ansible_plugin/executors/config.py @@ -0,0 +1,253 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections import defaultdict +from copy import deepcopy +from typing import Any, Collection, TypeAlias, TypedDict + +from cm.adcm_config.ansible import ansible_decrypt +from cm.api import set_object_config_with_plugin +from cm.converters import core_type_to_model +from cm.models import ConfigLog +from cm.services.config import ConfigAttrPair +from cm.services.config.spec import FlatSpec, retrieve_flat_spec_for_objects +from cm.status_api import send_config_creation_event +from core.types import CoreObjectDescriptor +from django.db.transaction import atomic +from pydantic import BaseModel, model_validator +from typing_extensions import Self + +from ansible_plugin.base import ( + ADCMAnsiblePluginExecutor, + AnsibleJobContext, + ArgumentsConfig, + CallResult, + PluginExecutorConfig, + TargetConfig, + from_arguments_root, +) +from ansible_plugin.errors import PluginIncorrectCallError, PluginTargetDetectionError, PluginValidationError +from ansible_plugin.executors._validators import validate_target_allowed_for_context_owner +from ansible_plugin.utils import cast_to_type + +# don't want to typehint due to serialization problems and serialization priority +# (e.g. bool casted successfully to float, etc.) +ParamValue: TypeAlias = Any +OriginalValues: TypeAlias = ConfigAttrPair + + +class ParameterToChange(BaseModel): + key: str + value: ParamValue = None + active: bool | None = None + + @model_validator(mode="after") + def check_one_is_specified(self) -> Self: + if self.model_fields_set.issuperset({"active", "value"}): + message = "Could use only `value` or `active`, not both" + raise ValueError(message) + + return self + + @model_validator(mode="after") + def check_either_value_or_active(self) -> Self: + if not self.model_fields_set.intersection({"active", "value"}): + message = "Either `value` or `active` should be specified" + raise ValueError(message) + + return self + + +class ChangeConfigArguments(ParameterToChange): + # new API to change multiple parameters + parameters: list[ParameterToChange] | None = None + + # not required for old API for changing one parameter + key: str | None = None + + @model_validator(mode="after") + def check_either_single_or_multi_parameters(self) -> Self: + if "parameters" in self.model_fields_set: + if self.model_fields_set.intersection({"key", "value", "active"}): + message = "`parameters` can't be used with `key`/`value`/`active`" + raise ValueError(message) + elif not (self.key and self.model_fields_set.intersection({"active", "value"})): + message = "`key` should be specified with `active` or `value` when `parameters` aren't" + raise ValueError(message) + + return self + + @model_validator(mode="after") + def check_either_value_or_active(self) -> Self: + # check is moved to `check_either_single_or_multi_parameters` + return self + + +class ChangeConfigReturn(TypedDict): + value: dict[str, ParamValue] | ParamValue + + +def validate_type_is_present( + context_owner: CoreObjectDescriptor, + context: AnsibleJobContext, # noqa: ARG001 + raw_arguments: dict, +) -> PluginValidationError | None: + _ = context, context_owner + + if "type" not in raw_arguments: + return PluginValidationError(message="`type` is required") + + return None + + +class ADCMConfigPluginExecutor(ADCMAnsiblePluginExecutor[ChangeConfigArguments, ChangeConfigReturn]): + _config = PluginExecutorConfig( + arguments=ArgumentsConfig(represent_as=ChangeConfigArguments), + target=TargetConfig(detectors=(from_arguments_root,), validators=(validate_type_is_present,)), + ) + + @atomic() + def __call__( + self, + targets: Collection[CoreObjectDescriptor], + arguments: ChangeConfigArguments, + context_owner: CoreObjectDescriptor, + context: AnsibleJobContext, + ) -> CallResult[ChangeConfigReturn]: + _ = context + + target, *_ = targets + + if error := validate_target_allowed_for_context_owner(context_owner=context_owner, target=target): + return CallResult(value={}, changed=False, error=error) + + changes = ConfigAttrPair(config={}, attr={}) + for parameter in arguments.parameters or [arguments]: + key = parameter.key + if "/" not in key: + key = f"{key}/" + + if parameter.active is not None: + changes.attr[key] = {"active": parameter.active} + else: + changes.config[key] = parameter.value + + return_value = self._prepare_return_value(changes.config) + + model = core_type_to_model(core_type=target.type) + try: + db_object = model.objects.select_related("config").get(id=target.id) + except model.DoesNotExist: + return CallResult( + value=None, changed=False, error=PluginTargetDetectionError(message=f"Failed to find {target=}") + ) + + configuration = ConfigAttrPair(**ConfigLog.objects.values("config", "attr").get(id=db_object.config.current)) + spec = next(iter(retrieve_flat_spec_for_objects(prototypes=(db_object.prototype_id,)).values())) + + original_values = _fill_config_and_attr(target=configuration, changes=changes, spec=spec) + + if _does_contain(base_dict=configuration.config, part=original_values.config) and _does_contain( + base_dict=configuration.attr, part=original_values.attr + ): + return CallResult(value=return_value, changed=False, error=None) + + set_object_config_with_plugin(obj=db_object, config=configuration.config, attr=configuration.attr) + send_config_creation_event(object_=db_object) + + return CallResult(value=return_value, changed=True, error=None) + + @staticmethod + def _prepare_return_value(config: dict) -> ChangeConfigReturn: + # todo what should be returned in `value`?? + # originally the same data that was passed was returned + # WITHOUT type casting + if len(config) == 1: + config_params = next(iter(config.values())) + else: + # removing trailing "/" to return to keys to input format + config_params = {key.rstrip("/"): value for key, value in config.items()} + + # putting result under the "value" key, because during result parsing dicts are merged into response, + # return of this plugin should always have `value` key + return ChangeConfigReturn(value=config_params) + + +def _fill_config_and_attr(target: ConfigAttrPair, changes: ConfigAttrPair, spec: FlatSpec) -> OriginalValues: + """ + Fill `target` with values from `changes` in-place + + :param target: Values for complex structures are nested (e.g. ["groupkey"]["valingorupkey"]) + :param changes: Keys must have the same format as flatspec (e.g. ["groupkey/subgroupkey"]) + :param spec: Flat specification for the changing config + :returns: Original values (from `target`) of keys that was changed for further checks + """ + + keys_to_change = set(changes.config.keys()) | set(changes.attr.keys()) + if missing_keys := keys_to_change - spec.keys(): + message = f"Some keys aren't presented in specification: {', '.join(sorted(missing_keys))}" + raise PluginIncorrectCallError(message=message) + + original_fields = defaultdict(dict) + original_attrs = {} + + for key, value in changes.config.items(): + param_spec = spec[key] + cast_value = cast_to_type(field_type=param_spec.type, value=value, limits=param_spec.limits) + + key, *subs = key.split("/", maxsplit=1) + subkey = subs[0] if subs else None + + if subkey: + original_fields[key][subkey] = target.config[key][subkey] + target.config[key][subkey] = cast_value + else: + original_fields[key] = target.config[key] + target.config[key] = cast_value + + # here we consider key full key + for key, value in changes.attr.items(): + param_spec = spec[key] + if "activatable" not in param_spec.limits: + message = ( + "`active` parameter can be changed only for activatable group. " f"Group {key} is not one of them." + ) + raise PluginIncorrectCallError(message=message) + + attr_key = key.rstrip("/") + original_attrs[attr_key] = deepcopy(target.attr[attr_key]) + target.attr[attr_key] |= value + + return OriginalValues(config=original_fields, attr=original_attrs) + + +def _does_contain(base_dict: dict, part: dict) -> bool: + """ + Check fields in `part` have the same value in `base_dict` + """ + + for key, val2 in part.items(): + if key not in base_dict: + return False + + val1 = base_dict[key] + + if isinstance(val1, dict) and isinstance(val2, dict): + if not _does_contain(val1, val2): + return False + else: + val1 = ansible_decrypt(val1) + val2 = ansible_decrypt(val2) + if val1 != val2: + return False + + return True diff --git a/python/ansible_plugin/tests/bundles/cluster_complex_config/config.yaml b/python/ansible_plugin/tests/bundles/cluster_complex_config/config.yaml new file mode 100644 index 0000000000..a23a8f20ce --- /dev/null +++ b/python/ansible_plugin/tests/bundles/cluster_complex_config/config.yaml @@ -0,0 +1,81 @@ +- type: cluster + name: with_config + version: 3 + + actions: &actions + dummy: &action + type: job + script_type: ansible + script: ./playbook.yaml + masking: + + on_host: + <<: *action + host_action: true + + config: &config + - name: plain_s + type: string + default: "4" + - name: plain_i + type: integer + default: 3 + - name: g1 + type: group + subs: + - name: plain_s + type: string + default: "inside of group" + - name: records + type: list + required: false + - name: group_b + type: boolean + default: false + - name: ag1 + type: group + activatable: true + active: false + subs: + - name: kv_pairs + type: map + required: false + - name: records + type: list + default: + - "first" + - "sec:ond" + - name: ag2 + type: group + activatable: true + active: true + subs: + - name: sec1 + type: password + required: false + - name: sec2 + type: secretmap + default: + k1: v1 + +- &service + type: service + name: service_1 + version: 2 + + actions: *actions + + config: *config + + components: + component_1: + actions: *actions + + config: *config + + component_2: + actions: *actions + +- <<: *service + name: service_2 + diff --git a/python/ansible_plugin/tests/bundles/provider/config.yaml b/python/ansible_plugin/tests/bundles/provider/config.yaml index 0c954415c6..292ec846ca 100644 --- a/python/ansible_plugin/tests/bundles/provider/config.yaml +++ b/python/ansible_plugin/tests/bundles/provider/config.yaml @@ -9,8 +9,24 @@ script: ./playbook.yaml masking: + config: &config + - name: ip + type: string + default: "127.0.0.1" + - name: inside + type: group + subs: + - name: simple_secret + type: password + required: false + - name: complex_secret + type: secrettext + required: false + - type: host name: host version: 2 actions: *actions + + config: *config diff --git a/python/ansible_plugin/tests/test_adcm_config.py b/python/ansible_plugin/tests/test_adcm_config.py new file mode 100644 index 0000000000..2adb8e7b37 --- /dev/null +++ b/python/ansible_plugin/tests/test_adcm_config.py @@ -0,0 +1,220 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from cm.adcm_config.ansible import ansible_decrypt +from cm.models import ADCMEntity, ConcernItem, ConfigLog, ServiceComponent +from cm.services.config import ConfigAttrPair +from cm.services.job.run.repo import JobRepoImpl +from core.job.types import Task + +from ansible_plugin.base import CallResult +from ansible_plugin.errors import PluginTargetError +from ansible_plugin.executors.config import ADCMConfigPluginExecutor +from ansible_plugin.tests.base import BaseTestEffectsOfADCMAnsiblePlugins + +EXECUTOR_MODULE = "ansible_plugin.executors.config" + + +class TestEffectsOfADCMAnsiblePlugins(BaseTestEffectsOfADCMAnsiblePlugins): + def setUp(self) -> None: + super().setUp() + + self.cluster_bundle = self.add_bundle(self.bundles_dir / "cluster_complex_config") + self.cluster = self.add_cluster(bundle=self.cluster_bundle, name="Cluster With Config") + + ConcernItem.objects.all().delete() + + self.service_1, self.service_2 = self.add_services_to_cluster( + ["service_1", "service_2"], cluster=self.cluster + ).order_by("prototype__name") + self.component_1, self.component_2 = ( + ServiceComponent.objects.filter(service=self.service_1).order_by("prototype__name").all() + ) + + self.add_host_to_cluster(cluster=self.cluster, host=self.host_1) + self.add_host_to_cluster(cluster=self.cluster, host=self.host_2) + + self.set_hostcomponent( + cluster=self.cluster, + entries=((self.host_1, self.component_1), (self.host_1, self.component_2), (self.host_2, self.component_1)), + ) + + def get_config_attr(self, object_: ADCMEntity) -> ConfigAttrPair: + object_.refresh_from_db(fields=["config"]) + return ConfigAttrPair(**ConfigLog.objects.values("config", "attr").get(id=object_.config.current)) + + def execute_plugin(self, task: Task, call_arguments: str | dict) -> CallResult: + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMConfigPluginExecutor, + call_arguments=call_arguments, + call_context=job, + ) + + return executor.execute() + + def test_simple_change_one_value_success(self) -> None: + changed_value = "awesomenewstring" + in_group_value = "inside of group" + + result = self.execute_plugin( + task=self.prepare_task(owner=self.cluster, name="dummy"), + call_arguments=f""" + type: cluster + key: plain_s + value: {changed_value} + """, + ) + + self.assertIsNone(result.error) + self.assertTrue(result.changed) + self.assertEqual(result.value, {"value": changed_value}) + + after = self.get_config_attr(self.cluster) + self.assertEqual(after.config["plain_s"], changed_value) + self.assertEqual(after.config["g1"]["plain_s"], in_group_value) + + def test_multi_change_with_activation_success(self) -> None: + values_to_change = { + "plain_i": 4, + "g1/records": ["hello", "14953"], + "g1/group_b": True, + "ag1/kv_pairs": {"good": "in-every", "per": "son"}, + } + expected_config = self.get_config_attr(self.cluster).config + expected_config["plain_i"] = values_to_change["plain_i"] + expected_config["g1"]["records"] = values_to_change["g1/records"] + expected_config["g1"]["group_b"] = values_to_change["g1/group_b"] + expected_config["ag1"]["kv_pairs"] = values_to_change["ag1/kv_pairs"] + + result = self.execute_plugin( + task=self.prepare_task(owner=self.cluster, name="dummy"), + call_arguments={ + "type": "cluster", + "parameters": [ + *[{"key": key, "value": value} for key, value in values_to_change.items()], + {"key": "ag1", "active": True}, + ], + }, + ) + + self.assertIsNone(result.error, result.error) + self.assertTrue(result.changed) + self.assertEqual(result.value, {"value": values_to_change}) + + after = self.get_config_attr(self.cluster) + self.assertTrue(after.attr["ag1"], {"active": True}) + self.assertEqual(after.config, expected_config) + + def test_no_change_call_on_provider_success(self) -> None: + expected_config = self.get_config_attr(self.provider).config + same_values = {"ip": expected_config["ip"], "inside/simple_secret": None} + config_before = self.provider.config.current + + result = self.execute_plugin( + task=self.prepare_task(owner=self.provider, name="dummy"), + call_arguments={ + "type": "provider", + "parameters": [*[{"key": key, "value": value} for key, value in same_values.items()]], + }, + ) + + self.assertIsNone(result.error, result.error) + self.assertFalse(result.changed) + self.assertEqual(result.value, {"value": same_values}) + + after = self.get_config_attr(self.provider) + self.assertEqual(after.config, expected_config) + + self.assertEqual(self.provider.config.current, config_before) + + def test_change_secret_field_success(self) -> None: + new_secretfile = "multiline awesome\ncontent" + + result = self.execute_plugin( + task=self.prepare_task(owner=self.provider, name="dummy"), + call_arguments={"type": "provider", "key": "inside/complex_secret", "value": new_secretfile}, + ) + + self.assertIsNone(result.error, result.error) + self.assertTrue(result.changed) + self.assertEqual(result.value, {"value": new_secretfile}) + + config = self.get_config_attr(self.provider).config + self.assertEqual(ansible_decrypt(config["inside"]["complex_secret"]), new_secretfile) + + def test_change_only_active_success(self) -> None: + object_ = self.service_1 + + result = self.execute_plugin( + task=self.prepare_task(owner=object_, name="dummy"), + call_arguments=""" + type: service + key: ag1 + active: true + """, + ) + + self.assertIsNone(result.error) + self.assertTrue(result.changed) + self.assertEqual(result.value, {"value": {}}) + + after = self.get_config_attr(object_) + self.assertTrue(after.attr["ag1"]["active"]) + + def test_change_de_activate_multiple_groups_success(self) -> None: + object_ = self.component_1 + + result = self.execute_plugin( + task=self.prepare_task(owner=object_, name="dummy"), + call_arguments={ + "type": "component", + "parameters": [{"key": "ag1", "active": True}, {"key": "ag2", "active": False}], + }, + ) + + self.assertIsNone(result.error) + self.assertTrue(result.changed) + self.assertEqual(result.value, {"value": {}}) + + after = self.get_config_attr(object_) + self.assertTrue(after.attr["ag1"]["active"]) + self.assertFalse(after.attr["ag2"]["active"]) + + def test_no_change_multiple_activatable_groups_success(self) -> None: + object_ = self.component_1 + + result = self.execute_plugin( + task=self.prepare_task(owner=object_, name="dummy"), + call_arguments={ + "type": "component", + "parameters": [{"key": "ag1", "active": False}, {"key": "ag2", "active": True}], + }, + ) + + self.assertIsNone(result.error) + self.assertFalse(result.changed) + self.assertEqual(result.value, {"value": {}}) + + after = self.get_config_attr(object_) + self.assertFalse(after.attr["ag1"]["active"]) + self.assertTrue(after.attr["ag2"]["active"]) + + def test_change_one_host_from_another_fail(self) -> None: + result = self.execute_plugin( + task=self.prepare_task(owner=self.host_1, name="dummy"), + call_arguments={"type": "host", "host_id": self.host_2.id, "key": "something", "value": "troll"}, + ) + + self.assertIsInstance(result.error, PluginTargetError) + self.assertEqual(result.error.message, "Wrong context. One host can't be changed from another's context.") diff --git a/python/ansible_plugin/utils.py b/python/ansible_plugin/utils.py index f788a188f3..fe37d24891 100644 --- a/python/ansible_plugin/utils.py +++ b/python/ansible_plugin/utils.py @@ -11,8 +11,7 @@ # limitations under the License. from collections import defaultdict -from copy import deepcopy -from typing import Any, NamedTuple +from typing import Any import json import fcntl @@ -35,9 +34,8 @@ MSG_NO_SERVICE_NAME, MSG_NO_MULTI_STATE_TO_DELETE, ) -from cm.adcm_config.ansible import ansible_decrypt from cm.adcm_config.config import get_option_value -from cm.api import add_hc, get_hc, set_object_config_with_plugin +from cm.api import add_hc, get_hc from cm.errors import AdcmEx from cm.models import ( Action, @@ -45,7 +43,6 @@ CheckLog, Cluster, ClusterObject, - ConfigLog, GroupCheckLog, Host, HostProvider, @@ -53,11 +50,9 @@ JobStatus, LogStorage, Prototype, - PrototypeConfig, ServiceComponent, - get_model_by_type, ) -from cm.status_api import send_object_update_event, send_config_creation_event +from cm.status_api import send_object_update_event from rbac.models import Role, Policy from rbac.roles import assign_group_perm # isort: on @@ -116,20 +111,6 @@ def get_object_id_from_context( return context[id_type], None -def get_context_object(task_vars: dict, err_msg: str = None) -> ADCMEntity: - obj_type = task_vars["context"]["type"] - - obj_pk, _ = get_object_id_from_context( - task_vars=task_vars, id_type=f"{obj_type}_id", context_types=(obj_type,), err_msg=err_msg - ) - obj = get_model_by_type(object_type=obj_type).objects.filter(pk=obj_pk).first() - - if not obj: - raise AnsibleError(f'Object of type "{obj_type}" with PK "{obj_pk}" does not exist') - - return obj - - class ContextActionModule(ActionBase): TRANSFERS_FILES = False _VALID_ARGS = None @@ -137,11 +118,10 @@ class ContextActionModule(ActionBase): def _wrap_call(self, func, *args): try: - res = func(*args) + func(*args) except AdcmEx as e: return {"failed": True, "msg": e.msg} - if isinstance(res, PluginResult): - return {"changed": res.changed} + return {"changed": True} def _check_mandatory(self): @@ -399,142 +379,6 @@ def cast_to_type(field_type: str, value: Any, limits: dict) -> Any: raise AnsibleError(f"Could not convert '{value}' to '{field_type}'") from error -class PluginResult(NamedTuple): - value: dict | int | str - changed: bool - - -def update_config(obj: ADCMEntity, conf: dict, attr: dict) -> PluginResult: - config_log = ConfigLog.objects.get(id=obj.config.current) - - new_config = deepcopy(config_log.config) - new_attr = deepcopy(config_log.attr) if config_log.attr is not None else {} - - for keys, value in conf.items(): - keys_list = keys.split("/") - key = keys_list[0] - subkey = None - if len(keys_list) > 1: - subkey = keys_list[1] - - if subkey: - try: - prototype_conf = PrototypeConfig.objects.get( - name=key, subname=subkey, prototype=obj.prototype, action=None - ) - except PrototypeConfig.DoesNotExist as error: - raise AnsibleError(f"Config parameter '{key}/{subkey}' does not exist") from error - new_config[key][subkey] = cast_to_type( - field_type=prototype_conf.type, value=value, limits=prototype_conf.limits - ) - else: - try: - prototype_conf = PrototypeConfig.objects.get(name=key, subname="", prototype=obj.prototype, action=None) - except PrototypeConfig.DoesNotExist as error: - raise AnsibleError(f"Config parameter '{key}' does not exist") from error - new_config[key] = cast_to_type(field_type=prototype_conf.type, value=value, limits=prototype_conf.limits) - - if key in attr: - prototype_conf = PrototypeConfig.objects.filter( - name=key, prototype=obj.prototype, type="group", action=None - ) - - if not prototype_conf or "activatable" not in prototype_conf.first().limits: - raise AnsibleError("'active' key should be used only with activatable group") - - new_attr.update(attr) - - for key in attr: - for subkey, value in config_log.config[key].items(): - if not new_config[key] or subkey not in new_config[key]: - new_config[key][subkey] = value - - if _does_contain(base_dict=config_log.config, part=new_config) and _does_contain( - base_dict=config_log.attr, part=new_attr - ): - return PluginResult(conf, False) - - set_object_config_with_plugin(obj=obj, config=new_config, attr=new_attr) - send_config_creation_event(object_=obj) - - if len(conf) == 1: - return PluginResult(list(conf.values())[0], True) - - return PluginResult(conf, True) - - -def set_cluster_config(cluster_id: int, config: dict, attr: dict) -> PluginResult: - obj = Cluster.obj.get(id=cluster_id) - - return update_config(obj=obj, conf=config, attr=attr) - - -def set_host_config(host_id: int, config: dict, attr: dict) -> PluginResult: - obj = Host.obj.get(id=host_id) - - return update_config(obj=obj, conf=config, attr=attr) - - -def set_provider_config(provider_id: int, config: dict, attr: dict) -> PluginResult: - obj = HostProvider.obj.get(id=provider_id) - - return update_config(obj=obj, conf=config, attr=attr) - - -def set_service_config_by_name(cluster_id: int, service_name: str, config: dict, attr: dict) -> PluginResult: - obj = get_service_by_name(cluster_id, service_name) - - return update_config(obj=obj, conf=config, attr=attr) - - -def set_service_config(cluster_id: int, service_id: int, config: dict, attr: dict) -> PluginResult: - obj = ClusterObject.obj.get(id=service_id, cluster__id=cluster_id, prototype__type="service") - - return update_config(obj=obj, conf=config, attr=attr) - - -def _does_contain(base_dict: dict, part: dict) -> bool: - """ - Check fields in `part` have the same value in `base_dict` - """ - - for key, val2 in part.items(): - if key not in base_dict: - return False - - val1 = base_dict[key] - - if isinstance(val1, dict) and isinstance(val2, dict): - if not _does_contain(val1, val2): - return False - else: - val1 = ansible_decrypt(val1) - val2 = ansible_decrypt(val2) - if val1 != val2: - return False - - return True - - -def set_component_config_by_name( - cluster_id: int, - service_id: int, - component_name: str, - service_name: str, - config: dict, - attr: dict, -): - obj = get_component_by_name(cluster_id, service_id, component_name, service_name) - - return update_config(obj=obj, conf=config, attr=attr) - - -def set_component_config(component_id: int, config: dict, attr: dict): - obj = ServiceComponent.obj.get(id=component_id) - - return update_config(obj=obj, conf=config, attr=attr) - - def check_missing_ok(obj: ADCMEntity, multi_state: str, missing_ok): if missing_ok is False and multi_state not in obj.multi_state: raise AnsibleError(MSG_NO_MULTI_STATE_TO_DELETE) diff --git a/python/cm/tests/test_ansible_plugins/test_adcm_config.py b/python/cm/tests/test_ansible_plugins/test_adcm_config.py deleted file mode 100644 index dc03f93fa6..0000000000 --- a/python/cm/tests/test_ansible_plugins/test_adcm_config.py +++ /dev/null @@ -1,155 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from pathlib import Path - -from adcm.tests.base import BaseTestCase, BusinessLogicMixin -from ansible_plugin.utils import set_cluster_config, set_provider_config - -from cm.adcm_config.ansible import ansible_decrypt -from cm.models import ConfigLog - - -class TestAnsiblePluginADCMConfig(BusinessLogicMixin, BaseTestCase): - def setUp(self): - super().setUp() - self.bundles_dir = Path(__file__).parent.parent / "bundles" - - cluster_bundle = self.add_bundle(source_dir=self.bundles_dir / "cluster_full_config") - provider_bundle = self.add_bundle(source_dir=self.bundles_dir / "provider_full_config") - cluster_multiple_activatable_group_bundle = self.add_bundle( - source_dir=self.bundles_dir / "cluster_multiple_activatable_group_config" - ) - self.cluster_1 = self.add_cluster(bundle=cluster_multiple_activatable_group_bundle, name="cluster_1") - self.current_cluster_1_config = ConfigLog.objects.get(id=self.cluster_1.config.current) - self.cluster = self.add_cluster(bundle=cluster_bundle, name="cluster") - self.current_cluster_config = ConfigLog.objects.get(id=self.cluster.config.current) - self.provider = self.add_provider(bundle=provider_bundle, name="provider") - self.current_provider_config = ConfigLog.objects.get(id=self.provider.config.current) - - def test_edit_provider_config_no_changes_success(self): - config = {"source_list": ["ok", "fail"]} - attr = {} - - set_provider_config(provider_id=self.provider.pk, config=config, attr=attr) - - self.provider.refresh_from_db() - changed_hostprovider_config = ConfigLog.objects.get(id=self.provider.config.current) - - self.assertEqual(self.current_provider_config.pk, changed_hostprovider_config.pk) - - def test_edit_cluster_config_with_activate_group_changed_success(self): - config = { - "activatable_group": {"simple": "string"}, - "boolean": False, - "plain_group": {"map": {"key": "value"}}, - "list": ["value4", "value2", "value3"], - } - attr = {"activatable_group": {"active": True}} - - set_cluster_config(cluster_id=self.cluster.pk, config=config, attr=attr) - - self.cluster.refresh_from_db() - changed_cluster_config = ConfigLog.objects.get(id=self.cluster.config.current) - - self.assertNotEqual(self.current_cluster_config.pk, changed_cluster_config.pk) - self.assertEqual(changed_cluster_config.config["activatable_group"]["simple"], "string") - self.assertFalse(changed_cluster_config.config["boolean"]) - self.assertDictEqual(changed_cluster_config.config["plain_group"]["map"], {"key": "value"}) - self.assertListEqual(changed_cluster_config.config["list"], ["value4", "value2", "value3"]) - self.assertTrue(changed_cluster_config.attr["activatable_group"]["active"]) - - def test_edit_provider_config_secret_field_changed_success(self): - config = {"secretmap": {"secret_string": "string"}} - attr = {} - - set_provider_config(provider_id=self.provider.pk, config=config, attr=attr) - - self.provider.refresh_from_db() - changed_provider_config = ConfigLog.objects.get(id=self.provider.config.current) - - self.assertNotEqual(self.current_cluster_config.pk, changed_provider_config.pk) - self.assertEqual("string", ansible_decrypt(changed_provider_config.config["secretmap"]["secret_string"])) - - def test_edit_cluster_config_one_field_changed_success(self): - config = { - "source_list": ["ok", "fail", "abort"], - } - attr = {} - - set_cluster_config(cluster_id=self.cluster.pk, config=config, attr=attr) - - self.cluster.refresh_from_db() - - changed_cluster_config = ConfigLog.objects.get(id=self.cluster.config.current) - - self.assertNotEqual(self.current_cluster_config.pk, changed_cluster_config.pk) - self.assertNotEqual( - self.current_cluster_config.config["source_list"], changed_cluster_config.config["source_list"] - ) - - def test_edit_cluster_config_update_empty_string_field_not_changes_success(self): - config = {"string": ""} - attr = {} - - set_cluster_config(cluster_id=self.cluster.pk, config=config, attr=attr) - - self.cluster.refresh_from_db() - changed_cluster_config = ConfigLog.objects.get(id=self.cluster.config.current) - self.assertEqual(self.current_cluster_config.pk, changed_cluster_config.pk) - - def test_edit_cluster_config_update_only_activated_group_changed_success(self): - # TODO: need to send for update attr, this is guaranteed by the function `_get_config` from ActionModule class - config = {"activatable_group": {}} - attr = {"activatable_group": {"active": True}} - - set_cluster_config(cluster_id=self.cluster.pk, config=config, attr=attr) - - self.cluster.refresh_from_db() - changed_cluster_config = ConfigLog.objects.get(id=self.cluster.config.current) - self.assertNotEqual(self.current_cluster_config.pk, changed_cluster_config.pk) - self.assertTrue(changed_cluster_config.attr["activatable_group"]["active"]) - - def test_edit_cluster_config_update_only_activated_group_no_changes_success(self): - # TODO: need to send for update attr, this is guaranteed by the function `_get_config` from ActionModule class - config = {"activatable_group": {}} - attr = {"activatable_group": {"active": False}} - - set_cluster_config(cluster_id=self.cluster.pk, config=config, attr=attr) - - self.cluster.refresh_from_db() - changed_cluster_config = ConfigLog.objects.get(id=self.cluster.config.current) - self.assertEqual(self.current_cluster_config.pk, changed_cluster_config.pk) - - def test_edit_cluster_config_update_multiple_activatable_group_changed_success(self): - # TODO: need to send for update attr, this is guaranteed by the function `_get_config` from ActionModule class - config = {"activatable_group_string": {}, "activatable_group_integer": {}} - attr = {"activatable_group_string": {"active": True}, "activatable_group_integer": {"active": True}} - - set_cluster_config(cluster_id=self.cluster_1.pk, config=config, attr=attr) - - self.cluster_1.refresh_from_db() - changed_cluster_config = ConfigLog.objects.get(id=self.cluster_1.config.current) - self.assertNotEqual(changed_cluster_config.pk, self.current_cluster_1_config.pk) - self.assertTrue(changed_cluster_config.attr["activatable_group_string"]["active"]) - self.assertTrue(changed_cluster_config.attr["activatable_group_integer"]["active"]) - - def test_edit_cluster_config_update_multiple_activatable_group_no_changed_success(self): - # TODO: need to send for update attr, this is guaranteed by the function `_get_config` from ActionModule class - config = {"activatable_group_string": {}, "activatable_group_integer": {}} - attr = {"activatable_group_string": {"active": False}, "activatable_group_integer": {"active": False}} - - set_cluster_config(cluster_id=self.cluster_1.pk, config=config, attr=attr) - - self.cluster_1.refresh_from_db() - changed_cluster_config = ConfigLog.objects.get(id=self.cluster_1.config.current) - self.assertEqual(changed_cluster_config.pk, self.current_cluster_1_config.pk) From 1a06a4aad7d45aa57c5df9a19d14e6e110f38cfa Mon Sep 17 00:00:00 2001 From: Egor Araslanov Date: Mon, 20 May 2024 12:21:03 +0500 Subject: [PATCH 106/208] ADCM-5564 Get rid of `reverse` in `test_actions.py` --- python/api_v2/tests/test_actions.py | 161 +++++++--------------------- 1 file changed, 38 insertions(+), 123 deletions(-) diff --git a/python/api_v2/tests/test_actions.py b/python/api_v2/tests/test_actions.py index f8948d9504..115a45ff76 100644 --- a/python/api_v2/tests/test_actions.py +++ b/python/api_v2/tests/test_actions.py @@ -28,7 +28,6 @@ ) from cm.services.job.jinja_scripts import get_action_info from cm.tests.mocks.task_runner import RunTaskMock -from django.urls import reverse from rbac.models import Role from rbac.services.group import create as create_group from rbac.services.policy import policy_create @@ -40,22 +39,6 @@ ObjectWithActions: TypeAlias = Cluster | ClusterObject | ServiceComponent | HostProvider | Host -def get_viewname_and_kwargs_for_object(object_: ObjectWithActions) -> tuple[str, dict[str, int]]: - if isinstance(object_, ClusterObject): - return "v2:service-action-list", {"service_pk": object_.pk, "cluster_pk": object_.cluster.pk} - - if isinstance(object_, ServiceComponent): - return "v2:component-action-list", { - "component_pk": object_.pk, - "service_pk": object_.service.pk, - "cluster_pk": object_.cluster.pk, - } - - classname: str = object_.__class__.__name__.lower() - # change hostp->p is for hostprovider->provider mutation for viewname - return f"v2:{classname.replace('hostp', 'p')}-action-list", {f"{classname}_pk": object_.pk} - - class TestActionsFiltering(BaseAPITestCase): def setUp(self) -> None: super().setUp() @@ -124,11 +107,7 @@ def test_upgrading_status_host_remove_fail(self) -> None: self.add_host_to_cluster(self.cluster_1, self.host_1) self.cluster_1.set_state("upgrading") - response = self.client.delete( - path=reverse( - viewname="v2:host-cluster-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.host_1.pk} - ), - ) + response = self.client.v2[self.cluster_1, "hosts", self.host_1].delete() self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertDictEqual( @@ -143,11 +122,7 @@ def test_upgrading_status_host_remove_fail(self) -> None: def test_upgrading_status_foreign_host_remove_fail(self) -> None: self.cluster_1.set_state("upgrading") - response = self.client.delete( - path=reverse( - viewname="v2:host-cluster-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.host_1.pk} - ), - ) + response = self.client.v2[self.cluster_1, "hosts", self.host_1].delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -159,9 +134,8 @@ def test_upgrading_status_service_remove_fail(self) -> None: ] self.cluster_1.save() - response = self.client.delete( - path=reverse(viewname="v2:service-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": service_1.pk}), - ) + response = self.client.v2[service_1].delete() + self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertDictEqual( response.json(), @@ -176,9 +150,8 @@ def test_upgrading_status_service_success(self) -> None: service_1 = self.add_services_to_cluster(service_names=["service_1"], cluster=self.cluster_1).get() self.cluster_1.set_state("upgrading") - response = self.client.delete( - path=reverse(viewname="v2:service-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": service_1.pk}), - ) + response = self.client.v2[service_1].delete() + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) def test_upgrading_status_foreign_service_remove_fail(self) -> None: @@ -187,63 +160,41 @@ def test_upgrading_status_foreign_service_remove_fail(self) -> None: service.prototype.name for service in ClusterObject.objects.filter(cluster=self.cluster_1) ] - response = self.client.delete( - path=reverse( - viewname="v2:service-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_1.pk} - ), - ) + response = self.client.v2[self.cluster_1, "services", self.service_1].delete() + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_filter_object_own_actions_success(self) -> None: for object_ in (self.cluster, self.service_1, self.component_1, self.hostprovider, self.host_1): - viewname, object_kwargs = get_viewname_and_kwargs_for_object(object_=object_) with self.subTest(msg=f"{object_.__class__.__name__} at different states"): - self.check_object_action_list( - viewname=viewname, object_kwargs=object_kwargs, expected_actions=self.available_at_created_no_multi - ) + self.check_object_action_list(object_=object_, expected_actions=self.available_at_created_no_multi) object_.set_multi_state(self.flag_multi_state) - self.check_object_action_list( - viewname=viewname, object_kwargs=object_kwargs, expected_actions=self.available_at_created_flag - ) + self.check_object_action_list(object_=object_, expected_actions=self.available_at_created_flag) object_.unset_multi_state(self.flag_multi_state) object_.set_multi_state(self.bag_multi_state) - self.check_object_action_list( - viewname=viewname, object_kwargs=object_kwargs, expected_actions=self.available_at_created_bag - ) + self.check_object_action_list(object_=object_, expected_actions=self.available_at_created_bag) object_.unset_multi_state(self.bag_multi_state) object_.set_state(self.installed_state) - self.check_object_action_list( - viewname=viewname, - object_kwargs=object_kwargs, - expected_actions=self.available_at_installed_no_multi, - ) + self.check_object_action_list(object_=object_, expected_actions=self.available_at_installed_no_multi) object_.set_multi_state(self.flag_multi_state) - self.check_object_action_list( - viewname=viewname, object_kwargs=object_kwargs, expected_actions=self.available_at_installed_flag - ) + self.check_object_action_list(object_=object_, expected_actions=self.available_at_installed_flag) object_.unset_multi_state(self.flag_multi_state) object_.set_multi_state(self.bag_multi_state) - self.check_object_action_list( - viewname=viewname, object_kwargs=object_kwargs, expected_actions=self.available_at_installed_bag - ) + self.check_object_action_list(object_=object_, expected_actions=self.available_at_installed_bag) def test_filter_host_actions_success(self) -> None: - check_host_1_actions = partial( - self.check_object_action_list, *get_viewname_and_kwargs_for_object(object_=self.host_1) - ) - check_host_2_actions = partial( - self.check_object_action_list, *get_viewname_and_kwargs_for_object(object_=self.host_2) - ) + check_host_1_actions = partial(self.check_object_action_list, object_=self.host_1) + check_host_2_actions = partial(self.check_object_action_list, object_=self.host_2) any_cluster = "from cluster any" any_all = (any_cluster, "from service any", "from component any") cluster_host_actions = ["cluster_host_action_allowed", "cluster_host_action_disallowed"] @@ -313,27 +264,21 @@ def test_filter_host_actions_success(self) -> None: def test_adcm_4516_disallowed_host_action_not_executable_success(self) -> None: self.add_host_to_cluster(self.cluster, self.host_1) disallowed_action = Action.objects.filter(display_name="cluster_host_action_disallowed").first() - check_host_1_actions = partial( - self.check_object_action_list, *get_viewname_and_kwargs_for_object(object_=self.host_1) - ) - check_host_1_actions( + self.check_object_action_list( + object_=self.host_1, expected_actions=[ *self.available_at_created_no_multi, "from cluster any", "cluster_host_action_allowed", "cluster_host_action_disallowed", - ] + ], ) self.host_1.maintenance_mode = MaintenanceMode.ON self.host_1.save(update_fields=["maintenance_mode"]) with RunTaskMock() as run_task: - response = self.client.post( - path=reverse( - viewname="v2:host-action-run", - kwargs={"host_pk": self.host_1.pk, "pk": disallowed_action.pk}, - ), + response = self.client.v2[self.host_1, "actions", disallowed_action, "run"].post( data={"hostComponentMap": [], "config": {}, "adcmMeta": {}, "isVerbose": False}, ) @@ -354,18 +299,14 @@ def test_adcm_4535_job_cant_be_terminated_success(self) -> None: allowed_action = Action.objects.filter(display_name="cluster_host_action_allowed").first() with RunTaskMock() as run_task: - response = self.client.post( - path=reverse( - viewname="v2:host-action-run", - kwargs={"host_pk": self.host_1.pk, "pk": allowed_action.pk}, - ), + response = self.client.v2[self.host_1, "actions", allowed_action, "run"].post( data={"hostComponentMap": [], "config": {}, "adcmMeta": {}, "isVerbose": False}, ) self.assertEqual(response.status_code, HTTP_200_OK) job = JobLog.objects.filter(task=run_task.target_task).first() - response = self.client.post(path=reverse(viewname="v2:joblog-terminate", kwargs={"pk": job.pk}), data={}) + response = self.client.v2[job, "terminate"].post(data={}) self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertDictEqual( @@ -382,11 +323,7 @@ def test_adcm_4856_action_with_non_existing_component_fail(self) -> None: allowed_action = Action.objects.filter(display_name="cluster_host_action_allowed").first() with RunTaskMock() as run_task: - response = self.client.post( - path=reverse( - viewname="v2:host-action-run", - kwargs={"host_pk": self.host_1.pk, "pk": allowed_action.pk}, - ), + response = self.client.v2[self.host_1, "actions", allowed_action, "run"].post( data={ "hostComponentMap": [{"hostId": self.host_1.pk, "componentId": 1000}], "config": {}, @@ -404,11 +341,7 @@ def test_adcm_4856_action_with_non_existing_host_fail(self) -> None: allowed_action = Action.objects.filter(display_name="cluster_host_action_allowed").first() with RunTaskMock() as run_task: - response = self.client.post( - path=reverse( - viewname="v2:host-action-run", - kwargs={"host_pk": self.host_1.pk, "pk": allowed_action.pk}, - ), + response = self.client.v2[self.host_1, "actions", allowed_action, "run"].post( data={ "hostComponentMap": [{"hostId": 1000, "componentId": self.component_1.pk}], "config": {}, @@ -426,11 +359,7 @@ def test_adcm_4856_action_with_duplicated_hc_success(self) -> None: allowed_action = Action.objects.filter(display_name="cluster_host_action_allowed").first() with RunTaskMock() as run_task: - response = self.client.post( - path=reverse( - viewname="v2:host-action-run", - kwargs={"host_pk": self.host_1.pk, "pk": allowed_action.pk}, - ), + response = self.client.v2[self.host_1, "actions", allowed_action, "run"].post( data={ "hostComponentMap": [ {"hostId": self.host_1.pk, "componentId": self.component_1.pk}, @@ -452,11 +381,7 @@ def test_adcm_4856_action_with_several_entries_hc_success(self) -> None: allowed_action = Action.objects.filter(display_name="cluster_host_action_allowed").first() with RunTaskMock() as run_task: - response = self.client.post( - path=reverse( - viewname="v2:host-action-run", - kwargs={"host_pk": self.host_1.pk, "pk": allowed_action.pk}, - ), + response = self.client.v2[self.host_1, "actions", allowed_action, "run"].post( data={ "hostComponentMap": [ {"hostId": self.host_1.pk, "componentId": self.component_1.pk}, @@ -509,20 +434,20 @@ def test_adcm_5348_action_not_allowed_on_any_cluster_failed(self): object=[self.cluster_1, self.cluster_2], ) - response = self.client.get( - path=reverse(viewname="v2:cluster-action-list", kwargs={"cluster_pk": cluster_as_cluster_one.pk}) - ) + response = self.client.v2[cluster_as_cluster_one, "actions"].get() + self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.data), 3) self.client.login(**test_user_credentials) - response = self.client.get( - path=reverse(viewname="v2:cluster-action-list", kwargs={"cluster_pk": cluster_as_cluster_one.pk}) - ) + response = self.client.v2[cluster_as_cluster_one, "actions"].get() + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) - def check_object_action_list(self, viewname: str, object_kwargs: dict, expected_actions: list[str]) -> None: - response = self.client.get(path=reverse(viewname=viewname, kwargs=object_kwargs)) + def check_object_action_list( + self, object_: Cluster | ClusterObject | ServiceComponent | HostProvider | Host, expected_actions: list[str] + ) -> None: + response = self.client.v2[object_, "actions"].get() self.assertEqual(response.status_code, HTTP_200_OK) @@ -547,9 +472,7 @@ def setUp(self) -> None: def test_retrieve_jinja_config(self): action = Action.objects.filter(name="check_state", prototype=self.cluster.prototype).first() - response = self.client.get( - path=reverse(viewname="v2:cluster-action-detail", kwargs={"cluster_pk": self.cluster.pk, "pk": action.pk}) - ) + response = self.client.v2[self.cluster, "actions", action].get() self.assertEqual(response.status_code, HTTP_200_OK) configuration = response.json()["configuration"] @@ -569,14 +492,11 @@ def test_retrieve_jinja_config(self): def test_adcm_4703_action_retrieve_returns_500(self) -> None: for object_ in (self.cluster, self.service_1, self.component_1): with self.subTest(object_.__class__.__name__): - viewname, kwargs = get_viewname_and_kwargs_for_object(object_) - response = self.client.get(path=reverse(viewname=viewname, kwargs=kwargs)) + response = self.client.v2[object_, "actions"].get() self.assertEqual(response.status_code, HTTP_200_OK) for action_id in map(itemgetter("id"), response.json()): - response = self.client.get( - path=reverse(viewname=viewname.replace("-list", "-detail"), kwargs={**kwargs, "pk": action_id}) - ) + response = self.client.v2[object_, "actions", action_id].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_get_action_info_success(self) -> None: @@ -596,12 +516,7 @@ def setUp(self) -> None: self.action_with_config = Action.objects.filter(name="with_config", prototype=self.cluster_1.prototype).first() def test_retrieve_with_config(self): - response = self.client.get( - path=reverse( - viewname="v2:cluster-action-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.action_with_config.pk}, - ) - ) + response = self.client.v2[self.cluster_1, "actions", self.action_with_config].get() self.assertEqual(response.status_code, HTTP_200_OK) configuration = response.json()["configuration"] From f081b18eb166921a35e5e08e91c6c431012d5a0b Mon Sep 17 00:00:00 2001 From: Egor Araslanov Date: Mon, 20 May 2024 15:58:14 +0500 Subject: [PATCH 107/208] ADCM-5566 Replace `reverse` in `test_bundle.py` with new client calls --- python/adcm/tests/client.py | 4 +- python/api_v2/tests/test_bundle.py | 95 +++++++----------------------- 2 files changed, 23 insertions(+), 76 deletions(-) diff --git a/python/adcm/tests/client.py b/python/adcm/tests/client.py index bbd5325ced..79b1841a4d 100644 --- a/python/adcm/tests/client.py +++ b/python/adcm/tests/client.py @@ -52,8 +52,8 @@ def path(self) -> str: def get(self, *, query: dict | None = None) -> Response: return self._client.get(path=self.path, data=query) - def post(self, *, data: dict | list[dict]) -> Response: - return self._client.post(path=self.path, data=data) + def post(self, *, data: dict | list[dict], format_: str | None = None) -> Response: + return self._client.post(path=self.path, data=data, format=format_) def patch(self, *, data: dict) -> Response: return self._client.patch(path=self.path, data=data) diff --git a/python/api_v2/tests/test_bundle.py b/python/api_v2/tests/test_bundle.py index c5df9792e4..363c64e949 100644 --- a/python/api_v2/tests/test_bundle.py +++ b/python/api_v2/tests/test_bundle.py @@ -12,7 +12,6 @@ from cm.models import Action, Bundle from django.conf import settings -from rest_framework.reverse import reverse from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, @@ -40,18 +39,14 @@ def setUp(self) -> None: self.same_names_bundle = self.add_bundle(source_dir=same_names_bundle_path) def test_list_success(self): - response = self.client.get(path=reverse(viewname="v2:bundle-list")) + response = (self.client.v2 / "bundles").get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 2) def test_upload_success(self): with open(settings.DOWNLOAD_DIR / self.new_bundle_file, encoding=settings.ENCODING_UTF_8) as f: - response = self.client.post( - path=reverse(viewname="v2:bundle-list"), - data={"file": f}, - format="multipart", - ) + response = (self.client.v2 / "bundles").post(data={"file": f}, format_="multipart") self.assertEqual(Bundle.objects.filter(name="cluster_two").exists(), True) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -59,71 +54,55 @@ def test_upload_success(self): def test_upload_duplicate_fail(self): with open(settings.DOWNLOAD_DIR / self.new_bundle_file, encoding=settings.ENCODING_UTF_8) as f: with open(settings.DOWNLOAD_DIR / self.new_bundle_file, encoding=settings.ENCODING_UTF_8) as f_duplicate: - self.client.post( - path=reverse(viewname="v2:bundle-list"), - data={"file": f}, - format="multipart", - ) - response = self.client.post( - path=reverse(viewname="v2:bundle-list"), - data={"file": f_duplicate}, - format="multipart", - ) + (self.client.v2 / "bundles").post(data={"file": f}, format_="multipart") + response = (self.client.v2 / "bundles").post(data={"file": f_duplicate}, format_="multipart") self.assertEqual(response.status_code, HTTP_409_CONFLICT) def test_upload_fail(self): with open(settings.DOWNLOAD_DIR / self.new_bundle_file, encoding=settings.ENCODING_UTF_8) as f: f.readlines() - response = self.client.post( - path=reverse(viewname="v2:bundle-list"), - data={"file": f}, - format="multipart", - ) + response = (self.client.v2 / "bundles").post(data={"file": f}, format_="multipart") self.assertEqual(Bundle.objects.filter(name="cluster_two").exists(), False) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) def test_retrieve_success(self): - response = self.client.get(path=reverse(viewname="v2:bundle-detail", kwargs={"pk": self.bundle_1.pk})) + response = self.client.v2[self.bundle_1].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["id"], self.bundle_1.pk) def test_retrieve_not_found_fail(self): - response = self.client.get( - path=reverse(viewname="v2:bundle-detail", kwargs={"pk": self.get_non_existent_pk(model=Bundle)}) - ) + response = (self.client.v2 / "bundles" / self.get_non_existent_pk(model=Bundle)).get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_delete_success(self): - response = self.client.delete(path=reverse(viewname="v2:bundle-detail", kwargs={"pk": self.bundle_1.pk})) + response = self.client.v2[self.bundle_1].delete() self.assertEqual(Bundle.objects.filter(pk=self.bundle_1.pk).exists(), False) self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) def test_delete_not_found_fail(self): - response = self.client.delete( - path=reverse(viewname="v2:bundle-detail", kwargs={"pk": self.get_non_existent_pk(model=Bundle)}) - ) + response = (self.client.v2 / "bundles" / self.get_non_existent_pk(model=Bundle)).delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_filter_by_display_name_success(self): - response = self.client.get(path=reverse(viewname="v2:bundle-list"), data={"displayName": "product"}) + response = (self.client.v2 / "bundles").get(query={"displayName": "product"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) def test_filter_by_product_success(self): - response = self.client.get(path=reverse(viewname="v2:bundle-list"), data={"product": "product"}) + response = (self.client.v2 / "bundles").get(query={"product": "product"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) def test_ordering_asc_success(self): - response = self.client.get(path=reverse(viewname="v2:bundle-list")) + response = (self.client.v2 / "bundles").get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertListEqual( @@ -132,7 +111,7 @@ def test_ordering_asc_success(self): ) def test_ordering_desc_success(self): - response = self.client.get(path=reverse(viewname="v2:bundle-list"), data={"ordering": "-displayName"}) + response = (self.client.v2 / "bundles").get(query={"ordering": "-displayName"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertListEqual( @@ -148,11 +127,7 @@ def test_upload_no_required_component_fail(self): ) with open(settings.DOWNLOAD_DIR / bundle_path, encoding=settings.ENCODING_UTF_8) as bundle_file: - response = self.client.post( - path=reverse(viewname="v2:bundle-list"), - data={"file": bundle_file}, - format="multipart", - ) + response = (self.client.v2 / "bundles").post(data={"file": bundle_file}, format_="multipart") self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertEqual(Bundle.objects.count(), initial_bundles_count) @@ -161,11 +136,7 @@ def test_upload_adcm_min_old_version_success(self): bundle_file = self.prepare_bundle_file(source_dir=self.test_bundles_dir / "adcm_min_version" / "old") with open(settings.DOWNLOAD_DIR / bundle_file, encoding=settings.ENCODING_UTF_8) as f: - response = self.client.post( - path=reverse(viewname="v2:bundle-list"), - data={"file": f}, - format="multipart", - ) + response = (self.client.v2 / "bundles").post(data={"file": f}, format_="multipart") self.assertEqual(Bundle.objects.filter(name="cluster_adcm_min_version").exists(), True) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -174,11 +145,7 @@ def test_upload_adcm_min_version_success(self): bundle_file = self.prepare_bundle_file(source_dir=self.test_bundles_dir / "adcm_min_version" / "new" / "older") with open(settings.DOWNLOAD_DIR / bundle_file, encoding=settings.ENCODING_UTF_8) as f: - response = self.client.post( - path=reverse(viewname="v2:bundle-list"), - data={"file": f}, - format="multipart", - ) + response = (self.client.v2 / "bundles").post(data={"file": f}, format_="multipart") self.assertEqual(Bundle.objects.filter(name="cluster_adcm_min_version").exists(), True) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -187,11 +154,7 @@ def test_upload_adcm_min_version_fail(self): bundle_file = self.prepare_bundle_file(source_dir=self.test_bundles_dir / "adcm_min_version" / "new" / "newer") with open(settings.DOWNLOAD_DIR / bundle_file, encoding=settings.ENCODING_UTF_8) as f: - response = self.client.post( - path=reverse(viewname="v2:bundle-list"), - data={"file": f}, - format="multipart", - ) + response = (self.client.v2 / "bundles").post(data={"file": f}, format_="multipart") self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertDictEqual( @@ -207,11 +170,7 @@ def test_upload_adcm_min_version_multiple_fail(self): bundle_file = self.prepare_bundle_file(source_dir=self.test_bundles_dir / "adcm_min_version" / "multiple") with open(settings.DOWNLOAD_DIR / bundle_file, encoding=settings.ENCODING_UTF_8) as f: - response = self.client.post( - path=reverse(viewname="v2:bundle-list"), - data={"file": f}, - format="multipart", - ) + response = (self.client.v2 / "bundles").post(data={"file": f}, format_="multipart") self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertDictEqual( @@ -229,11 +188,7 @@ def test_upload_plain_scripts_and_scripts_jinja_fail(self): ) with open(settings.DOWNLOAD_DIR / bundle_file, encoding=settings.ENCODING_UTF_8) as f: - response = self.client.post( - path=reverse(viewname="v2:bundle-list"), - data={"file": f}, - format="multipart", - ) + response = (self.client.v2 / "bundles").post(data={"file": f}, format_="multipart") self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertEqual(response.data["code"], "INVALID_OBJECT_DEFINITION") @@ -246,11 +201,7 @@ def test_upload_scripts_jinja_in_job_fail(self): ) with open(settings.DOWNLOAD_DIR / bundle_file, encoding=settings.ENCODING_UTF_8) as f: - response = self.client.post( - path=reverse(viewname="v2:bundle-list"), - data={"file": f}, - format="multipart", - ) + response = (self.client.v2 / "bundles").post(data={"file": f}, format_="multipart") self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertEqual(response.data["code"], "INVALID_OBJECT_DEFINITION") @@ -262,11 +213,7 @@ def test_upload_scripts_jinja_success(self): self.assertEqual(Action.objects.filter(scripts_jinja="").count(), Action.objects.count()) with open(settings.DOWNLOAD_DIR / bundle_file, encoding=settings.ENCODING_UTF_8) as f: - response = self.client.post( - path=reverse(viewname="v2:bundle-list"), - data={"file": f}, - format="multipart", - ) + response = (self.client.v2 / "bundles").post(data={"file": f}, format_="multipart") self.assertEqual(response.status_code, HTTP_201_CREATED) self.assertSetEqual(set(Action.objects.values_list("scripts_jinja", flat=True)), {"", "scripts.j2"}) From ae8c745ee068bd5c0bb1e4a950d184a5297d8ec2 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Tue, 21 May 2024 04:47:18 +0000 Subject: [PATCH 108/208] ADCM-5556 Fix path detection for paths in jinja-rendered fields --- python/cm/services/job/jinja_scripts.py | 11 ++-- .../bundles/bugs/ADCM-5556/outer/config.yaml | 11 ++++ .../bugs/ADCM-5556/outer/configs/config_1.j2 | 15 +++++ .../bugs/ADCM-5556/outer/configs/text.yaml | 13 ++++ .../bundles/bugs/ADCM-5556/outer/text.yaml | 13 ++++ python/cm/tests/bundles/cluster_1/config.yaml | 2 +- .../bundles/cluster_1/{ => jinja}/scripts.j2 | 8 ++- python/cm/tests/test_jinja_config.py | 61 +++++++++++++++++++ python/cm/tests/test_jinja_scripts.py | 16 ++++- python/jinja_config.py | 28 ++++++--- 10 files changed, 161 insertions(+), 17 deletions(-) create mode 100644 python/cm/tests/bundles/bugs/ADCM-5556/outer/config.yaml create mode 100644 python/cm/tests/bundles/bugs/ADCM-5556/outer/configs/config_1.j2 create mode 100644 python/cm/tests/bundles/bugs/ADCM-5556/outer/configs/text.yaml create mode 100644 python/cm/tests/bundles/bugs/ADCM-5556/outer/text.yaml rename python/cm/tests/bundles/cluster_1/{ => jinja}/scripts.j2 (63%) create mode 100644 python/cm/tests/test_jinja_config.py diff --git a/python/cm/services/job/jinja_scripts.py b/python/cm/services/job/jinja_scripts.py index c4acce1030..9bc8975766 100755 --- a/python/cm/services/job/jinja_scripts.py +++ b/python/cm/services/job/jinja_scripts.py @@ -27,7 +27,7 @@ ServiceComponent, TaskLog, ) -from cm.services.bundle import BundlePathResolver +from cm.services.bundle import BundlePathResolver, detect_relative_path_to_bundle_root from cm.services.cluster import retrieve_clusters_topology from cm.services.job.inventory import ( ClusterNode, @@ -95,9 +95,8 @@ def get_env(task: TaskLog, delta: dict | None = None) -> JinjaScriptsEnvironment def get_job_specs_from_template(task_id: TaskID, delta: dict | None) -> Generator[JobSpec, None, None]: task = TaskLog.objects.select_related("action", "action__prototype__bundle").get(pk=task_id) - scripts_jinja_file = BundlePathResolver(bundle_hash=task.action.prototype.bundle.hash).resolve( - task.action.scripts_jinja - ) + path_resolver = BundlePathResolver(bundle_hash=task.action.prototype.bundle.hash) + scripts_jinja_file = path_resolver.resolve(task.action.scripts_jinja) template_builder = TemplateBuilder( template_path=scripts_jinja_file, context=get_env(task=task, delta=delta), @@ -107,13 +106,15 @@ def get_job_specs_from_template(task_id: TaskID, delta: dict | None) -> Generato if not template_builder.data: raise RuntimeError(f'Template "{scripts_jinja_file}" has no jobs') + dir_with_jinja = scripts_jinja_file.parent.relative_to(path_resolver.bundle_root) + for job in template_builder.data: state_on_fail, multi_state_on_fail_set, multi_state_on_fail_unset = get_on_fail_states(config=job) yield JobSpec( name=job["name"], display_name=job.get("display_name", ""), - script=job["script"], + script=str(detect_relative_path_to_bundle_root(source_file_dir=dir_with_jinja, raw_path=job["script"])), script_type=job["script_type"], allow_to_terminate=job.get("allow_to_terminate", task.action.allow_to_terminate), state_on_fail=state_on_fail, diff --git a/python/cm/tests/bundles/bugs/ADCM-5556/outer/config.yaml b/python/cm/tests/bundles/bugs/ADCM-5556/outer/config.yaml new file mode 100644 index 0000000000..9c815b3b00 --- /dev/null +++ b/python/cm/tests/bundles/bugs/ADCM-5556/outer/config.yaml @@ -0,0 +1,11 @@ +- type: cluster + version: 3 + name: with_jinja_config + + actions: + with_j2_config: + type: job + script: action.yaml + script_type: ansible + masking: {} + config_jinja: outer/configs/config_1.j2 diff --git a/python/cm/tests/bundles/bugs/ADCM-5556/outer/configs/config_1.j2 b/python/cm/tests/bundles/bugs/ADCM-5556/outer/configs/config_1.j2 new file mode 100644 index 0000000000..7d4852bc04 --- /dev/null +++ b/python/cm/tests/bundles/bugs/ADCM-5556/outer/configs/config_1.j2 @@ -0,0 +1,15 @@ +- name: full + display_name: "Full" + type: structure + yspec: outer/text.yaml + required: False +- name: relative + type: structure + yspec: ./text.yaml + required: no +- name: fplain + type: file + default: outer/text.yaml +- name: fsec + type: secretfile + default: ./text.yaml diff --git a/python/cm/tests/bundles/bugs/ADCM-5556/outer/configs/text.yaml b/python/cm/tests/bundles/bugs/ADCM-5556/outer/configs/text.yaml new file mode 100644 index 0000000000..05d87a1bae --- /dev/null +++ b/python/cm/tests/bundles/bugs/ADCM-5556/outer/configs/text.yaml @@ -0,0 +1,13 @@ +--- +root: + match: dict_key_selection + selector: type + variants: + tutu: string + tata: integer + +string: + match: string + +integer: + match: int diff --git a/python/cm/tests/bundles/bugs/ADCM-5556/outer/text.yaml b/python/cm/tests/bundles/bugs/ADCM-5556/outer/text.yaml new file mode 100644 index 0000000000..d9f793cfaa --- /dev/null +++ b/python/cm/tests/bundles/bugs/ADCM-5556/outer/text.yaml @@ -0,0 +1,13 @@ +--- +root: + match: dict_key_selection + selector: type + variants: + string: string + integer: integer + +string: + match: string + +integer: + match: int diff --git a/python/cm/tests/bundles/cluster_1/config.yaml b/python/cm/tests/bundles/cluster_1/config.yaml index 3b91286a16..0dc6c69f53 100644 --- a/python/cm/tests/bundles/cluster_1/config.yaml +++ b/python/cm/tests/bundles/cluster_1/config.yaml @@ -58,7 +58,7 @@ action: remove jinja_scripts_action: type: task - scripts_jinja: "./scripts.j2" + scripts_jinja: "./jinja/scripts.j2" states: available: any unprocessable_jinja_scripts_action: diff --git a/python/cm/tests/bundles/cluster_1/scripts.j2 b/python/cm/tests/bundles/cluster_1/jinja/scripts.j2 similarity index 63% rename from python/cm/tests/bundles/cluster_1/scripts.j2 rename to python/cm/tests/bundles/cluster_1/jinja/scripts.j2 index 0d1d24ea5e..f098912bc5 100644 --- a/python/cm/tests/bundles/cluster_1/scripts.j2 +++ b/python/cm/tests/bundles/cluster_1/jinja/scripts.j2 @@ -8,5 +8,11 @@ script: playbook.yaml - name: job2 script_type: ansible - script: playbook.yaml + script: ./playbook.yaml {% endif %} +- name: job3 + script_type: ansible + script: inner/playbook.yaml +- name: job4 + script_type: ansible + script: ./inner/playbook.yaml diff --git a/python/cm/tests/test_jinja_config.py b/python/cm/tests/test_jinja_config.py new file mode 100644 index 0000000000..50c6145780 --- /dev/null +++ b/python/cm/tests/test_jinja_config.py @@ -0,0 +1,61 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path + +from adcm.tests.base import BaseTestCase, BusinessLogicMixin, TaskTestMixin +from jinja_config import get_jinja_config + +from cm.models import Action + + +class TestJinjaConfigBugs(BusinessLogicMixin, TaskTestMixin, BaseTestCase): + maxDiff = None + + def setUp(self) -> None: + super().setUp() + + self.bugs_bundle_dir = Path(__file__).parent / "bundles" / "bugs" + + def test_incorrect_path_bug_adcm_5556(self) -> None: + expected_full_limits = { + "root": { + "match": "dict_key_selection", + "selector": "type", + "variants": {"string": "string", "integer": "integer"}, + }, + "string": {"match": "string"}, + "integer": {"match": "int"}, + } + expected_relative_limits = { + **expected_full_limits, + "root": {**expected_full_limits["root"], "variants": {"tutu": "string", "tata": "integer"}}, + } + + bundle = self.add_bundle(self.bugs_bundle_dir / "ADCM-5556") + cluster = self.add_cluster(bundle=bundle, name="adcm-5556-cluster") + action = Action.objects.get(prototype=cluster.prototype, name="with_j2_config") + + config_prototypes = { + proto.name: {"limits": proto.limits, "default": str(proto.default)} + for proto in get_jinja_config(obj=cluster, action=action)[0] + } + + self.assertDictEqual( + config_prototypes, + { + "full": {"limits": {"yspec": expected_full_limits}, "default": ""}, + "relative": {"limits": {"yspec": expected_relative_limits}, "default": ""}, + "fplain": {"limits": {}, "default": "outer/text.yaml"}, + "fsec": {"limits": {}, "default": "outer/configs/text.yaml"}, + }, + ) diff --git a/python/cm/tests/test_jinja_scripts.py b/python/cm/tests/test_jinja_scripts.py index fc0c3480d9..17e82beb81 100644 --- a/python/cm/tests/test_jinja_scripts.py +++ b/python/cm/tests/test_jinja_scripts.py @@ -135,8 +135,18 @@ def setUp(self) -> None: def test_jobs_generation(self): task_id = self.prepare_task(owner=self.cluster, name="jinja_scripts_action").id - self.assertSetEqual( - set(JobLog.objects.filter(task_id=task_id).values_list("name", flat=True)), {"job1", "job2"} + self.assertListEqual( + list(JobLog.objects.filter(task_id=task_id).values_list("name", flat=True).order_by("id")), + ["job1", "job2", "job3", "job4"], + ) + self.assertEqual( + dict(JobLog.objects.filter(task_id=task_id).values_list("name", "script")), + { + "job1": "playbook.yaml", + "job2": "jinja/playbook.yaml", + "job3": "inner/playbook.yaml", + "job4": "jinja/inner/playbook.yaml", + }, ) self.set_hostcomponent(cluster=self.cluster, entries=((self.host, self.component),)) @@ -144,7 +154,7 @@ def test_jobs_generation(self): self.assertSetEqual( set(JobLog.objects.filter(task_id=task_id).values_list("name", flat=True)), - {"job_if_component_1_group_exists"}, + {"job_if_component_1_group_exists", "job3", "job4"}, ) def test_unprocessable_template(self): diff --git a/python/jinja_config.py b/python/jinja_config.py index 5a4ede7e16..3a83175eda 100644 --- a/python/jinja_config.py +++ b/python/jinja_config.py @@ -47,7 +47,7 @@ def _get_attr(config: dict) -> dict: return attr -def _get_limits(config: dict, root_path: Path) -> dict: +def _get_limits(config: dict, dir_with_config: Path, resolver: BundlePathResolver) -> dict: limits = {} if "pattern" in config: @@ -70,7 +70,8 @@ def _get_limits(config: dict, root_path: Path) -> dict: limits["pattern"] = pattern.raw if "yspec" in config and config["type"] in settings.STACK_COMPLEX_FIELD_TYPES: - limits["yspec"] = safe_load(stream=(root_path / config["yspec"]).read_text(encoding="utf-8")) + spec_path = detect_relative_path_to_bundle_root(source_file_dir=dir_with_config, raw_path=config["yspec"]) + limits["yspec"] = safe_load(stream=resolver.resolve(spec_path).read_text(encoding="utf-8")) if "option" in config and config["type"] == "option": limits["option"] = config["option"] @@ -114,7 +115,10 @@ def _get_limits(config: dict, root_path: Path) -> dict: return limits -def _normalize_config(config: dict, root_path: Path, name: str = "", subname: str = "") -> list[dict]: +def _normalize_config( + config: dict, dir_with_config: Path, resolver: BundlePathResolver, name: str = "", subname: str = "" +) -> list[dict]: + """`dir_with_config` should be relative to bundle root""" config_list = [config] name = name or config["name"] @@ -125,15 +129,23 @@ def _normalize_config(config: dict, root_path: Path, name: str = "", subname: st if config.get("display_name") is None: config["display_name"] = subname or name - config["limits"] = _get_limits(config=config, root_path=root_path) + config["limits"] = _get_limits(config=config, dir_with_config=dir_with_config, resolver=resolver) if config["type"] in settings.STACK_FILE_FIELD_TYPES and config.get("default"): - config["default"] = detect_relative_path_to_bundle_root(source_file_dir=root_path, raw_path=config["default"]) + config["default"] = detect_relative_path_to_bundle_root( + source_file_dir=dir_with_config, raw_path=config["default"] + ) if "subs" in config: for subconf in config["subs"]: config_list.extend( - _normalize_config(config=subconf, root_path=root_path, name=name, subname=subconf["name"]), + _normalize_config( + config=subconf, + dir_with_config=dir_with_config, + resolver=resolver, + name=name, + subname=subconf["name"], + ), ) for field in settings.TEMPLATE_CONFIG_DELETE_FIELDS: @@ -165,7 +177,9 @@ def get_jinja_config(action: Action, obj: ADCMEntity) -> tuple[list[PrototypeCon configs = [] attr = {} for config in template_builder.data: - for normalized_config in _normalize_config(config=config, root_path=jinja_conf_file.parent): + for normalized_config in _normalize_config( + config=config, dir_with_config=jinja_conf_file.parent.relative_to(resolver.bundle_root), resolver=resolver + ): configs.append(PrototypeConfig(prototype=action.prototype, action=action, **normalized_config)) attr.update(**_get_attr(config=normalized_config)) From 52a7472f57888fe03a238b356082b77a0d1b4a65 Mon Sep 17 00:00:00 2001 From: Skrynnik Daniil Date: Tue, 21 May 2024 16:47:29 +0300 Subject: [PATCH 109/208] ADCM-5572: refactor client path composition in api_v2/test_host.py --- python/api_v2/tests/test_host.py | 274 ++++++++----------------------- 1 file changed, 64 insertions(+), 210 deletions(-) diff --git a/python/api_v2/tests/test_host.py b/python/api_v2/tests/test_host.py index e7bf9f234e..b591fc2be5 100644 --- a/python/api_v2/tests/test_host.py +++ b/python/api_v2/tests/test_host.py @@ -13,7 +13,6 @@ from cm.models import Action, Host, HostComponent, HostProvider, ServiceComponent from cm.tests.mocks.task_runner import RunTaskMock from core.types import ADCMCoreType -from django.urls import reverse from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, @@ -38,17 +37,13 @@ def setUp(self) -> None: self.cluster_action = Action.objects.filter(prototype=self.cluster_1.prototype, host_action=True).first() def test_list_success(self): - response = self.client.get( - path=reverse(viewname="v2:host-list"), - ) + response = (self.client.v2 / "hosts").get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) def test_retrieve_success(self): - response = self.client.get( - path=reverse(viewname="v2:host-detail", kwargs={"pk": self.host.pk}), - ) + response = self.client.v2[self.host].get() data = { "id": self.host.pk, "name": "test_host", @@ -71,21 +66,15 @@ def test_retrieve_success(self): self.assertEqual(response.data["maintenance_mode"], data["maintenance_mode"]) def test_create_without_cluster_success(self): - response = self.client.post( - path=reverse(viewname="v2:host-list"), - data={ - "hostproviderId": self.provider.pk, - "name": "new-test-host", - }, - ) + response = (self.client.v2 / "hosts").post(data={"hostproviderId": self.provider.pk, "name": "new-test-host"}) self.assertEqual(response.status_code, HTTP_201_CREATED) - response = self.client.get( - path=reverse(viewname="v2:host-detail", kwargs={"pk": 2}), - ) + + host_pk = response.json()["id"] + response = (self.client.v2 / "hosts" / str(host_pk)).get() data = { - "id": 2, + "id": host_pk, "name": "new-test-host", "state": "created", "status": 32, @@ -104,17 +93,15 @@ def test_create_without_cluster_success(self): self.assertEqual(response.data["maintenance_mode"], data["maintenance_mode"]) def test_create_failed_wrong_provider(self): - response = self.client.post( - path=reverse(viewname="v2:host-list"), - data={"hostprovider_id": self.get_non_existent_pk(model=HostProvider), "name": "woohoo"}, + response = (self.client.v2 / "hosts").post( + data={"hostprovider_id": self.get_non_existent_pk(model=HostProvider), "name": "woohoo"} ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_create_with_cluster_success(self): - response = self.client.post( - path=reverse(viewname="v2:host-list"), - data={"hostprovider_id": self.provider.pk, "name": "new-test-host", "cluster_id": self.cluster_1.pk}, + response = (self.client.v2 / "hosts").post( + data={"hostprovider_id": self.provider.pk, "name": "new-test-host", "cluster_id": self.cluster_1.pk} ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -122,12 +109,11 @@ def test_create_with_cluster_success(self): self.assertEqual(host_2.cluster, self.cluster_1) def test_fqdn_validation_create_failed(self): - response = self.client.post( - path=reverse(viewname="v2:host-list"), + response = (self.client.v2 / "hosts").post( data={ "hostproviderId": self.provider.pk, "name": "new_test_host", - }, + } ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -135,10 +121,7 @@ def test_fqdn_validation_create_failed(self): def test_update_name_success(self): new_test_host_fqdn = "new-fqdn" - response = self.client.patch( - path=reverse(viewname="v2:host-detail", kwargs={"pk": self.host.pk}), - data={"name": new_test_host_fqdn}, - ) + response = self.client.v2[self.host].patch(data={"name": new_test_host_fqdn}) self.assertEqual(response.status_code, HTTP_200_OK) self.host.refresh_from_db() @@ -147,10 +130,7 @@ def test_update_name_success(self): def test_update_name_fail(self): new_host = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="new_host") - response = self.client.patch( - path=reverse(viewname="v2:host-detail", kwargs={"pk": self.host.pk}), - data={"name": new_host.name}, - ) + response = self.client.v2[self.host].patch(data={"name": new_host.name}) self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertDictEqual( response.json(), @@ -159,21 +139,13 @@ def test_update_name_fail(self): def test_update_name_locking_concern_fail(self): with RunTaskMock(): - response = self.client.post( - path=reverse( - "v2:host-action-run", - kwargs={ - "host_pk": self.host.pk, - "pk": self.host_action.pk, - }, - ), - data={"hostComponentMap": [], "config": {}, "adcmMeta": {}, "isVerbose": False}, + response = self.client.v2[self.host, "actions", self.host_action, "run"].post( + data={"hostComponentMap": [], "config": {}, "adcmMeta": {}, "isVerbose": False} ) self.assertEqual(response.status_code, HTTP_200_OK) - response = self.client.patch( - path=reverse(viewname="v2:host-detail", kwargs={"pk": self.host.pk}), + response = self.client.v2[self.host].patch( data={"name": "new-name"}, ) self.assertEqual(response.status_code, HTTP_409_CONFLICT) @@ -190,24 +162,13 @@ def test_update_name_locking_concern_from_cluster_fail(self): self.add_host_to_cluster(self.cluster_1, self.host) with RunTaskMock(): - response = self.client.post( - path=reverse( - "v2:host-cluster-action-run", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "host_pk": self.host.pk, - "pk": self.cluster_action.pk, - }, - ), - data={"hostComponentMap": [], "config": {}, "adcmMeta": {}, "isVerbose": False}, + response = self.client.v2[self.cluster_1, "hosts", self.host, "actions", self.cluster_action, "run"].post( + data={"hostComponentMap": [], "config": {}, "adcmMeta": {}, "isVerbose": False} ) self.assertEqual(response.status_code, HTTP_200_OK) - response = self.client.patch( - path=reverse(viewname="v2:host-detail", kwargs={"pk": self.host.pk}), - data={"name": "new-name"}, - ) + response = self.client.v2[self.host].patch(data={"name": "new-name"}) self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertDictEqual( response.json(), @@ -222,10 +183,7 @@ def test_update_name_state_not_create_fail(self): self.host.state = "running" self.host.save(update_fields=["state"]) - response = self.client.patch( - path=reverse(viewname="v2:host-detail", kwargs={"pk": self.host.pk}), - data={"name": "new-fqdn"}, - ) + response = self.client.v2[self.host].patch(data={"name": "new-fqdn"}) self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertDictEqual( response.json(), @@ -239,10 +197,7 @@ def test_update_name_state_not_create_fail(self): def test_update_name_bound_to_cluster_fail(self): self.add_host_to_cluster(cluster=self.cluster_1, host=self.host) - response = self.client.patch( - path=reverse(viewname="v2:host-detail", kwargs={"pk": self.host.pk}), - data={"name": "new-fqdn"}, - ) + response = self.client.v2[self.host].patch(data={"name": "new-fqdn"}) self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertDictEqual( response.json(), @@ -254,25 +209,19 @@ def test_update_name_bound_to_cluster_fail(self): ) def test_delete_success(self): - response = self.client.delete(path=reverse(viewname="v2:host-detail", kwargs={"pk": self.host.pk})) + response = self.client.v2[self.host].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.assertFalse(Host.objects.filter(pk=self.host.pk).exists()) def test_maintenance_mode(self): - response = self.client.post( - path=reverse(viewname="v2:host-maintenance-mode", kwargs={"pk": self.host.pk}), - data={"maintenanceMode": "on"}, - ) + response = self.client.v2[self.host, "maintenance-mode"].post(data={"maintenanceMode": "on"}) self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertEqual(response.data["code"], "MAINTENANCE_MODE_NOT_AVAILABLE") self.add_host_to_cluster(cluster=self.cluster_1, host=self.host) - response = self.client.post( - path=reverse(viewname="v2:host-maintenance-mode", kwargs={"pk": self.host.pk}), - data={"maintenanceMode": "on"}, - ) + response = self.client.v2[self.host, "maintenance-mode"].post(data={"maintenanceMode": "on"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.data["maintenance_mode"], "on") @@ -285,10 +234,7 @@ def test_filter_is_host_in_cluster_success(self): cluster=self.cluster_1, ) - response = self.client.get( - path=reverse(viewname="v2:host-list"), - data={"isInCluster": True}, - ) + response = (self.client.v2 / "hosts").get(query={"isInCluster": True}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) @@ -301,10 +247,7 @@ def test_filter_is_host_not_in_cluster_success(self): ) self.add_host_to_cluster(self.cluster_1, host=host2) - response = self.client.get( - path=reverse(viewname="v2:host-list"), - data={"isInCluster": False}, - ) + response = (self.client.v2 / "hosts").get(query={"isInCluster": False}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) @@ -317,10 +260,7 @@ def test_hostprovider_filter(self): bundle=self.provider_bundle, description="description", provider=second_provider, fqdn="test_host_2" ) - response = self.client.get( - path=reverse(viewname="v2:host-list"), - data={"hostproviderName": second_provider.name}, - ) + response = (self.client.v2 / "hosts").get(query={"hostproviderName": second_provider.name}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) @@ -330,9 +270,7 @@ def test_ordering_by_default_success(self): self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_5") self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_2") - response = self.client.get( - path=reverse(viewname="v2:host-list"), - ) + response = (self.client.v2 / "hosts").get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 3) @@ -345,7 +283,7 @@ def test_ordering_by_id_desc_success(self): host_2 = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_6") host_3 = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_3") - response = self.client.get(path=reverse(viewname="v2:host-list"), data={"ordering": "-id"}) + response = (self.client.v2 / "hosts").get(query={"ordering": "-id"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 3) @@ -383,29 +321,20 @@ def check_control_hosts(self) -> None: def test_list_success(self): self.add_host_to_cluster(cluster=self.cluster_1, host=self.host) - response = self.client.get( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), - ) + response = self.client.v2[self.cluster_1, "hosts"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 2) def test_retrieve_success(self): self.add_host_to_cluster(cluster=self.cluster_1, host=self.host) - response = self.client.get( - path=reverse( - viewname="v2:host-cluster-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.host.pk} - ), - ) + response = self.client.v2[self.cluster_1, "hosts", self.host].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["id"], self.host.pk) def test_create_success(self): - response = self.client.post( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), - data={"hostId": self.host.pk}, - ) + response = self.client.v2[self.cluster_1, "hosts"].post(data={"hostId": self.host.pk}) self.assertEqual(response.status_code, HTTP_201_CREATED) self.host.refresh_from_db() @@ -416,10 +345,7 @@ def test_create_success(self): def test_create_belonging_to_another_cluster_fail(self): self.add_host_to_cluster(cluster=self.cluster_2, host=self.host) - response = self.client.post( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), - data={"hostId": self.host.pk}, - ) + response = self.client.v2[self.cluster_1, "hosts"].post(data={"hostId": self.host.pk}) self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertDictEqual( @@ -436,10 +362,7 @@ def test_create_belonging_to_another_cluster_fail(self): def test_create_already_added_fail(self) -> None: self.add_host_to_cluster(cluster=self.cluster_1, host=self.host) - response = self.client.post( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), - data={"hostId": self.host.pk}, - ) + response = self.client.v2[self.cluster_1, "hosts"].post(data={"hostId": self.host.pk}) self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertDictEqual( @@ -454,10 +377,7 @@ def test_create_already_added_fail(self) -> None: self.check_control_hosts() def test_create_not_found_fail(self): - response = self.client.post( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), - data={"hostId": self.get_non_existent_pk(model=Host)}, - ) + response = self.client.v2[self.cluster_1, "hosts"].post(data={"hostId": self.get_non_existent_pk(model=Host)}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -468,9 +388,8 @@ def test_create_not_found_fail(self): self.check_control_hosts() def test_add_many_success(self): - response = self.client.post( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), - data=[{"hostId": self.host.pk}, {"hostId": self.host_2.pk}], + response = self.client.v2[self.cluster_1, "hosts"].post( + data=[{"hostId": self.host.pk}, {"hostId": self.host_2.pk}] ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -489,9 +408,8 @@ def test_add_many_success(self): def test_add_many_when_one_belongs_to_another_cluster_fail(self): self.add_host_to_cluster(cluster=self.cluster_2, host=self.host) - response = self.client.post( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), - data=[{"hostId": self.host_2.pk}, {"hostId": self.host.pk}], + response = self.client.v2[self.cluster_1, "hosts"].post( + data=[{"hostId": self.host_2.pk}, {"hostId": self.host.pk}] ) self.assertEqual(response.status_code, HTTP_409_CONFLICT) @@ -515,9 +433,8 @@ def test_add_many_when_one_belongs_to_another_cluster_fail(self): def test_add_many_when_one_is_already_added_fail(self) -> None: self.add_host_to_cluster(cluster=self.cluster_1, host=self.host) - response = self.client.post( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), - data=[{"hostId": self.host_2.pk}, {"hostId": self.host.pk}], + response = self.client.v2[self.cluster_1, "hosts"].post( + data=[{"hostId": self.host_2.pk}, {"hostId": self.host.pk}] ) self.assertEqual(response.status_code, HTTP_409_CONFLICT) @@ -539,8 +456,7 @@ def test_add_many_when_one_is_already_added_fail(self) -> None: self.check_control_hosts() def test_add_many_when_one_is_not_found_fail(self): - response = self.client.post( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), + response = self.client.v2[self.cluster_1, "hosts"].post( data=[ {"hostId": self.host_2.pk}, {"hostId": self.host.pk}, @@ -564,11 +480,7 @@ def test_add_many_when_one_is_not_found_fail(self): def test_maintenance_mode(self): self.add_host_to_cluster(cluster=self.cluster_1, host=self.host) - response = self.client.post( - path=reverse( - viewname="v2:host-cluster-maintenance-mode", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.host.pk}, - ), + response = self.client.v2[self.cluster_1, "hosts", self.host, "maintenance-mode"].post( data={"maintenanceMode": "on"}, ) @@ -579,9 +491,7 @@ def test_ordering_by_default_success(self): self.add_host_to_cluster(cluster=self.cluster_1, host=self.host) self.add_host_to_cluster(cluster=self.cluster_1, host=self.host_2) - response = self.client.get( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), - ) + response = self.client.v2[self.cluster_1, "hosts"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 3) @@ -594,10 +504,7 @@ def test_ordering_by_id_desc_success(self): self.add_host_to_cluster(cluster=self.cluster_1, host=self.host) self.add_host_to_cluster(cluster=self.cluster_1, host=self.host_2) - response = self.client.get( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), - data={"ordering": "-id"}, - ) + response = self.client.v2[self.cluster_1, "hosts"].get(query={"ordering": "-id"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 3) @@ -619,43 +526,21 @@ def setUp(self) -> None: self.component_1 = ServiceComponent.objects.get(prototype__name="component_1", service=self.service_1) def test_host_cluster_list_success(self): - response = self.client.get( - path=reverse( - "v2:host-cluster-action-list", - kwargs={"cluster_pk": self.cluster_1.pk, "host_pk": self.host.pk}, - ), - ) + response = self.client.v2[self.cluster_1, "hosts", self.host, "actions"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()), 2) def test_host_cluster_retrieve_success(self): - response = self.client.get( - path=reverse( - "v2:host-cluster-action-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "host_pk": self.host.pk, - "pk": self.action.pk, - }, - ), - ) + response = self.client.v2[self.cluster_1, "hosts", self.host, "actions", self.action].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertTrue(response.json()) def test_host_cluster_run_success(self): with RunTaskMock() as run_task: - response = self.client.post( - path=reverse( - "v2:host-cluster-action-run", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "host_pk": self.host.pk, - "pk": self.action.pk, - }, - ), - data={"hostComponentMap": [], "config": {}, "adcmMeta": {}, "isVerbose": False}, + response = self.client.v2[self.cluster_1, "hosts", self.host, "actions", self.action, "run"].post( + data={"hostComponentMap": [], "config": {}, "adcmMeta": {}, "isVerbose": False} ) self.assertEqual(response.status_code, HTTP_200_OK) @@ -670,26 +555,21 @@ def test_host_cluster_run_success(self): self.assertEqual(run_task.target_task.status, "success") def test_host_list_success(self): - response = self.client.get( - path=reverse("v2:host-action-list", kwargs={"host_pk": self.host.pk}), - ) + response = self.client.v2[self.host, "actions"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()), 2) def test_host_retrieve_success(self): - response = self.client.get( - path=reverse("v2:host-action-detail", kwargs={"host_pk": self.host.pk, "pk": self.action.pk}), - ) + response = self.client.v2[self.host, "actions", self.action].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertTrue(response.json()) def test_host_run_success(self): with RunTaskMock() as run_task: - response = self.client.post( - path=reverse("v2:host-action-run", kwargs={"host_pk": self.host.pk, "pk": self.action.pk}), - data={"hostComponentMap": [], "config": {}, "adcmMeta": {}, "isVerbose": False}, + response = self.client.v2[self.host, "actions", self.action, "run"].post( + data={"hostComponentMap": [], "config": {}, "adcmMeta": {}, "isVerbose": False} ) self.assertEqual(response.status_code, HTTP_200_OK) @@ -707,12 +587,7 @@ def test_host_mapped_list_success(self) -> None: HostComponent.objects.create( cluster=self.cluster_1, service=self.service_1, component=self.component_1, host=self.host ) - response = self.client.get( - path=reverse( - "v2:host-action-list", - kwargs={"host_pk": self.host.pk}, - ), - ) + response = self.client.v2[self.host, "actions"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()), 4) @@ -722,12 +597,7 @@ def test_host_mapped_retrieve_success(self) -> None: cluster=self.cluster_1, service=self.service_1, component=self.component_1, host=self.host ) action = Action.objects.filter(prototype=self.service_1.prototype, host_action=True).first() - response = self.client.get( - path=reverse( - "v2:host-action-detail", - kwargs={"host_pk": self.host.pk, "pk": action.pk}, - ), - ) + response = self.client.v2[self.host, "actions", action].get() self.assertEqual(response.status_code, HTTP_200_OK) @@ -737,10 +607,7 @@ def test_filter_is_host_own_action_true_success(self): ) host_action = Action.objects.filter(name="host_action", prototype=self.host.prototype).first() - response = self.client.get( - path=reverse(viewname="v2:host-action-list", kwargs={"host_pk": self.host.pk}), - data={"isHostOwnAction": True}, - ) + response = self.client.v2[self.host, "actions"].get(query={"isHostOwnAction": True}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertListEqual( @@ -765,10 +632,7 @@ def test_filter_is_host_own_action_false_success(self): name="component_on_host", prototype=self.component_1.prototype ).first() - response = self.client.get( - path=reverse(viewname="v2:host-action-list", kwargs={"host_pk": self.host.pk}), - data={"isHostOwnAction": False}, - ) + response = self.client.v2[self.host, "actions"].get(query={"isHostOwnAction": False}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertListEqual( @@ -803,9 +667,8 @@ def test_filter_is_host_own_action_false_component_success(self): name="component_on_host", prototype=self.component_1.prototype ).first() - response = self.client.get( - path=reverse(viewname="v2:host-action-list", kwargs={"host_pk": self.host.pk}), - data={"isHostOwnAction": False, "prototypeId": self.component_1.prototype.pk}, + response = self.client.v2[self.host, "actions"].get( + query={"isHostOwnAction": False, "prototypeId": self.component_1.prototype.pk} ) self.assertEqual(response.status_code, HTTP_200_OK) @@ -864,12 +727,7 @@ def setUp(self) -> None: ) def test_components_success(self): - response = self.client.get( - path=reverse( - viewname="v2:host-cluster-component-list", - kwargs={"cluster_pk": self.cluster_1.pk, "host_pk": self.host_1.pk}, - ) - ) + response = self.client.v2[self.cluster_1, "hosts", self.host_1, "components"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 2) self.assertSetEqual( @@ -892,12 +750,8 @@ def test_components_success(self): ) def test_ordering_by_display_name_reverse_success(self): - response = self.client.get( - path=reverse( - viewname="v2:host-cluster-component-list", - kwargs={"cluster_pk": self.cluster_1.pk, "host_pk": self.host_1.pk}, - ), - data={"ordering": "-displayName"}, + response = self.client.v2[self.cluster_1, "hosts", self.host_1, "components"].get( + query={"ordering": "-displayName"} ) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 2) From 43a96e7d01393cb41b735061d53c2730a8c8745b Mon Sep 17 00:00:00 2001 From: Vladislav Paskov Date: Wed, 22 May 2024 08:55:30 +0000 Subject: [PATCH 110/208] ADCM-5551: Imports navigation buttons https://tracker.yandex.ru/ADCM-5551 --- .../ClusterImportsService.module.scss | 3 +++ .../ClusterImportServices/ClusterImportsService.tsx | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImportServices/ClusterImportsService.module.scss diff --git a/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImportServices/ClusterImportsService.module.scss b/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImportServices/ClusterImportsService.module.scss new file mode 100644 index 0000000000..cc44ae7f97 --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImportServices/ClusterImportsService.module.scss @@ -0,0 +1,3 @@ +.clusterImportToolbarWrapper { + margin-top: 32px; +} diff --git a/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImportServices/ClusterImportsService.tsx b/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImportServices/ClusterImportsService.tsx index e2f47d81b8..af8ece4fe2 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImportServices/ClusterImportsService.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImportServices/ClusterImportsService.tsx @@ -9,6 +9,7 @@ import { useDispatch, useStore } from '@hooks'; import { useEffect } from 'react'; import { setBreadcrumbs } from '@store/adcm/breadcrumbs/breadcrumbsSlice'; import PermissionsChecker from '@commonComponents/PermissionsChecker/PermissionsChecker'; +import s from './ClusterImportsService.module.scss'; const ClusterImportsService = () => { const { @@ -40,7 +41,7 @@ const ClusterImportsService = () => { setBreadcrumbs([ { href: '/clusters', label: 'Clusters' }, { href: `/clusters/${cluster.id}`, label: cluster.name }, - { label: 'Import' }, + { href: `/clusters/${cluster.id}/import`, label: 'Import' }, { label: 'Services' }, ]), ); @@ -48,7 +49,7 @@ const ClusterImportsService = () => { }, [cluster, dispatch]); return ( - <> +
{ ))} - +
); }; From 2397bdc1c6651d21af73f0de10a77a5bb453d8df Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Wed, 22 May 2024 09:35:06 +0000 Subject: [PATCH 111/208] ADCM-5434 Change ansible runtime `vars` validation and rework `adcm_hc` plugin --- python/adcm/tests/ansible.py | 20 +- python/ansible/plugins/action/adcm_hc.py | 49 +--- python/ansible_plugin/base.py | 85 +++++-- python/ansible_plugin/executors/add_host.py | 17 +- .../executors/add_host_to_cluster.py | 12 +- .../ansible_plugin/executors/change_flag.py | 13 +- python/ansible_plugin/executors/config.py | 15 +- .../ansible_plugin/executors/delete_host.py | 14 +- .../executors/delete_service.py | 16 +- .../ansible_plugin/executors/hostcomponent.py | 124 +++++++++ .../executors/remove_host_from_cluster.py | 13 +- .../tests/bundles/cluster/config.yaml | 8 +- python/ansible_plugin/tests/test_adcm_hc.py | 239 ++++++++++++++++++ .../tests/test_targets_extraction.py | 5 +- python/ansible_plugin/utils.py | 42 --- python/cm/api.py | 12 +- .../test_task_runner/test_plugin_effects.py | 21 +- 17 files changed, 502 insertions(+), 203 deletions(-) create mode 100644 python/ansible_plugin/executors/hostcomponent.py create mode 100644 python/ansible_plugin/tests/test_adcm_hc.py diff --git a/python/adcm/tests/ansible.py b/python/adcm/tests/ansible.py index a960926f95..549d487c73 100644 --- a/python/adcm/tests/ansible.py +++ b/python/adcm/tests/ansible.py @@ -15,10 +15,10 @@ from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, - AnsibleJobContext, CallArguments, CallResult, PluginExecutorConfig, + RuntimeEnvironment, ) from cm.models import JobLog from cm.services.job.run._target_factories import prepare_ansible_job_config @@ -64,12 +64,11 @@ def prepare_executor( job_id = call_context if isinstance(call_context, int) else call_context.id task_id = JobLog.objects.values_list("task_id", flat=True).get(id=job_id) - job_ansible_config = prepare_ansible_job_config( + context = prepare_ansible_job_config( task=JobRepoImpl.get_task(id=task_id), job=JobRepoImpl.get_job(id=job_id), configuration=configuration ) - context = job_ansible_config["context"] - return executor_type(arguments=arguments, context=context) + return executor_type(arguments=arguments, runtime_vars=context) def build_executor_call( self, @@ -90,8 +89,7 @@ def _executor_func(executor: JobExecutor) -> int: class PassedArguments(NamedTuple): targets: Collection[CoreObjectDescriptor] arguments: CallArguments - context_owner: CoreObjectDescriptor - context: AnsibleJobContext + runtime: RuntimeEnvironment def DummyExecutor( # noqa: N802 @@ -101,16 +99,10 @@ class DummyExecutorWithConfig(ADCMAnsiblePluginExecutor): _config: PluginExecutorConfig[CallArguments] = config def __call__( - self, - targets: Collection[CoreObjectDescriptor], - arguments: CallArguments, - context_owner: CoreObjectDescriptor, - context: AnsibleJobContext, + self, targets: Collection[CoreObjectDescriptor], arguments: CallArguments, runtime: RuntimeEnvironment ) -> CallResult[PassedArguments]: return CallResult( - value=PassedArguments( - targets=targets, arguments=arguments, context_owner=context_owner, context=context - ), + value=PassedArguments(targets=targets, arguments=arguments, runtime=runtime), changed=True, error=None, ) diff --git a/python/ansible/plugins/action/adcm_hc.py b/python/ansible/plugins/action/adcm_hc.py index 1824f903fc..ed5cea08c4 100644 --- a/python/ansible/plugins/action/adcm_hc.py +++ b/python/ansible/plugins/action/adcm_hc.py @@ -20,7 +20,8 @@ short_description: change host component map (hc) for cluster description: - The C(adcm_hc) module is intended to change host component map. - This module should be run in cluster or service context. Cluster Id is taken from context. + This module should be run in cluster, service or component context. + Cluster's ID is taken from context. options: """ @@ -38,55 +39,19 @@ service: "hadoop" component: node host: "h1.company.com" - """ -RETURN = r""" -""" +RETURN = "" import sys -from ansible.errors import AnsibleError -from ansible.plugins.action import ActionBase - sys.path.append("/adcm/python") import adcm.init_django # noqa: F401, isort:skip -from ansible_plugin.utils import change_hc, get_object_id_from_context -from cm.errors import AdcmEx -from cm.logger import logger - - -class ActionModule(ActionBase): - TRANSFERS_FILES = False - _VALID_ARGS = frozenset(("operations",)) - _VALID_SUB_ARGS = frozenset(("action", "service", "component", "host")) - - def run(self, tmp=None, task_vars=None): - super().run(tmp, task_vars) - msg = "You can modify hc only in cluster, service or component context" - cluster_id, _ = get_object_id_from_context( - task_vars=task_vars, id_type="cluster_id", context_types=("cluster", "service", "component"), err_msg=msg - ) - job_id = task_vars["job"]["id"] - operations = self._task.args["operations"] - - logger.info("ansible module adcm_hc: cluster #%s, ops: %s", cluster_id, operations) - - if not isinstance(operations, list): - raise AnsibleError(f"Operations should be an array: {operations}") - - for operation in operations: - if not isinstance(operation, dict): - raise AnsibleError(f"Operation items should be a dictionary: {operation}") - args = frozenset(operation.keys()) - if args.difference(self._VALID_SUB_ARGS): - raise AnsibleError(f"Invalid operation arguments: {operation}") +from ansible_plugin.base import ADCMAnsiblePlugin +from ansible_plugin.executors.hostcomponent import ADCMHostComponentPluginExecutor - try: - change_hc(job_id, cluster_id, operations) - except AdcmEx as e: - raise AnsibleError(e.code + ": " + e.msg) from e - return {"failed": False, "changed": True} +class ActionModule(ADCMAnsiblePlugin): + executor_class = ADCMHostComponentPluginExecutor diff --git a/python/ansible_plugin/base.py b/python/ansible_plugin/base.py index 2dce318f8b..616d2517e7 100644 --- a/python/ansible_plugin/base.py +++ b/python/ansible_plugin/base.py @@ -19,7 +19,7 @@ from ansible.module_utils._text import to_native from ansible.plugins.action import ActionBase from cm.models import ClusterObject, ServiceComponent -from core.types import ADCMCoreType, CoreObjectDescriptor +from core.types import ADCMCoreType, CoreObjectDescriptor, ObjectID from django.conf import settings from pydantic import BaseModel, ValidationError, field_validator @@ -37,8 +37,11 @@ TargetTypeLiteral = Literal["cluster", "service", "component", "provider", "host"] -class AnsibleJobContext(BaseModel): - """Context from `config.json`'s `context` section""" +class VarsContextSection(BaseModel): + """ + Context from `config.json`'s `context` section + (actually from "vars" available during ansible run) + """ type: TargetTypeLiteral cluster_id: int | None = None @@ -54,12 +57,39 @@ def convert_type_to_string(cls, v: Any) -> str: return str(v) +class VarsJobSection(BaseModel): + """ + Job related info from ansible runtime vars' "job" dictionary + """ + + id: ObjectID + action: str + + +class AnsibleRuntimeVars(BaseModel): + """ + Container for ansible runtime variables required for plugin execution + """ + + context: VarsContextSection + job: VarsJobSection + + +class RuntimeEnvironment(BaseModel): + """ + Container for things dependent on ansible runtime + """ + + context_owner: CoreObjectDescriptor + vars: AnsibleRuntimeVars + + # Target class TargetDetector(Protocol): def __call__( - self, context_owner: CoreObjectDescriptor, context: AnsibleJobContext, raw_arguments: dict + self, context_owner: CoreObjectDescriptor, context: VarsContextSection, raw_arguments: dict ) -> tuple[CoreObjectDescriptor, ...]: ... @@ -80,7 +110,7 @@ def convert_type_to_string(cls, v: Any) -> str: def from_objects( context_owner: CoreObjectDescriptor, # noqa: ARG001 - context: AnsibleJobContext, + context: VarsContextSection, raw_arguments: dict, ) -> tuple[CoreObjectDescriptor, ...]: if not isinstance(objects := raw_arguments.get("objects"), list): @@ -94,7 +124,7 @@ def from_objects( def from_arguments_root( context_owner: CoreObjectDescriptor, # noqa: ARG001 - context: AnsibleJobContext, + context: VarsContextSection, raw_arguments: dict, ) -> tuple[CoreObjectDescriptor, ...]: try: @@ -107,14 +137,14 @@ def from_arguments_root( def from_context( context_owner: CoreObjectDescriptor, - context: AnsibleJobContext, # noqa: ARG001 + context: VarsContextSection, # noqa: ARG001 raw_arguments: dict, # noqa: ARG001 ) -> tuple[CoreObjectDescriptor, ...]: return (context_owner,) def _from_target_description( - target_description: CoreObjectTargetDescription, context: AnsibleJobContext + target_description: CoreObjectTargetDescription, context: VarsContextSection ) -> CoreObjectDescriptor: match target_description.type: case "cluster": @@ -230,7 +260,7 @@ class ArgumentsConfig(Generic[CallArguments]): class TargetValidator(Protocol): def __call__( - self, context_owner: CoreObjectDescriptor, context: AnsibleJobContext, raw_arguments: dict + self, context_owner: CoreObjectDescriptor, context: VarsContextSection, raw_arguments: dict ) -> PluginValidationError | None: ... @@ -259,7 +289,9 @@ class TargetConfig: class ContextValidator(Protocol): - def __call__(self, context_owner: CoreObjectDescriptor, context: AnsibleJobContext) -> PluginValidationError | None: + def __call__( + self, context_owner: CoreObjectDescriptor, context: VarsContextSection + ) -> PluginValidationError | None: ... @@ -299,17 +331,13 @@ class ADCMAnsiblePluginExecutor(Generic[CallArguments, ReturnValue]): _config: PluginExecutorConfig[CallArguments] - def __init__(self, arguments: dict, context: dict): + def __init__(self, arguments: dict, runtime_vars: dict): self._raw_arguments = arguments - self._raw_context = context + self._raw_vars = runtime_vars @abstractmethod def __call__( - self, - targets: Collection[CoreObjectDescriptor], - arguments: CallArguments, - context_owner: CoreObjectDescriptor, - context: AnsibleJobContext, + self, targets: Collection[CoreObjectDescriptor], arguments: CallArguments, runtime: RuntimeEnvironment ) -> CallResult[ReturnValue]: """ Perform plugin operation. @@ -333,7 +361,8 @@ def execute(self) -> CallResult[ReturnValue]: """ try: - call_arguments, call_context = self._validate_inputs() + call_arguments, ansible_vars = self._validate_inputs() + call_context = ansible_vars.context owner_from_context = CoreObjectDescriptor( id=getattr(call_context, f"{call_context.type}_id"), type=ADCMCoreType(call_context.type) if call_context.type != "provider" else ADCMCoreType.HOSTPROVIDER, @@ -342,7 +371,9 @@ def execute(self) -> CallResult[ReturnValue]: self._validate_targets(context_owner=owner_from_context, context=call_context) targets = self._detect_targets(context_owner=owner_from_context, context=call_context) result = self( - context_owner=owner_from_context, targets=targets, arguments=call_arguments, context=call_context + targets=targets, + arguments=call_arguments, + runtime=RuntimeEnvironment(context_owner=owner_from_context, vars=ansible_vars), ) except ADCMPluginError as err: return CallResult(value={}, changed=False, error=err) @@ -352,7 +383,7 @@ def execute(self) -> CallResult[ReturnValue]: return result - def _validate_inputs(self) -> tuple[CallArguments, AnsibleJobContext]: + def _validate_inputs(self) -> tuple[CallArguments, AnsibleRuntimeVars]: try: arguments = self._config.arguments.represent_as(**self._raw_arguments) except ValidationError as err: @@ -365,14 +396,14 @@ def _validate_inputs(self) -> tuple[CallArguments, AnsibleJobContext]: raise error try: - context = AnsibleJobContext(**self._raw_context) + ansible_vars = AnsibleRuntimeVars(**self._raw_vars) except ValidationError as err: - message = f"Context doesn't match expected schema:\n{err}" + message = f"Ansible variables doesn't match expected schema:\n{err}" raise PluginValidationError(message=message) from err - return arguments, context + return arguments, ansible_vars - def _validate_context(self, context_owner: CoreObjectDescriptor, context: AnsibleJobContext) -> None: + def _validate_context(self, context_owner: CoreObjectDescriptor, context: VarsContextSection) -> None: allowed_context_owners = self._config.context.allow_only # it's important that if it's empty no check is performed if allowed_context_owners and context_owner.type not in allowed_context_owners: @@ -388,14 +419,14 @@ def _validate_context(self, context_owner: CoreObjectDescriptor, context: Ansibl if error: raise error - def _validate_targets(self, context_owner: CoreObjectDescriptor, context: AnsibleJobContext) -> None: + def _validate_targets(self, context_owner: CoreObjectDescriptor, context: VarsContextSection) -> None: for validator in self._config.target.validators: error = validator(context_owner=context_owner, context=context, raw_arguments=self._raw_arguments) if error: raise error def _detect_targets( - self, context_owner: CoreObjectDescriptor, context: AnsibleJobContext + self, context_owner: CoreObjectDescriptor, context: VarsContextSection ) -> tuple[CoreObjectDescriptor, ...]: if not self._config.target.detectors: # Note that only in this case it's ok to return empty targets. @@ -450,7 +481,7 @@ def run(self, tmp=None, task_vars=None): with (settings.RUN_DIR / str(task_vars["job"]["id"]) / "config.json").open(encoding="utf-8") as file: fcntl.flock(file.fileno(), fcntl.LOCK_EX) - executor = self.executor_class(arguments=self._task.args, context=task_vars.get("context", {})) + executor = self.executor_class(arguments=self._task.args, runtime_vars=task_vars) execution_result = executor.execute() if execution_result.error: diff --git a/python/ansible_plugin/executors/add_host.py b/python/ansible_plugin/executors/add_host.py index 596ee0d040..393ae1adba 100644 --- a/python/ansible_plugin/executors/add_host.py +++ b/python/ansible_plugin/executors/add_host.py @@ -21,12 +21,12 @@ from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, - AnsibleJobContext, ArgumentsConfig, CallResult, ContextConfig, PluginExecutorConfig, ReturnValue, + RuntimeEnvironment, ) from ansible_plugin.errors import PluginRuntimeError @@ -47,17 +47,13 @@ class ADCMAddHostPluginExecutor(ADCMAnsiblePluginExecutor[AddHostArguments, AddH ) def __call__( - self, - targets: Collection[CoreObjectDescriptor], - arguments: AddHostArguments, - context_owner: CoreObjectDescriptor, - context: AnsibleJobContext, + self, targets: Collection[CoreObjectDescriptor], arguments: AddHostArguments, runtime: RuntimeEnvironment ) -> CallResult[ReturnValue]: - _ = targets, context + _ = targets with atomic(): try: - hostprovider = HostProvider.objects.select_related("prototype__bundle").get(id=context_owner.id) + hostprovider = HostProvider.objects.select_related("prototype__bundle").get(id=runtime.context_owner.id) host_prototype = Prototype.objects.get(type="host", bundle=hostprovider.prototype.bundle) host = add_host( provider=hostprovider, @@ -69,14 +65,15 @@ def __call__( return CallResult( value=None, changed=False, - error=PluginRuntimeError(message=f"Failed to find HostProvider with id {context_owner.id}"), + error=PluginRuntimeError(message=f"Failed to find HostProvider with id {runtime.context_owner.id}"), ) except Prototype.DoesNotExist: return CallResult( value=None, changed=False, error=PluginRuntimeError( - message=f"Failed to locate host's prototype based on HostProvider with id {context_owner.id}" + message="Failed to locate host's prototype based on HostProvider " + f"with id {runtime.context_owner.id}" ), ) except IntegrityError as err: diff --git a/python/ansible_plugin/executors/add_host_to_cluster.py b/python/ansible_plugin/executors/add_host_to_cluster.py index 5ec3ef95c8..224df0543c 100644 --- a/python/ansible_plugin/executors/add_host_to_cluster.py +++ b/python/ansible_plugin/executors/add_host_to_cluster.py @@ -21,11 +21,12 @@ from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, - AnsibleJobContext, ArgumentsConfig, CallResult, ContextConfig, PluginExecutorConfig, + RuntimeEnvironment, + VarsContextSection, ) from ansible_plugin.errors import PluginRuntimeError, PluginValidationError @@ -45,7 +46,7 @@ def check_either_is_specified(self) -> Self: def cluster_id_must_be_in_context( - context_owner: CoreObjectDescriptor, context: AnsibleJobContext + context_owner: CoreObjectDescriptor, context: VarsContextSection ) -> PluginValidationError | None: _ = context_owner @@ -69,10 +70,9 @@ def __call__( self, targets: Collection[CoreObjectDescriptor], arguments: AddHostToClusterArguments, - context_owner: CoreObjectDescriptor, - context: AnsibleJobContext, + runtime: RuntimeEnvironment, ) -> CallResult[None]: - _ = targets, context_owner + _ = targets host_id = arguments.host_id if arguments.fqdn is not None: @@ -89,6 +89,6 @@ def __call__( # at this point it can't be None due to validation => raising instead of returning raise PluginRuntimeError(message="Failed to detect what host to add") - perform_host_to_cluster_map(cluster_id=context.cluster_id, hosts=[host_id], status_service=notify) + perform_host_to_cluster_map(cluster_id=runtime.vars.context.cluster_id, hosts=[host_id], status_service=notify) return CallResult(value=None, changed=True, error=None) diff --git a/python/ansible_plugin/executors/change_flag.py b/python/ansible_plugin/executors/change_flag.py index 762604716a..a7c3a044ad 100644 --- a/python/ansible_plugin/executors/change_flag.py +++ b/python/ansible_plugin/executors/change_flag.py @@ -27,12 +27,13 @@ from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, - AnsibleJobContext, ArgumentsConfig, CallResult, PluginExecutorConfig, ReturnValue, + RuntimeEnvironment, TargetConfig, + VarsContextSection, from_context, from_objects, ) @@ -64,7 +65,7 @@ def check_name_length(cls, v: str | None) -> str | None: def validate_objects( context_owner: CoreObjectDescriptor, - context: AnsibleJobContext, # noqa: ARG001 + context: VarsContextSection, # noqa: ARG001 raw_arguments: dict, ) -> PluginValidationError | None: match context_owner.type: @@ -102,13 +103,9 @@ class ADCMChangeFlagPluginExecutor(ADCMAnsiblePluginExecutor[ChangeFlagArguments @atomic() def __call__( - self, - targets: Collection[CoreObjectDescriptor], - arguments: ChangeFlagArguments, - context_owner: CoreObjectDescriptor, - context: AnsibleJobContext, + self, targets: Collection[CoreObjectDescriptor], arguments: ChangeFlagArguments, runtime: RuntimeEnvironment ) -> CallResult[ReturnValue]: - _ = context, context_owner + _ = runtime match arguments.operation: case ChangeFlagOperation.UP: diff --git a/python/ansible_plugin/executors/config.py b/python/ansible_plugin/executors/config.py index 1bd3cbb811..f1bf298bfc 100644 --- a/python/ansible_plugin/executors/config.py +++ b/python/ansible_plugin/executors/config.py @@ -28,11 +28,12 @@ from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, - AnsibleJobContext, ArgumentsConfig, CallResult, PluginExecutorConfig, + RuntimeEnvironment, TargetConfig, + VarsContextSection, from_arguments_root, ) from ansible_plugin.errors import PluginIncorrectCallError, PluginTargetDetectionError, PluginValidationError @@ -98,7 +99,7 @@ class ChangeConfigReturn(TypedDict): def validate_type_is_present( context_owner: CoreObjectDescriptor, - context: AnsibleJobContext, # noqa: ARG001 + context: VarsContextSection, # noqa: ARG001 raw_arguments: dict, ) -> PluginValidationError | None: _ = context, context_owner @@ -117,17 +118,11 @@ class ADCMConfigPluginExecutor(ADCMAnsiblePluginExecutor[ChangeConfigArguments, @atomic() def __call__( - self, - targets: Collection[CoreObjectDescriptor], - arguments: ChangeConfigArguments, - context_owner: CoreObjectDescriptor, - context: AnsibleJobContext, + self, targets: Collection[CoreObjectDescriptor], arguments: ChangeConfigArguments, runtime: RuntimeEnvironment ) -> CallResult[ChangeConfigReturn]: - _ = context - target, *_ = targets - if error := validate_target_allowed_for_context_owner(context_owner=context_owner, target=target): + if error := validate_target_allowed_for_context_owner(context_owner=runtime.context_owner, target=target): return CallResult(value={}, changed=False, error=error) changes = ConfigAttrPair(config={}, attr={}) diff --git a/python/ansible_plugin/executors/delete_host.py b/python/ansible_plugin/executors/delete_host.py index 9da007fc76..34d7b62dc0 100644 --- a/python/ansible_plugin/executors/delete_host.py +++ b/python/ansible_plugin/executors/delete_host.py @@ -18,12 +18,12 @@ from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, - AnsibleJobContext, ArgumentsConfig, CallResult, ContextConfig, NoArguments, PluginExecutorConfig, + RuntimeEnvironment, ) from ansible_plugin.errors import PluginTargetDetectionError @@ -35,21 +35,17 @@ class ADCMDeleteHostPluginExecutor(ADCMAnsiblePluginExecutor[NoArguments, None]) ) def __call__( - self, - targets: Collection[CoreObjectDescriptor], - arguments: NoArguments, - context_owner: CoreObjectDescriptor, - context: AnsibleJobContext, + self, targets: Collection[CoreObjectDescriptor], arguments: NoArguments, runtime: RuntimeEnvironment ) -> CallResult[None]: - _ = targets, context, arguments + _ = targets, arguments try: - delete_host(Host.obj.get(pk=context_owner.id), cancel_tasks=False) + delete_host(Host.obj.get(pk=runtime.context_owner.id), cancel_tasks=False) except Host.DoesNotExist: return CallResult( value=None, changed=False, - error=PluginTargetDetectionError(message=f"Host #{context_owner.id} wasn't found"), + error=PluginTargetDetectionError(message=f"Host #{runtime.context_owner.id} wasn't found"), ) return CallResult(value=None, changed=True, error=None) diff --git a/python/ansible_plugin/executors/delete_service.py b/python/ansible_plugin/executors/delete_service.py index d0d3788007..e3df7d73e3 100644 --- a/python/ansible_plugin/executors/delete_service.py +++ b/python/ansible_plugin/executors/delete_service.py @@ -20,11 +20,11 @@ from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, - AnsibleJobContext, ArgumentsConfig, CallResult, ContextConfig, PluginExecutorConfig, + RuntimeEnvironment, ) from ansible_plugin.errors import PluginRuntimeError, PluginTargetDetectionError @@ -40,20 +40,16 @@ class ADCMDeleteServicePluginExecutor(ADCMAnsiblePluginExecutor[DeleteServiceArg ) def __call__( - self, - targets: Collection[CoreObjectDescriptor], - arguments: DeleteServiceArguments, - context_owner: CoreObjectDescriptor, - context: AnsibleJobContext, + self, targets: Collection[CoreObjectDescriptor], arguments: DeleteServiceArguments, runtime: RuntimeEnvironment ) -> CallResult[None]: _ = targets if arguments.service: - search_kwargs = {"cluster_id": context.cluster_id, "prototype__name": arguments.service} - elif context_owner.type == ADCMCoreType.SERVICE: - search_kwargs = {"id": context_owner.id} + search_kwargs = {"cluster_id": runtime.vars.context.cluster_id, "prototype__name": arguments.service} + elif runtime.context_owner.type == ADCMCoreType.SERVICE: + search_kwargs = {"id": runtime.context_owner.id} else: - message = f"Incorrect plugin call for {arguments.service=} in context {context_owner}" + message = f"Incorrect plugin call for {arguments.service=} in context {runtime.context_owner}" raise PluginRuntimeError(message) try: diff --git a/python/ansible_plugin/executors/hostcomponent.py b/python/ansible_plugin/executors/hostcomponent.py new file mode 100644 index 0000000000..576d177499 --- /dev/null +++ b/python/ansible_plugin/executors/hostcomponent.py @@ -0,0 +1,124 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any, Collection, Literal + +from cm.api import add_hc, get_hc +from cm.models import Cluster, Host, JobLog, ServiceComponent +from core.types import ADCMCoreType, CoreObjectDescriptor +from pydantic import BaseModel, field_validator + +from ansible_plugin.base import ( + ADCMAnsiblePluginExecutor, + ArgumentsConfig, + CallResult, + ContextConfig, + PluginExecutorConfig, + RuntimeEnvironment, + VarsContextSection, +) +from ansible_plugin.errors import PluginIncorrectCallError, PluginRuntimeError, PluginValidationError + + +class Operation(BaseModel): + action: Literal["add", "remove"] + service: str + component: str + host: str + + @field_validator("action", mode="before") + @classmethod + def convert_action_to_string(cls, v: Any) -> str: + # requited to pre-process Ansible Strings + return str(v) + + +class ChangeHostComponentArguments(BaseModel): + operations: list[Operation] + + +def cluster_id_must_be_in_context( + context_owner: CoreObjectDescriptor, context: VarsContextSection +) -> PluginValidationError | None: + _ = context_owner + + return ( + None + if context.cluster_id + else PluginValidationError(message="Expected `cluster_id` in context, but it's missing") + ) + + +class ADCMHostComponentPluginExecutor(ADCMAnsiblePluginExecutor[ChangeHostComponentArguments, None]): + _config = PluginExecutorConfig( + arguments=ArgumentsConfig(represent_as=ChangeHostComponentArguments), + context=ContextConfig( + allow_only=frozenset((ADCMCoreType.CLUSTER, ADCMCoreType.SERVICE, ADCMCoreType.COMPONENT)), + validators=(cluster_id_must_be_in_context,), + ), + ) + + def __call__( + self, + targets: Collection[CoreObjectDescriptor], + arguments: ChangeHostComponentArguments, + runtime: RuntimeEnvironment, + ) -> CallResult[None]: + _ = targets + + action_hc_map = ( + JobLog.objects.values_list("task__action__hostcomponentmap", flat=True) + .filter(id=runtime.vars.job.id) + .first() + ) + if action_hc_map: + raise PluginIncorrectCallError(message="You can not change hc in plugin for action with hc_acl") + + cluster = Cluster.objects.get(id=runtime.vars.context.cluster_id) + hostcomponent = get_hc(cluster) + for operation in arguments.operations: + component_id, service_id = ServiceComponent.objects.values_list("id", "service_id").get( + cluster=cluster, service__prototype__name=operation.service, prototype__name=operation.component + ) + host_id = Host.objects.values_list("id", flat=True).get(cluster=cluster, fqdn=operation.host) + item = { + "host_id": host_id, + "service_id": service_id, + "component_id": component_id, + } + if operation.action == "add": + if item in hostcomponent: + return CallResult( + value=None, + changed=False, + error=PluginRuntimeError( + message=f'There is already component "{operation.component}" on host "{operation.host}"' + ), + ) + + hostcomponent.append(item) + + else: + if item not in hostcomponent: + return CallResult( + value=None, + changed=False, + error=PluginRuntimeError( + message=f'There is no component "{operation.component}" on host "{operation.host}"' + ), + ) + + hostcomponent.remove(item) + + add_hc(cluster, hostcomponent) + + return CallResult(value=None, changed=True, error=None) diff --git a/python/ansible_plugin/executors/remove_host_from_cluster.py b/python/ansible_plugin/executors/remove_host_from_cluster.py index d58d57899b..db5e085724 100644 --- a/python/ansible_plugin/executors/remove_host_from_cluster.py +++ b/python/ansible_plugin/executors/remove_host_from_cluster.py @@ -20,11 +20,11 @@ from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, - AnsibleJobContext, ArgumentsConfig, CallResult, ContextConfig, PluginExecutorConfig, + RuntimeEnvironment, ) from ansible_plugin.errors import ( PluginRuntimeError, @@ -56,10 +56,9 @@ def __call__( self, targets: Collection[CoreObjectDescriptor], arguments: RemoveHostFromClusterArguments, - context_owner: CoreObjectDescriptor, - context: AnsibleJobContext, + runtime: RuntimeEnvironment, ) -> CallResult[None]: - _ = targets, context_owner, context + _ = targets # eventually would be better to set explicit restriction to arguments - either on id or one fqdn # for now, we'll leave it as it is in order to support old behavior @@ -80,11 +79,13 @@ def __call__( changed=False, error=PluginRuntimeError(message=f"Host {host.fqdn} is unbound to any cluster"), ) - elif host.cluster_id != context.cluster_id: + elif host.cluster_id != runtime.vars.context.cluster_id: return CallResult( value=None, changed=False, - error=PluginRuntimeError(message=f"Host {host.fqdn} is not in cluster id: {context.cluster_id}"), + error=PluginRuntimeError( + message=f"Host {host.fqdn} is not in cluster id: {runtime.vars.context.cluster_id}" + ), ) remove_host_from_cluster(host) diff --git a/python/ansible_plugin/tests/bundles/cluster/config.yaml b/python/ansible_plugin/tests/bundles/cluster/config.yaml index 4f1a09494f..fa1c49f00a 100644 --- a/python/ansible_plugin/tests/bundles/cluster/config.yaml +++ b/python/ansible_plugin/tests/bundles/cluster/config.yaml @@ -13,6 +13,13 @@ <<: *action host_action: true + with_hc: + <<: *action + hc_acl: + - action: add + service: service_1 + component: component_1 + - &service type: service name: service_1 @@ -29,4 +36,3 @@ - <<: *service name: service_2 - diff --git a/python/ansible_plugin/tests/test_adcm_hc.py b/python/ansible_plugin/tests/test_adcm_hc.py new file mode 100644 index 0000000000..af3b371deb --- /dev/null +++ b/python/ansible_plugin/tests/test_adcm_hc.py @@ -0,0 +1,239 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from operator import itemgetter + +from cm.converters import orm_object_to_core_type +from cm.models import HostComponent, ServiceComponent +from cm.services.job.run.repo import JobRepoImpl + +from ansible_plugin.errors import PluginContextError, PluginIncorrectCallError, PluginRuntimeError +from ansible_plugin.executors.hostcomponent import ADCMHostComponentPluginExecutor +from ansible_plugin.tests.base import BaseTestEffectsOfADCMAnsiblePlugins + + +class TestEffectsOfADCMAnsiblePlugins(BaseTestEffectsOfADCMAnsiblePlugins): + def setUp(self) -> None: + super().setUp() + + self.service_1 = self.add_services_to_cluster(["service_1"], cluster=self.cluster).first() + self.component_1 = ServiceComponent.objects.filter(service=self.service_1).first() + + self.add_host_to_cluster(cluster=self.cluster, host=self.host_1) + self.add_host_to_cluster(cluster=self.cluster, host=self.host_2) + + def get_current_hc_dicts(self) -> list[dict]: + return list(HostComponent.objects.values("service_id", "component_id", "host_id").filter(cluster=self.cluster)) + + def test_simple_call_success(self) -> None: + for object_ in (self.cluster, self.service_1, self.component_1): + with self.subTest(object_.__class__.__name__): + self.set_hostcomponent( + cluster=self.cluster, + entries=((self.host_1, self.component_1),), + ) + hostcomponent = self.get_current_hc_dicts() + self.assertEqual(len(hostcomponent), 1) + expected_hc = sorted( + (hostcomponent[0], {**hostcomponent[0], "host_id": self.host_2.id}), key=itemgetter("host_id") + ) + + task = self.prepare_task(owner=object_, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMHostComponentPluginExecutor, + call_arguments=f""" + operations: + - action: add + service: {self.service_1.name} + component: {self.component_1.name} + host: {self.host_2.fqdn} + """, + call_context=job, + ) + + result = executor.execute() + self.assertIsNone(result.error) + + actual_hc = sorted(self.get_current_hc_dicts(), key=itemgetter("host_id")) + self.assertEqual(len(actual_hc), 2) + self.assertListEqual(actual_hc, expected_hc) + + def test_complex_call_success(self) -> None: + service_2 = self.add_services_to_cluster(["service_2"], cluster=self.cluster).get() + component_2 = self.service_1.servicecomponent_set.get(prototype__name="component_2") + component_3 = service_2.servicecomponent_set.get(prototype__name="component_1") + component_4 = service_2.servicecomponent_set.get(prototype__name="component_2") + + object_ = self.service_1 + + expected_hc = sorted( + ( + {"host_id": self.host_2.id, "component_id": component_4.id, "service_id": service_2.id}, + {"host_id": self.host_2.id, "component_id": self.component_1.id, "service_id": self.service_1.id}, + {"host_id": self.host_1.id, "component_id": component_2.id, "service_id": self.service_1.id}, + ), + key=itemgetter("component_id"), + ) + + self.set_hostcomponent( + cluster=self.cluster, + entries=((self.host_1, self.component_1), (self.host_2, component_3), (self.host_2, component_4)), + ) + + task = self.prepare_task(owner=object_, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMHostComponentPluginExecutor, + call_arguments={ + "operations": [ + { + "action": "add", + "service": self.service_1.name, + "component": self.component_1.name, + "host": self.host_2.fqdn, + }, + { + "action": "remove", + "service": self.service_1.name, + "component": self.component_1.name, + "host": self.host_1.fqdn, + }, + { + "action": "add", + "service": self.service_1.name, + "component": component_2.name, + "host": self.host_1.fqdn, + }, + { + "action": "remove", + "service": service_2.name, + "component": component_3.name, + "host": self.host_2.fqdn, + }, + ] + }, + call_context=job, + ) + + result = executor.execute() + self.assertIsNone(result.error) + + self.assertEqual(sorted(self.get_current_hc_dicts(), key=itemgetter("component_id")), expected_hc) + + def test_incorrect_context_call_fail(self) -> None: + for object_ in (self.provider, self.host_1): + name = object_.__class__.__name__ + with self.subTest(name): + task = self.prepare_task(owner=object_, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + executor = self.prepare_executor( + executor_type=ADCMHostComponentPluginExecutor, + call_arguments={"operations": []}, + call_context=job, + ) + + result = executor.execute() + + self.assertIsInstance(result.error, PluginContextError) + self.assertIn( + "Plugin should be called only in context of cluster or component or service, " + f"not {orm_object_to_core_type(object_).value}", + result.error.message, + ) + + def test_call_for_action_with_hc_fail(self) -> None: + object_ = self.cluster + + task = self.prepare_task(owner=object_, name="with_hc") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMHostComponentPluginExecutor, + call_arguments=f""" + operations: + - action: add + service: {self.service_1.name} + component: {self.component_1.name} + host: {self.host_2.fqdn} + """, + call_context=job, + ) + + result = executor.execute() + + self.assertIsInstance(result.error, PluginIncorrectCallError) + self.assertEqual(result.error.message, "You can not change hc in plugin for action with hc_acl") + + def test_add_already_existing_fail(self) -> None: + object_ = self.service_1 + self.set_hostcomponent( + cluster=self.cluster, + entries=((self.host_1, self.component_1),), + ) + expected_hc = self.get_current_hc_dicts() + + task = self.prepare_task(owner=object_, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMHostComponentPluginExecutor, + call_arguments=f""" + operations: + - action: add + service: {self.service_1.name} + component: {self.component_1.name} + host: {self.host_1.fqdn} + """, + call_context=job, + ) + + result = executor.execute() + + self.assertIsInstance(result.error, PluginRuntimeError) + self.assertEqual( + result.error.message, f'There is already component "{self.component_1.name}" on host "{self.host_1.fqdn}"' + ) + self.assertEqual(self.get_current_hc_dicts(), expected_hc) + + def test_remove_absent_fail(self) -> None: + object_ = self.component_1 + self.set_hostcomponent( + cluster=self.cluster, + entries=((self.host_1, self.component_1),), + ) + expected_hc = self.get_current_hc_dicts() + + task = self.prepare_task(owner=object_, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMHostComponentPluginExecutor, + call_arguments=f""" + operations: + - action: remove + service: {self.service_1.name} + component: {self.component_1.name} + host: {self.host_2.fqdn} + """, + call_context=job, + ) + + result = executor.execute() + + self.assertIsInstance(result.error, PluginRuntimeError) + self.assertEqual( + result.error.message, f'There is no component "{self.component_1.name}" on host "{self.host_2.fqdn}"' + ) + self.assertEqual(self.get_current_hc_dicts(), expected_hc) diff --git a/python/ansible_plugin/tests/test_targets_extraction.py b/python/ansible_plugin/tests/test_targets_extraction.py index ab5a4b956d..f1bf84df36 100644 --- a/python/ansible_plugin/tests/test_targets_extraction.py +++ b/python/ansible_plugin/tests/test_targets_extraction.py @@ -223,7 +223,4 @@ def check_target_detection( result = executor.execute() self.assertIsNone(result.error, result.error.message if result.error else "") - self.assertListEqual( - list(result.value.targets), - expected_targets, - ) + self.assertListEqual(list(result.value.targets), expected_targets) diff --git a/python/ansible_plugin/utils.py b/python/ansible_plugin/utils.py index fe37d24891..052e26ce29 100644 --- a/python/ansible_plugin/utils.py +++ b/python/ansible_plugin/utils.py @@ -35,10 +35,8 @@ MSG_NO_MULTI_STATE_TO_DELETE, ) from cm.adcm_config.config import get_option_value -from cm.api import add_hc, get_hc from cm.errors import AdcmEx from cm.models import ( - Action, ADCMEntity, CheckLog, Cluster, @@ -324,46 +322,6 @@ def set_host_multi_state(host_id, multi_state): return _set_object_multi_state(obj, multi_state) -def change_hc(job_id, cluster_id, operations): - """ - For use in ansible plugin adcm_hc - """ - file_descriptor = job_lock(job_id) - action_id = JobLog.objects.values_list("task__action_id", flat=True).get(id=job_id) - action = Action.objects.get(id=action_id) - if action.hostcomponentmap: - raise AdcmEx("ACTION_ERROR", "You can not change hc in plugin for action with hc_acl") - - cluster = Cluster.obj.get(id=cluster_id) - hostcomponent = get_hc(cluster) - for operation in operations: - service = ClusterObject.obj.get(cluster=cluster, prototype__name=operation["service"]) - component = ServiceComponent.obj.get(cluster=cluster, service=service, prototype__name=operation["component"]) - host = Host.obj.get(cluster=cluster, fqdn=operation["host"]) - item = { - "host_id": host.id, - "service_id": service.id, - "component_id": component.id, - } - if operation["action"] == "add": - if item not in hostcomponent: - hostcomponent.append(item) - else: - msg = 'There is already component "{}" on host "{}"' - raise AdcmEx("COMPONENT_CONFLICT", msg.format(component.prototype.name, host.fqdn)) - elif operation["action"] == "remove": - if item in hostcomponent: - hostcomponent.remove(item) - else: - msg = 'There is no component "{}" on host "{}"' - raise AdcmEx("COMPONENT_CONFLICT", msg.format(component.prototype.name, host.fqdn)) - else: - raise AdcmEx("INVALID_INPUT", f'unknown hc action "{operation["action"]}"') - - add_hc(cluster, hostcomponent) - file_descriptor.close() - - def cast_to_type(field_type: str, value: Any, limits: dict) -> Any: try: match field_type: diff --git a/python/cm/api.py b/python/cm/api.py index 8cf80b2f43..5317394a9d 100644 --- a/python/cm/api.py +++ b/python/cm/api.py @@ -427,17 +427,7 @@ def get_hc(cluster: Cluster | None) -> list[dict] | None: if not cluster: return None - hc_map = [] - for hostcomponent in HostComponent.objects.filter(cluster=cluster): - hc_map.append( - { - "host_id": hostcomponent.host.pk, - "service_id": hostcomponent.service.pk, - "component_id": hostcomponent.component.pk, - }, - ) - - return hc_map + return list(HostComponent.objects.values("host_id", "service_id", "component_id").filter(cluster=cluster)) def check_sub_key(hc_in): diff --git a/python/cm/tests/test_task_runner/test_plugin_effects.py b/python/cm/tests/test_task_runner/test_plugin_effects.py index aad28be326..ed7ce11396 100644 --- a/python/cm/tests/test_task_runner/test_plugin_effects.py +++ b/python/cm/tests/test_task_runner/test_plugin_effects.py @@ -13,15 +13,18 @@ from pathlib import Path import json +from adcm.tests.ansible import ADCMAnsiblePluginTestMixin from adcm.tests.base import BusinessLogicMixin, ParallelReadyTestCase, TestCaseWithCommonSetUpTearDown -from ansible_plugin.utils import change_hc +from ansible_plugin.executors.hostcomponent import ADCMHostComponentPluginExecutor from cm.models import Action, ServiceComponent from cm.services.job.action import ActionRunPayload, run_action from cm.tests.mocks.task_runner import ETFMockWithEnvPreparation, JobImitator, RunTaskMock -class TestEffectsOfADCMAnsiblePlugins(TestCaseWithCommonSetUpTearDown, ParallelReadyTestCase, BusinessLogicMixin): +class TestEffectsOfADCMAnsiblePlugins( + TestCaseWithCommonSetUpTearDown, ParallelReadyTestCase, BusinessLogicMixin, ADCMAnsiblePluginTestMixin +): def setUp(self) -> None: super().setUp() @@ -53,9 +56,21 @@ def test_adcm_hc_should_not_cause_hc_acl_effect(self) -> None: {"action": "remove", "service": service.name, "component": component_1.name, "host": self.host_1.name}, ] + def plugin_call(executor): + executor = self.prepare_executor( + executor_type=ADCMHostComponentPluginExecutor, + call_arguments={"operations": operations}, + call_context=int(executor._config.work_dir.name), # id of job + ) + result = executor.execute() + if result.error: + return 1 + + return 0 + with RunTaskMock( execution_target_factory=ETFMockWithEnvPreparation( - change_jobs={0: JobImitator(call=lambda _: change_hc(1, self.cluster.id, operations))} + change_jobs={0: JobImitator(call=plugin_call, use_call_return_code=True)} ) ) as run_task: run_action( From c5c517b67465763a8e36538bb088f12a5184d87e Mon Sep 17 00:00:00 2001 From: Kirill Fedorenko Date: Wed, 22 May 2024 10:17:39 +0000 Subject: [PATCH 112/208] ADCM-5421 [UI] Extend concerns placeholder rules forming https://tracker.yandex.ru/ADCM-5421 --- adcm-web/app/src/models/adcm/concern.ts | 35 ++++++--- adcm-web/app/src/utils/concernUtils.ts | 100 +++++++----------------- 2 files changed, 54 insertions(+), 81 deletions(-) diff --git a/adcm-web/app/src/models/adcm/concern.ts b/adcm-web/app/src/models/adcm/concern.ts index 66a0d49772..565d1985cc 100644 --- a/adcm-web/app/src/models/adcm/concern.ts +++ b/adcm-web/app/src/models/adcm/concern.ts @@ -7,30 +7,47 @@ export enum AdcmConcernCause { Job = 'job', } -export enum AdcmConcernType { +export enum AdcmConcernOwnerType { Adcm = 'adcm', Cluster = 'cluster', Service = 'service', Component = 'component', Host = 'host', Provider = 'provider', +} + +export enum AdcmConcernType { + AdcmConfig = 'adcm_config', + ClusterConfig = 'cluster_config', + ComponentConfig = 'component_config', + HostConfig = 'host_config', + ProviderConfig = 'provider_config', + ServiceConfig = 'service_config', + ClusterServices = 'cluster_services', // the same value for the type "Requirement" + ClusterImport = 'cluster_import', + HostComponent = 'cluster_mapping', Job = 'job', Prototype = 'prototype', + Adcm = 'adcm', + Cluster = 'cluster', + Service = 'service', + Component = 'component', + Provider = 'provider', + Host = 'host', } export interface AdcmConcernCommonPlaceholder { name: string; + type: AdcmConcernType; } export interface AdcmConcernClusterPlaceholder extends AdcmConcernCommonPlaceholder { - type: AdcmConcernType.Cluster; params: { clusterId: number; }; } export interface AdcmConcernServicePlaceholder extends AdcmConcernCommonPlaceholder { - type: AdcmConcernType.Service; params: { clusterId: number; serviceId: number; @@ -38,7 +55,6 @@ export interface AdcmConcernServicePlaceholder extends AdcmConcernCommonPlacehol } export interface AdcmConcernComponentPlaceholder extends AdcmConcernCommonPlaceholder { - type: AdcmConcernType.Component; params: { clusterId: number; serviceId: number; @@ -47,35 +63,30 @@ export interface AdcmConcernComponentPlaceholder extends AdcmConcernCommonPlaceh } export interface AdcmConcernHostPlaceholder extends AdcmConcernCommonPlaceholder { - type: AdcmConcernType.Host; params: { hostId: number; }; } export interface AdcmConcernHostProviderPlaceholder extends AdcmConcernCommonPlaceholder { - type: AdcmConcernType.Provider; params: { providerId: number; }; } export interface AdcmConcernJobPlaceholder extends AdcmConcernCommonPlaceholder { - type: AdcmConcernType.Job; params: { jobId: number; }; } export interface AdcmConcernPrototypePlaceholder extends AdcmConcernCommonPlaceholder { - type: AdcmConcernType.Prototype; params: { prototypeId: number; }; } export interface AdcmPrototypePlaceholder extends AdcmConcernCommonPlaceholder { - type: AdcmConcernType.Adcm; params: { adcmId: number; }; @@ -96,9 +107,15 @@ export interface AdcmConcernReason { placeholder: Record; } +interface AdcmConcernOwner { + id: number; + type: AdcmConcernOwnerType; +} + export interface AdcmConcerns { id: number; reason: AdcmConcernReason; isBlocking: boolean; cause: AdcmConcernCause; + owner: AdcmConcernOwner; } diff --git a/adcm-web/app/src/utils/concernUtils.ts b/adcm-web/app/src/utils/concernUtils.ts index 21dc249917..0dea6cbfec 100644 --- a/adcm-web/app/src/utils/concernUtils.ts +++ b/adcm-web/app/src/utils/concernUtils.ts @@ -1,16 +1,32 @@ -import { - AdcmConcernType, - AdcmConcerns, - AdcmConcernPlaceholder, - AdcmConcernCause, - AdcmConcernClusterPlaceholder, -} from '@models/adcm/concern'; +import { AdcmConcerns, AdcmConcernServicePlaceholder, AdcmConcernType } from '@models/adcm/concern'; +import { generatePath } from 'react-router-dom'; export interface ConcernObjectPathsData { path?: string; text: string; } +const concernTypeUrlDict: Record = { + [AdcmConcernType.AdcmConfig]: '', + [AdcmConcernType.ClusterConfig]: '/clusters/:clusterId/primary-configuration/', + [AdcmConcernType.ClusterImport]: '/clusters/:clusterId/import/', + [AdcmConcernType.ServiceConfig]: '/clusters/:clusterId/services/:serviceId/primary-configuration/', + [AdcmConcernType.ComponentConfig]: + '/clusters/:clusterId/services/:serviceId/components/:componentId/primary-configuration/', + [AdcmConcernType.HostConfig]: '/hosts/:hostId/primary-configuration/', + [AdcmConcernType.ProviderConfig]: '/hostproviders/:providerId/primary-configuration/', + [AdcmConcernType.HostComponent]: '/clusters/:clusterId/mapping/', + [AdcmConcernType.ClusterServices]: '/clusters/:clusterId/services/', // the same route for the Requirement concern type + [AdcmConcernType.Job]: '/jobs/:taskId/', + [AdcmConcernType.Prototype]: '', + [AdcmConcernType.Adcm]: '', + [AdcmConcernType.Cluster]: '/clusters/:clusterId/', + [AdcmConcernType.Service]: '/clusters/:clusterId/services/:serviceId/', + [AdcmConcernType.Component]: '/clusters/:clusterId/services/:serviceId/components/:componentId/', + [AdcmConcernType.Host]: '/hosts/:hostId/', + [AdcmConcernType.Provider]: '/hostproviders/:providerId/', +}; + export const getConcernLinkObjectPathsDataArray = ( concerns: AdcmConcerns[] | undefined, ): Array => { @@ -24,7 +40,11 @@ export const getConcernLinkObjectPathsDataArray = ( const separatedMessage = concern.reason.message.split(keyRegexp); Object.entries(concern.reason.placeholder).forEach(([key, placeholderItem]) => { - const path = getConcernPath(concern, placeholderItem); + const generatedPath = generatePath(concernTypeUrlDict[placeholderItem.type], placeholderItem.params); + const path = + placeholderItem.type === AdcmConcernType.ClusterImport + ? `${generatedPath}/services/?serviceId=${(placeholderItem as AdcmConcernServicePlaceholder).params.serviceId}` + : generatedPath; linksDataMap.set(key, { path, text: placeholderItem.name, @@ -44,70 +64,6 @@ export const getConcernLinkObjectPathsDataArray = ( }); }; -export const getConcernObjectPath = (placeholderProps: AdcmConcernPlaceholder): string => { - switch (placeholderProps.type) { - case AdcmConcernType.Cluster: - return `/clusters/${placeholderProps.params.clusterId}`; - case AdcmConcernType.Service: - return `/clusters/${placeholderProps.params.clusterId}/services/${placeholderProps.params.serviceId}`; - case AdcmConcernType.Component: - return `/clusters/${placeholderProps.params.clusterId}/services/${placeholderProps.params.serviceId}/components/${placeholderProps.params.componentId}`; - case AdcmConcernType.Host: - return `/hosts/${placeholderProps.params.hostId}`; - case AdcmConcernType.Provider: - return `/hostproviders/${placeholderProps.params.providerId}`; - case AdcmConcernType.Job: - return `/jobs/${placeholderProps.params.jobId}`; - case AdcmConcernType.Prototype: - case AdcmConcernType.Adcm: - default: - return ''; - } -}; - -const getConcernObjectConfigPath = (placeholderProps: AdcmConcernPlaceholder, concernCause?: AdcmConcernCause) => { - const clusterPath = `/clusters/${(placeholderProps as AdcmConcernClusterPlaceholder).params.clusterId}`; - const concernObjectPath = getConcernObjectPath(placeholderProps); - - if (placeholderProps.type === AdcmConcernType.Cluster && concernCause === AdcmConcernCause.Config) { - return `${clusterPath}/configuration`; - } else { - return `${concernObjectPath}/primary-configuration`; - } -}; - -const getConcernPath = (concern: AdcmConcerns, placeholderProps: AdcmConcernPlaceholder): string => { - if (placeholderProps.type === AdcmConcernType.Prototype || placeholderProps.type === AdcmConcernType.Adcm) { - return ''; - } - - const clusterPath = `/clusters/${(placeholderProps as AdcmConcernClusterPlaceholder).params.clusterId}`; - - const concernObjectPath = getConcernObjectPath(placeholderProps); - - switch (concern.cause) { - case AdcmConcernCause.Config: - return getConcernObjectConfigPath(placeholderProps, AdcmConcernCause.Config); - case AdcmConcernCause.HostComponent: - return `${clusterPath}/mapping`; - case AdcmConcernCause.Import: - return `${clusterPath}/import/${ - placeholderProps.type === AdcmConcernType.Service - ? `services/?serviceId=${placeholderProps.params.serviceId}` - : placeholderProps.type - }`; - case AdcmConcernCause.Service: - case AdcmConcernCause.Requirement: - return `${clusterPath}/services`; - case AdcmConcernCause.Job: - return concernObjectPath; - default: - return placeholderProps.type === AdcmConcernType.Cluster - ? clusterPath - : getConcernObjectConfigPath(placeholderProps); - } -}; - export const isBlockingConcernPresent = (concerns: AdcmConcerns[]) => { return concerns.some(({ isBlocking }) => isBlocking); }; From b72d5f3afba729feb0fe793148f60ff282a67df0 Mon Sep 17 00:00:00 2001 From: Igor Kuzmin Date: Wed, 22 May 2024 11:07:43 +0000 Subject: [PATCH 113/208] bugfix/ADCM-5599 disable apply button when pattern error occured Task: https://tracker.yandex.ru/ADCM-5599 --- .../StringControls/MultilineStringControl.tsx | 4 ++-- .../FieldControls/StringControls/SecretControl.tsx | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/MultilineStringControl.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/MultilineStringControl.tsx index 9e094da13b..0c03c2dabd 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/MultilineStringControl.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/MultilineStringControl.tsx @@ -15,7 +15,7 @@ interface MultilineStringControlProps { value: JSONPrimitive; fieldSchema: SingleSchemaDefinition; isReadonly: boolean; - onChange: (newValue: JSONPrimitive) => void; + onChange: (newValue: JSONPrimitive, isValid?: boolean) => void; } const MultilineStringControl = ({ @@ -46,7 +46,7 @@ const MultilineStringControl = ({ const handleChange = (code: string) => { const error = validate(code, fieldSchema); setError(error); - onChange(code); + onChange(code, error === undefined); }; return ( diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/SecretControl.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/SecretControl.tsx index e3379f4b86..79b31bb9d1 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/SecretControl.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/SecretControl.tsx @@ -18,12 +18,12 @@ export interface StringControlProps { const SecretControl = ({ fieldName, fieldSchema, value, isReadonly, onChange }: StringControlProps) => { const [secret, setSecret] = useState(value as string); const [confirm, setConfirm] = useState(value as string); - const [error, setError] = useState(undefined); + const [secretError, setSecretError] = useState(undefined); const [confirmError, setConfirmError] = useState(undefined); const handleSecretChange = (event: React.ChangeEvent) => { const error = validate(event.target.value, fieldSchema); - setError(error); + setSecretError(error); setSecret(event.target.value); }; @@ -33,9 +33,9 @@ const SecretControl = ({ fieldName, fieldSchema, value, isReadonly, onChange }: useEffect(() => { const areEqual = secret === confirm; - onChange(secret, areEqual); + onChange(secret, areEqual && secretError === undefined); setConfirmError(!areEqual ? mismatchErrorText : undefined); - }, [confirm, onChange, secret]); + }, [secret, secretError, confirm, onChange]); const handleResetToDefault = (defaultValue: JSONPrimitive) => { onChange(defaultValue, true); @@ -48,7 +48,7 @@ const SecretControl = ({ fieldName, fieldSchema, value, isReadonly, onChange }: From 1cfae64f00f6dd5586ae9d5a86d7afb2944501ad Mon Sep 17 00:00:00 2001 From: Skrynnik Daniil Date: Wed, 22 May 2024 17:01:01 +0300 Subject: [PATCH 114/208] ADCM-5573: refactor test_host_provider.py --- python/api_v2/tests/test_host_provider.py | 54 +++++------------------ 1 file changed, 11 insertions(+), 43 deletions(-) diff --git a/python/api_v2/tests/test_host_provider.py b/python/api_v2/tests/test_host_provider.py index 951811a90d..b1677ac6ec 100644 --- a/python/api_v2/tests/test_host_provider.py +++ b/python/api_v2/tests/test_host_provider.py @@ -12,7 +12,6 @@ from cm.models import Action, HostProvider from cm.tests.mocks.task_runner import RunTaskMock -from rest_framework.reverse import reverse from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, @@ -34,29 +33,24 @@ def setUp(self) -> None: self.host_provider = self.add_provider(self.host_provider_bundle, "test host provider") def test_list_success(self): - response = self.client.get(path=reverse(viewname="v2:hostprovider-list")) + response = (self.client.v2 / "hostproviders").get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) def test_retrieve_success(self): - response = self.client.get( - path=reverse(viewname="v2:hostprovider-detail", kwargs={"pk": self.host_provider.pk}), - ) + response = self.client.v2[self.host_provider].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["id"], self.host_provider.pk) def test_retrieve_not_found_fail(self): - response = self.client.get( - path=reverse(viewname="v2:hostprovider-detail", kwargs={"pk": self.host_provider.pk + 1}), - ) + response = (self.client.v2 / "hostproviders" / str(self.get_non_existent_pk(model=HostProvider))).get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_create_success(self): - response = self.client.post( - path=reverse(viewname="v2:hostprovider-list"), + response = (self.client.v2 / "hostproviders").post( data={ "prototypeId": self.host_provider_bundle.pk, "name": self.host_provider.name + " new", @@ -67,8 +61,7 @@ def test_create_success(self): self.assertEqual(response.json()["name"], self.host_provider.name + " new") def test_create_no_description_success(self): - response = self.client.post( - path=reverse(viewname="v2:hostprovider-list"), + response = (self.client.v2 / "hostproviders").post( data={ "prototypeId": self.host_provider_bundle.pk, "name": self.host_provider.name + " new", @@ -78,8 +71,7 @@ def test_create_no_description_success(self): self.assertEqual(response.json()["name"], self.host_provider.name + " new") def test_host_provider_duplicate_fail(self): - response = self.client.post( - path=reverse(viewname="v2:hostprovider-list"), + response = (self.client.v2 / "hostproviders").post( data={ "prototype": self.host_provider.pk, "name": self.host_provider.name, @@ -89,17 +81,13 @@ def test_host_provider_duplicate_fail(self): self.assertEqual(response.status_code, HTTP_409_CONFLICT) def test_delete_success(self): - response = self.client.delete( - path=reverse(viewname="v2:hostprovider-detail", kwargs={"pk": self.host_provider.pk}), - ) + response = self.client.v2[self.host_provider].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.assertFalse(HostProvider.objects.filter(pk=self.host_provider.pk).exists()) def test_delete_not_found_fail(self): - response = self.client.delete( - path=reverse(viewname="v2:hostprovider-detail", kwargs={"pk": self.host_provider.pk + 1}), - ) + response = (self.client.v2 / "hostproviders" / str(self.get_non_existent_pk(model=HostProvider))).delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -111,40 +99,20 @@ def setUp(self) -> None: self.action = Action.objects.get(prototype=self.provider.prototype, name="provider_action") def test_action_list_success(self): - response = self.client.get( - path=reverse( - viewname="v2:provider-action-list", - kwargs={"hostprovider_pk": self.provider.pk}, - ), - ) + response = self.client.v2[self.provider, "actions"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()), 1) def test_action_retrieve_success(self): - response = self.client.get( - path=reverse( - viewname="v2:provider-action-detail", - kwargs={ - "hostprovider_pk": self.provider.pk, - "pk": self.action.pk, - }, - ), - ) + response = self.client.v2[self.provider, "actions", self.action].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertTrue(response.json()) def test_action_run_success(self): with RunTaskMock() as run_task: - response = self.client.post( - path=reverse( - viewname="v2:provider-action-run", - kwargs={ - "hostprovider_pk": self.provider.pk, - "pk": self.action.pk, - }, - ), + response = self.client.v2[self.provider, "actions", self.action, "run"].post( data={"hostComponentMap": [], "config": {}, "adcmMeta": {}, "isVerbose": False}, ) From 627d29b4fd16e21c62f37a3bc880281a52543ac5 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Thu, 23 May 2024 04:43:59 +0000 Subject: [PATCH 115/208] ADCM-5576 & ADCM-5577 Remove `reverse` from `test_maintenance_mode` and `test_mapping` --- python/api_v2/tests/test_maintenance_mode.py | 16 +- python/api_v2/tests/test_mapping.py | 252 ++++++------------- 2 files changed, 76 insertions(+), 192 deletions(-) diff --git a/python/api_v2/tests/test_maintenance_mode.py b/python/api_v2/tests/test_maintenance_mode.py index 5b0ee86efe..65648f3322 100644 --- a/python/api_v2/tests/test_maintenance_mode.py +++ b/python/api_v2/tests/test_maintenance_mode.py @@ -12,7 +12,6 @@ from cm.models import ClusterObject, Host, MaintenanceMode, ServiceComponent from cm.tests.mocks.task_runner import ExecutionTargetFactoryDummyMock, FailedJobInfo, RunTaskMock -from django.urls import reverse from rest_framework.response import Response from rest_framework.status import HTTP_200_OK @@ -50,25 +49,14 @@ def _do_change_mm_request( case _: raise ValueError(f"Unexpected mm status: {obj.maintenance_mode}") - match type(obj).__name__: - case Host.__name__: - viewname = "v2:host-cluster-maintenance-mode" - kwargs = {"cluster_pk": obj.cluster_id, "pk": obj.pk} - case ClusterObject.__name__: - viewname = "v2:service-maintenance-mode" - kwargs = {"cluster_pk": obj.cluster_id, "pk": obj.pk} - case ServiceComponent.__name__: - viewname = "v2:component-maintenance-mode" - kwargs = {"cluster_pk": obj.cluster_id, "service_pk": obj.service_id, "pk": obj.pk} - case _: - raise ValueError(f"Wrong obj type: {type(obj).__name__}") + object_endpoint = self.client.v2[(obj.cluster, "hosts", obj) if isinstance(obj, Host) else obj] run_task_mock_kwargs = {} if failed_job: run_task_mock_kwargs = {"execution_target_factory": ExecutionTargetFactoryDummyMock(failed_job=failed_job)} with RunTaskMock(**run_task_mock_kwargs) as run_task_mock: - response = self.client.post(path=reverse(viewname=viewname, kwargs=kwargs), data=data) + response = (object_endpoint / "maintenance-mode").post(data=data) return response, run_task_mock diff --git a/python/api_v2/tests/test_mapping.py b/python/api_v2/tests/test_mapping.py index 9ac2773fc3..8e523f9f27 100644 --- a/python/api_v2/tests/test_mapping.py +++ b/python/api_v2/tests/test_mapping.py @@ -19,7 +19,6 @@ MaintenanceMode, ServiceComponent, ) -from django.urls import reverse from rest_framework.response import Response from rest_framework.status import ( HTTP_200_OK, @@ -63,9 +62,7 @@ def setUp(self) -> None: self.test_user = self.create_user(**self.test_user_credentials) def test_list_mapping_success(self): - response = self.client.get( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk}), - ) + response = self.client.v2[self.cluster_1, "mapping"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()), 1) @@ -79,9 +76,8 @@ def test_create_mapping_success(self): {"hostId": self.host_1.pk, "componentId": self.component_1.pk}, ] - response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk}), data=data - ) + response = self.client.v2[self.cluster_1, "mapping"].post(data=data) + self.assertEqual(response.status_code, HTTP_201_CREATED) self.assertEqual(HostComponent.objects.count(), 2) @@ -92,9 +88,8 @@ def test_permissions_mapping_host_another_cluster_role_create_denied(self): data = [ {"hostId": self.host_2.pk, "componentId": self.component_1.pk}, ] - response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk}), data=data - ) + response = self.client.v2[self.cluster_1, "mapping"].post(data=data) + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_model_permissions_mapping_host_another_cluster_role_create_denied(self): @@ -103,9 +98,8 @@ def test_model_permissions_mapping_host_another_cluster_role_create_denied(self) data = [ {"hostId": self.host_2.pk, "componentId": self.component_1.pk}, ] - response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk}), data=data - ) + response = self.client.v2[self.cluster_1, "mapping"].post(data=data) + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_model_and_object_permissions_mapping_host_another_cluster_role_create_denied(self): @@ -113,43 +107,41 @@ def test_model_and_object_permissions_mapping_host_another_cluster_role_create_d with self.grant_permissions(to=self.test_user, on=[], role_name="View any object host-components"): with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="View imports"): with self.grant_permissions(to=self.test_user, on=self.cluster_2, role_name="Cluster Administrator"): - data = [ - {"hostId": self.host_2.pk, "componentId": self.component_1.pk}, - ] - response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk}), data=data + response = self.client.v2[self.cluster_1, "mapping"].post( + data=[ + {"hostId": self.host_2.pk, "componentId": self.component_1.pk}, + ] ) + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_mapping_host_another_cluster_role_retrieve_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="View imports"): with self.grant_permissions(to=self.test_user, on=self.cluster_2, role_name="Cluster Administrator"): - response = self.client.get( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk}), - ) + response = self.client.v2[self.cluster_1, "mapping"].get() + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_model_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object host-components"): - response = self.client.get(path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk})) + response = self.client.v2[self.cluster_1, "mapping"].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_permissions_object_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="View host-components"): - response = self.client.get(path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk})) + response = self.client.v2[self.cluster_1, "mapping"].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_model_permissions_mapping_host_another_cluster_role_retrieve_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object import"): - response = self.client.get( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk}), - ) + response = self.client.v2[self.cluster_1, "mapping"].get() + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_model_and_object_permissions_mapping_host_another_cluster_role_retrieve_denied(self): @@ -157,9 +149,8 @@ def test_model_and_object_permissions_mapping_host_another_cluster_role_retrieve with self.grant_permissions(to=self.test_user, on=[], role_name="View any object import"): with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="View imports"): with self.grant_permissions(to=self.test_user, on=self.cluster_2, role_name="Cluster Administrator"): - response = self.client.get( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk}), - ) + response = self.client.v2[self.cluster_1, "mapping"].get() + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_create_mapping_duplicates_fail(self): @@ -183,9 +174,8 @@ def test_create_mapping_duplicates_fail(self): ) error_msg_part = ", ".join(f"component {map_ids[1]} - host {map_ids[0]}" for map_ids in sorted(duplicate_ids)) - response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk}), data=data - ) + response = self.client.v2[self.cluster_1, "mapping"].post(data=data) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.assertDictEqual( response.json(), @@ -197,26 +187,20 @@ def test_create_mapping_duplicates_fail(self): ) def test_create_empty_mapping_success(self): - response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk}), - data=[], - ) + response = self.client.v2[self.cluster_1, "mapping"].post(data=[]) + self.assertEqual(response.status_code, HTTP_201_CREATED) self.assertEqual(HostComponent.objects.count(), 0) def test_mapping_hosts_success(self): - response = self.client.get( - path=reverse(viewname="v2:cluster-mapping-hosts", kwargs={"pk": self.cluster_1.pk}), - ) + response = self.client.v2[self.cluster_1, "mapping", "hosts"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()), 2) self.assertEqual({host["id"] for host in response.json()}, {self.host_1.pk, self.host_2.pk}) def test_mapping_components_success(self): - response = self.client.get( - path=reverse(viewname="v2:cluster-mapping-components", kwargs={"pk": self.cluster_1.pk}), - ) + response = self.client.v2[self.cluster_1, "mapping", "components"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()), 2) @@ -227,7 +211,7 @@ def test_mapping_components_with_requires_success(self): cluster = self.add_cluster(bundle=bundle, name="cluster_requires") self.add_services_to_cluster(service_names=["hbase", "zookeeper", "hdfs"], cluster=cluster) - response = self.client.get(path=reverse(viewname="v2:cluster-mapping-components", kwargs={"pk": cluster.pk})) + response = self.client.v2[cluster, "mapping", "components"].get() self.assertEqual(response.status_code, HTTP_200_OK) data = response.json() @@ -278,8 +262,7 @@ def test_host_not_in_cluster_fail(self): prototype__name="component_1", service=service_no_requires, cluster=self.cluster ) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), + response: Response = self.client.v2[self.cluster, "mapping"].post( data=[ {"hostId": self.host_1.pk, "componentId": component_1.pk}, {"hostId": self.host_not_in_cluster.pk, "componentId": component_1.pk}, @@ -306,8 +289,7 @@ def test_foreign_host_fail(self): prototype__name="component_1", service=service_no_requires, cluster=self.cluster ) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), + response: Response = self.client.v2[self.cluster, "mapping"].post( data=[ {"hostId": self.host_1.pk, "componentId": component_1.pk}, {"hostId": self.foreign_host.pk, "componentId": component_1.pk}, @@ -334,8 +316,7 @@ def test_non_existent_host_fail(self): ) non_existent_host_pk = self.get_non_existent_pk(model=Host) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), + response: Response = self.client.v2[self.cluster, "mapping"].post( data=[ {"hostId": self.host_1.pk, "componentId": component_1.pk}, {"hostId": non_existent_host_pk, "componentId": component_1.pk}, @@ -364,8 +345,7 @@ def test_non_existent_component_fail(self): ) non_existent_component_pk = self.get_non_existent_pk(model=ServiceComponent) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), + response: Response = self.client.v2[self.cluster, "mapping"].post( data=[ {"hostId": self.host_1.pk, "componentId": component_1.pk}, {"hostId": self.host_1.pk, "componentId": non_existent_component_pk}, @@ -393,8 +373,7 @@ def test_no_required_service_fail(self): prototype__name="component_1", service=service_requires_service, cluster=self.cluster ) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), + response: Response = self.client.v2[self.cluster, "mapping"].post( data=[ {"hostId": self.host_1.pk, "componentId": component_1.pk}, ], @@ -424,8 +403,7 @@ def test_required_service_success(self): prototype__name="component_in_required_service", service=service_required, cluster=self.cluster ) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), + response: Response = self.client.v2[self.cluster, "mapping"].post( data=[ {"hostId": self.host_1.pk, "componentId": component_1.pk}, {"hostId": self.host_1.pk, "componentId": component_in_required_service.pk}, @@ -453,8 +431,7 @@ def test_no_required_component_fail(self): cluster=self.cluster, ) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), + response: Response = self.client.v2[self.cluster, "mapping"].post( data=[ {"hostId": self.host_1.pk, "componentId": component_1.pk}, {"hostId": self.host_1.pk, "componentId": not_required_component.pk}, @@ -494,8 +471,7 @@ def test_no_required_component_but_unrequired_component_present_fail(self): cluster=self.cluster, ) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), + response: Response = self.client.v2[self.cluster, "mapping"].post( data=[ {"hostId": self.host_1.pk, "componentId": component_1.pk}, {"hostId": self.host_1.pk, "componentId": not_required_component.pk}, @@ -535,8 +511,7 @@ def test_required_component_success(self): cluster=self.cluster, ) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), + response: Response = self.client.v2[self.cluster, "mapping"].post( data=[ {"hostId": self.host_1.pk, "componentId": component_1.pk}, {"hostId": self.host_1.pk, "componentId": required_component.pk}, @@ -556,8 +531,7 @@ def test_no_bound_fail(self): cluster=self.cluster, ) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), + response: Response = self.client.v2[self.cluster, "mapping"].post( data=[ {"hostId": self.host_1.pk, "componentId": bound_component.pk}, ], @@ -597,8 +571,7 @@ def test_bound_on_different_host_fail(self): cluster=self.cluster, ) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), + response: Response = self.client.v2[self.cluster, "mapping"].post( data=[ {"hostId": self.host_1.pk, "componentId": bound_component.pk}, {"hostId": self.host_2.pk, "componentId": bound_target_component.pk}, @@ -640,8 +613,7 @@ def test_bound_success(self): cluster=self.cluster, ) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), + response: Response = self.client.v2[self.cluster, "mapping"].post( data=[ {"hostId": self.host_1.pk, "componentId": bound_component.pk}, {"hostId": self.host_1.pk, "componentId": bound_target_component.pk}, @@ -661,9 +633,7 @@ def test_one_constraint_zero_in_hc_fail(self): cluster=self.cluster, ) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), data=[] - ) + response: Response = self.client.v2[self.cluster, "mapping"].post(data=[]) self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertDictEqual( @@ -689,8 +659,7 @@ def test_one_constraint_two_in_hc_fail(self): cluster=self.cluster, ) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), + response: Response = self.client.v2[self.cluster, "mapping"].post( data=[ {"hostId": self.host_1.pk, "componentId": component.pk}, {"hostId": self.host_2.pk, "componentId": component.pk}, @@ -721,11 +690,8 @@ def test_one_constraint_success(self): cluster=self.cluster, ) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), - data=[ - {"hostId": self.host_1.pk, "componentId": component.pk}, - ], + response: Response = self.client.v2[self.cluster, "mapping"].post( + data=[{"hostId": self.host_1.pk, "componentId": component.pk}], ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -741,8 +707,7 @@ def test_zero_one_constraint_two_in_hc_fail(self): cluster=self.cluster, ) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), + response: Response = self.client.v2[self.cluster, "mapping"].post( data=[ {"hostId": self.host_1.pk, "componentId": component.pk}, {"hostId": self.host_2.pk, "componentId": component.pk}, @@ -775,9 +740,7 @@ def test_zero_one_constraint_success(self): for data in ([], [{"hostId": self.host_1.pk, "componentId": component.pk}]): with self.subTest(f"[0,1] constraint, data: {data}"): - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), data=data - ) + response: Response = self.client.v2[self.cluster, "mapping"].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) self.assertEqual(HostComponent.objects.count(), len(data)) @@ -809,10 +772,7 @@ def test_one_two_constraint_fail(self): ), ): with self.subTest(f"[1,2] constraint, data: {data}"): - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), - data=data, - ) + response: Response = self.client.v2[self.cluster, "mapping"].post(data=data) self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertDictEqual( @@ -843,10 +803,7 @@ def test_one_two_constraint_success(self): ], ): with self.subTest(f"[1,2] constraint, data: {data}"): - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), - data=data, - ) + response: Response = self.client.v2[self.cluster, "mapping"].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) self.assertEqual(HostComponent.objects.count(), len(data)) @@ -877,10 +834,7 @@ def test_one_odd_first_variant_constraint_fail(self): ), ): with self.subTest(f"[1,odd] constraint, data: {data}"): - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), - data=data, - ) + response: Response = self.client.v2[self.cluster, "mapping"].post(data=data) self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertDictEqual( @@ -912,10 +866,7 @@ def test_one_odd_first_variant_constraint_success(self): ], ): with self.subTest(f"[1,odd] constraint, data: {data}"): - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), - data=data, - ) + response: Response = self.client.v2[self.cluster, "mapping"].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) self.assertEqual(HostComponent.objects.count(), len(data)) @@ -946,10 +897,7 @@ def test_one_odd_second_variant_constraint_fail(self): ), ): with self.subTest(f"[odd] constraint, data: {data}"): - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), - data=data, - ) + response: Response = self.client.v2[self.cluster, "mapping"].post(data=data) self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertDictEqual( @@ -981,10 +929,7 @@ def test_one_odd_second_variant_constraint_success(self): ], ): with self.subTest(f"[odd] constraint, data: {data}"): - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), - data=data, - ) + response: Response = self.client.v2[self.cluster, "mapping"].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) self.assertEqual(HostComponent.objects.count(), len(data)) @@ -999,8 +944,7 @@ def test_zero_odd_constraint_fail(self): cluster=self.cluster, ) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), + response: Response = self.client.v2[self.cluster, "mapping"].post( data=[ {"hostId": self.host_1.pk, "componentId": component.pk}, {"hostId": self.host_2.pk, "componentId": component.pk}, @@ -1039,10 +983,7 @@ def test_zero_odd_constraint_success(self): ], ): with self.subTest(f"[0,odd], data: {data}"): - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), - data=data, - ) + response: Response = self.client.v2[self.cluster, "mapping"].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) self.assertEqual(HostComponent.objects.count(), len(data)) @@ -1057,10 +998,7 @@ def test_one_plus_constraint_fail(self): cluster=self.cluster, ) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), - data=[], - ) + response: Response = self.client.v2[self.cluster, "mapping"].post(data=[]) self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertDictEqual( @@ -1097,10 +1035,7 @@ def test_one_plus_constraint_success(self): ], ): with self.subTest(f"[1,+], data: {data}"): - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), - data=data, - ) + response: Response = self.client.v2[self.cluster, "mapping"].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) self.assertEqual(HostComponent.objects.count(), len(data)) @@ -1115,8 +1050,7 @@ def test_plus_constraint_fail(self): cluster=self.cluster, ) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), + response: Response = self.client.v2[self.cluster, "mapping"].post( data=[ {"hostId": self.host_1.pk, "componentId": component.pk}, {"hostId": self.host_2.pk, "componentId": component.pk}, @@ -1146,10 +1080,7 @@ def test_plus_constraint_success(self): ) data = [{"hostId": host.pk, "componentId": component.pk} for host in self.cluster.host_set.all()] - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), - data=data, - ) + response: Response = self.client.v2[self.cluster, "mapping"].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) self.assertEqual(HostComponent.objects.count(), len(data)) @@ -1168,11 +1099,8 @@ def test_no_required_service_not_in_hc_fail(self): prototype__name="component_1", service=service_no_requires, cluster=self.cluster ) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), - data=[ - {"hostId": self.host_1.pk, "componentId": component_1.pk}, - ], + response: Response = self.client.v2[self.cluster, "mapping"].post( + data=[{"hostId": self.host_1.pk, "componentId": component_1.pk}], ) self.assertEqual(response.status_code, HTTP_409_CONFLICT) @@ -1200,8 +1128,7 @@ def test_host_in_mm_fail(self): self.host_1.maintenance_mode = MaintenanceMode.ON self.host_1.save(update_fields=["maintenance_mode"]) - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster.pk}), + response: Response = self.client.v2[self.cluster, "mapping"].post( data=[ {"hostId": self.host_1.pk, "componentId": component_1.pk}, {"hostId": self.host_2.pk, "componentId": component_1.pk}, @@ -1240,41 +1167,13 @@ def setUp(self) -> None: def _prepare_config_group_via_api( self, obj: Cluster | ClusterObject | ServiceComponent, hosts: list[Host], name: str, description: str = "" ) -> GroupConfig: - match type(obj).__name__: - case ServiceComponent.__name__: - viewname_create = "v2:component-group-config-list" - viewname_add_host = "v2:component-group-config-hosts-list" - kwargs = {"cluster_pk": obj.cluster.pk, "service_pk": obj.service.pk, "component_pk": obj.pk} - case ClusterObject.__name__: - viewname_create = "v2:service-group-config-list" - viewname_add_host = "v2:service-group-config-hosts-list" - kwargs = { - "cluster_pk": obj.cluster.pk, - "service_pk": obj.pk, - } - case Cluster.__name__: - viewname_create = "v2:cluster-group-config-list" - viewname_add_host = "v2:cluster-group-config-hosts-list" - kwargs = { - "cluster_pk": obj.pk, - } - case _: - raise NotImplementedError(str(obj)) - - response = self.client.post( - path=reverse(viewname=viewname_create, kwargs=kwargs), - data={"name": name, "description": description}, - ) + response = self.client.v2[obj, "config-groups"].post(data={"name": name, "description": description}) self.assertEqual(response.status_code, HTTP_201_CREATED) group_config = GroupConfig.objects.get(pk=response.json()["id"]) - kwargs.update({"group_config_pk": group_config.pk}) for host in hosts: - response = self.client.post( - path=reverse(viewname=viewname_add_host, kwargs=kwargs), - data={"hostId": host.pk}, - ) + response = self.client.v2[group_config, "hosts"].post(data={"hostId": host.pk}) self.assertEqual(response.status_code, HTTP_201_CREATED) group_config.refresh_from_db() @@ -1283,10 +1182,9 @@ def _prepare_config_group_via_api( return group_config def test_host_removed_from_component_group_config_on_mapping_change(self): - mapping_url = reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk}) mapping_data = [{"hostId": self.host_1.pk, "componentId": self.component_1_from_s1.pk}] - response: Response = self.client.post(path=mapping_url, data=mapping_data) + response: Response = self.client.v2[self.cluster_1, "mapping"].post(data=mapping_data) self.assertEqual(response.status_code, HTTP_201_CREATED) group_config = self._prepare_config_group_via_api( @@ -1294,17 +1192,17 @@ def test_host_removed_from_component_group_config_on_mapping_change(self): ) mapping_data[0].update({"componentId": self.component_2_from_s1.pk}) - response: Response = self.client.post(path=mapping_url, data=mapping_data) + response: Response = self.client.v2[self.cluster_1, "mapping"].post(data=mapping_data) self.assertEqual(response.status_code, HTTP_201_CREATED) group_config.refresh_from_db() self.assertEqual(group_config.hosts.count(), 0) def test_host_not_removed_from_component_group_config_on_mapping_remain(self): - mapping_url = reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk}) + endpoint = self.client.v2[self.cluster_1, "mapping"] mapping_data = [{"hostId": self.host_1.pk, "componentId": self.component_1_from_s1.pk}] - response: Response = self.client.post(path=mapping_url, data=mapping_data) + response: Response = endpoint.post(data=mapping_data) self.assertEqual(response.status_code, HTTP_201_CREATED) group_config = self._prepare_config_group_via_api( @@ -1312,17 +1210,17 @@ def test_host_not_removed_from_component_group_config_on_mapping_remain(self): ) mapping_data.append({"hostId": self.host_2.pk, "componentId": self.component_2_from_s1.pk}) - response: Response = self.client.post(path=mapping_url, data=mapping_data) + response: Response = endpoint.post(data=mapping_data) self.assertEqual(response.status_code, HTTP_201_CREATED) group_config.refresh_from_db() self.assertSetEqual(set(group_config.hosts.values_list("pk", flat=True)), {self.host_1.pk}) def test_host_removed_from_service_group_config_on_mapping_change(self): - mapping_url = reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk}) + endpoint = self.client.v2[self.cluster_1] / "mapping" mapping_data = [{"hostId": self.host_1.pk, "componentId": self.component_1_from_s1.pk}] - response: Response = self.client.post(path=mapping_url, data=mapping_data) + response: Response = endpoint.post(data=mapping_data) self.assertEqual(response.status_code, HTTP_201_CREATED) group_config = self._prepare_config_group_via_api( @@ -1330,17 +1228,16 @@ def test_host_removed_from_service_group_config_on_mapping_change(self): ) mapping_data[0].update({"componentId": self.component_2_from_s1.pk}) - response: Response = self.client.post(path=mapping_url, data=mapping_data) + response: Response = endpoint.post(data=mapping_data) self.assertEqual(response.status_code, HTTP_201_CREATED) group_config.refresh_from_db() self.assertEqual(group_config.hosts.count(), 0) def test_host_not_removed_from_service_group_config_on_mapping_remain(self): - mapping_url = reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk}) mapping_data = [{"hostId": self.host_1.pk, "componentId": self.component_1_from_s1.pk}] - response: Response = self.client.post(path=mapping_url, data=mapping_data) + response: Response = self.client.v2[self.cluster_1, "mapping"].post(data=mapping_data) self.assertEqual(response.status_code, HTTP_201_CREATED) group_config = self._prepare_config_group_via_api( @@ -1348,17 +1245,16 @@ def test_host_not_removed_from_service_group_config_on_mapping_remain(self): ) mapping_data.insert(0, {"hostId": self.host_2.pk, "componentId": self.component_2_from_s1.pk}) - response: Response = self.client.post(path=mapping_url, data=mapping_data) + response: Response = self.client.v2[self.cluster_1, "mapping"].post(data=mapping_data) self.assertEqual(response.status_code, HTTP_201_CREATED) group_config.refresh_from_db() self.assertSetEqual(set(group_config.hosts.values_list("pk", flat=True)), {self.host_1.pk}) def test_host_not_removed_from_cluster_group_config_on_mapping_change(self): - mapping_url = reverse(viewname="v2:cluster-mapping", kwargs={"pk": self.cluster_1.pk}) mapping_data = [{"hostId": self.host_1.pk, "componentId": self.component_1_from_s1.pk}] - response: Response = self.client.post(path=mapping_url, data=mapping_data) + response: Response = self.client.v2[self.cluster_1, "mapping"].post(data=mapping_data) self.assertEqual(response.status_code, HTTP_201_CREATED) group_config = self._prepare_config_group_via_api( @@ -1366,7 +1262,7 @@ def test_host_not_removed_from_cluster_group_config_on_mapping_change(self): ) mapping_data[0].update({"componentId": self.component_2_from_s1.pk}) - response: Response = self.client.post(path=mapping_url, data=mapping_data) + response: Response = self.client.v2[self.cluster_1, "mapping"].post(data=mapping_data) self.assertEqual(response.status_code, HTTP_201_CREATED) group_config.refresh_from_db() From 2a405cd9d3c7e372619fdd866f094a340239cd0a Mon Sep 17 00:00:00 2001 From: Egor Araslanov Date: Thu, 23 May 2024 15:55:40 +0500 Subject: [PATCH 116/208] ADCM-5575 Remove `reverse` from `test_jobs` --- python/adcm/tests/client.py | 21 +++++++- python/api_v2/tests/test_jobs.py | 89 ++++++++++---------------------- 2 files changed, 45 insertions(+), 65 deletions(-) diff --git a/python/adcm/tests/client.py b/python/adcm/tests/client.py index 79b1841a4d..41edaad7a0 100644 --- a/python/adcm/tests/client.py +++ b/python/adcm/tests/client.py @@ -14,11 +14,23 @@ from itertools import chain from typing import Protocol -from cm.models import Bundle, Cluster, ClusterObject, GroupConfig, Host, HostProvider, JobLog, ServiceComponent, TaskLog +from cm.models import ( + Bundle, + Cluster, + ClusterObject, + GroupConfig, + Host, + HostProvider, + JobLog, + LogStorage, + ServiceComponent, + TaskLog, +) from rest_framework.response import Response from rest_framework.test import APIClient -PathObject = Bundle | Cluster | ClusterObject | ServiceComponent | HostProvider | Host | TaskLog | JobLog | GroupConfig +_RootPathObject = Bundle | Cluster | HostProvider | Host | TaskLog | JobLog +PathObject = _RootPathObject | ClusterObject | ServiceComponent | LogStorage | GroupConfig class WithID(Protocol): @@ -117,6 +129,11 @@ def __getitem__(self, item: PathObject | tuple[PathObject, str | int | WithID, . # generally it's move clean and obvious when multiple `/` is used, but in here it looks like an overkill return self[path_object.object] / "/".join(("config-groups", str(path_object.id), *tail)) + if isinstance(path_object, LogStorage): + return APINode( + *self._path, "jobs", str(path_object.job_id), "logs", str(path_object.id), *tail, client=self._client + ) + message = f"Node auto-detection isn't defined for {path_object.__class__}" raise NotImplementedError(message) diff --git a/python/api_v2/tests/test_jobs.py b/python/api_v2/tests/test_jobs.py index 5616a6ae77..700aeed68a 100644 --- a/python/api_v2/tests/test_jobs.py +++ b/python/api_v2/tests/test_jobs.py @@ -13,7 +13,6 @@ from datetime import timedelta from unittest.mock import patch -from adcm.tests.base import BaseTestCase from cm.models import ( ADCM, Action, @@ -26,13 +25,14 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.http import HttpResponse -from django.urls import reverse from django.utils import timezone from rest_framework.response import Response from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND +from api_v2.tests.base import BaseAPITestCase -class TestJob(BaseTestCase): + +class TestJob(BaseAPITestCase): TRUNCATED_LOG_MESSAGE = settings.STDOUT_STDERR_TRUNCATED_LOG_MESSAGE def setUp(self) -> None: @@ -128,25 +128,25 @@ def setUp(self) -> None: ) def test_job_list_success(self): - response: Response = self.client.get(path=reverse(viewname="v2:joblog-list")) + response: Response = (self.client.v2 / "jobs").get() + self.assertEqual(len(response.data["results"]), 3) self.assertEqual(response.status_code, HTTP_200_OK) def test_job_retrieve_success(self): - response: Response = self.client.get( - path=reverse(viewname="v2:joblog-detail", kwargs={"pk": self.job_2.pk}), - ) + response: Response = self.client.v2[self.job_2].get() + self.assertEqual(response.data["id"], self.job_2.pk) self.assertEqual(response.status_code, HTTP_200_OK) def test_job_retrieve_not_found_fail(self): - response: Response = self.client.get( - path=reverse(viewname="v2:joblog-detail", kwargs={"pk": self.job_2.pk + 10}), - ) + response: Response = (self.client.v2 / "jobs" / self.get_non_existent_pk(JobLog)).get() + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_job_log_list_success(self): - response: Response = self.client.get(path=reverse(viewname="v2:log-list", kwargs={"job_pk": self.job_1.pk})) + response: Response = self.client.v2[self.job_1, "logs"].get() + self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()), 1) @@ -156,12 +156,8 @@ def test_job_log_detail_success(self): ) with self.subTest("Many lines [CUT]"): - response = self.client.get( - path=reverse( - viewname="v2:log-detail", - kwargs={"job_pk": self.job_with_logs.pk, "pk": self.ansible_stdout_many_lines.pk}, - ) - ) + response = self.client.v2[self.ansible_stdout_many_lines].get() + self.assertEqual(response.status_code, HTTP_200_OK) log = response.json()["content"].splitlines() self.assertEqual(log[0], self.TRUNCATED_LOG_MESSAGE) @@ -171,12 +167,7 @@ def test_job_log_detail_success(self): self.assertTrue(all(line == self.word_10_symbols for line in log_itself)) with self.subTest("Long lines, less than cutoff [UNCUT]"): - response = self.client.get( - path=reverse( - viewname="v2:log-detail", - kwargs={"job_pk": self.job_with_logs.pk, "pk": self.ansible_stderr_long_lines.pk}, - ) - ) + response = self.client.v2[self.ansible_stderr_long_lines].get() self.assertEqual(response.status_code, HTTP_200_OK) log = response.json()["content"].splitlines() self.assertEqual( @@ -192,23 +183,13 @@ def test_job_log_detail_success(self): ) with self.subTest("Custom log [UNCUT]"): - response = self.client.get( - path=reverse( - viewname="v2:log-detail", - kwargs={"job_pk": self.job_with_logs.pk, "pk": self.custom_log_long_and_many_lines.pk}, - ) - ) + response = self.client.v2[self.custom_log_long_and_many_lines].get() self.assertEqual(response.status_code, HTTP_200_OK) log = response.json()["content"] self.assertEqual(log, self.custom_log_long_and_many_lines.body) with self.subTest("Long both ways non-ansible stdout [CUT]"): - response = self.client.get( - path=reverse( - viewname="v2:log-detail", - kwargs={"job_pk": self.job_with_logs.pk, "pk": self.another_stdout_long_and_many_lines.pk}, - ) - ) + response = self.client.v2[self.another_stdout_long_and_many_lines].get() self.assertEqual(response.status_code, HTTP_200_OK) log = response.json()["content"].splitlines() self.assertEqual(log[0], self.TRUNCATED_LOG_MESSAGE) @@ -226,12 +207,7 @@ def test_job_log_detail_success(self): self.assertTrue(all(line == self.word_10_symbols for line in main_log)) with self.subTest("Long one line [CUT]"): - response = self.client.get( - path=reverse( - viewname="v2:log-detail", - kwargs={"job_pk": self.job_with_logs.pk, "pk": self.long_one_liner_log.pk}, - ) - ) + response = self.client.v2[self.long_one_liner_log].get() self.assertEqual(response.status_code, HTTP_200_OK) log = response.json()["content"] self.assertEqual( @@ -248,12 +224,7 @@ def test_adcm_5212_retrieve_log_null_body_cut_success(self) -> None: self.ansible_stdout_many_lines.save(update_fields=["body"]) with patch("api_v2.log_storage.serializers.extract_log_content_from_fs", return_value=log_content): - response = self.client.get( - path=reverse( - viewname="v2:log-detail", - kwargs={"job_pk": self.job_with_logs.pk, "pk": self.ansible_stdout_many_lines.pk}, - ) - ) + response = self.client.v2[self.ansible_stdout_many_lines].get() self.assertEqual(response.status_code, HTTP_200_OK) log = response.json()["content"].splitlines() @@ -264,18 +235,12 @@ def test_adcm_5212_retrieve_log_null_body_cut_success(self) -> None: self.assertTrue(all(line == self.word_10_symbols for line in log_itself)) def test_job_log_download_success(self): - response: Response = self.client.get( - path=reverse(viewname="v2:log-download", kwargs={"job_pk": self.job_1.pk, "pk": self.log_1.pk}) - ) + response: Response = self.client.v2[self.log_1, "download"].get() + self.assertEqual(response.status_code, HTTP_200_OK) def test_adcm_5212_job_log_download_full_success(self) -> None: - response: HttpResponse = self.client.get( - path=reverse( - viewname="v2:log-download", - kwargs={"job_pk": self.job_with_logs.pk, "pk": self.ansible_stdout_many_lines.pk}, - ) - ) + response: HttpResponse = self.client.v2[self.ansible_stdout_many_lines, "download"].get() self.assertEqual(response.status_code, HTTP_200_OK) log = response.content.decode("utf-8") @@ -283,15 +248,13 @@ def test_adcm_5212_job_log_download_full_success(self) -> None: self.assertEqual(self.ansible_stdout_many_lines.body, log) def test_job_log_not_found_download_fail(self): - response: Response = self.client.get( - path=reverse(viewname="v2:log-download", kwargs={"job_pk": self.job_1.pk, "pk": self.log_1.pk + 10}) - ) + response: Response = self.client.v2[self.job_1, "logs", self.get_non_existent_pk(LogStorage), "download"].get() + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_job_terminate_success(self): with patch("cm.models.os.kill") as kill_mock: - response: Response = self.client.post( - path=reverse(viewname="v2:joblog-terminate", kwargs={"pk": self.job_2.pk}), data={} - ) - kill_mock.assert_called() + response: Response = self.client.v2[self.job_2, "terminate"].post(data={}) + self.assertEqual(response.status_code, HTTP_200_OK) + kill_mock.assert_called() From 29d79a2bce90b07103bcf3255c8a80b695399640 Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Thu, 23 May 2024 11:43:05 +0000 Subject: [PATCH 117/208] ADCM-5438: Rework and add unittests for `adcm_state` --- python/ansible/plugins/action/adcm_state.py | 102 +++------------ python/ansible/plugins/lookup/adcm_state.py | 41 ++++-- .../ansible_plugin/executors/_validators.py | 16 ++- python/ansible_plugin/executors/config.py | 18 +-- python/ansible_plugin/executors/state.py | 74 +++++++++++ .../ansible_plugin/tests/test_adcm_state.py | 117 ++++++++++++++++++ python/ansible_plugin/utils.py | 41 ------ 7 files changed, 260 insertions(+), 149 deletions(-) create mode 100644 python/ansible_plugin/executors/state.py create mode 100644 python/ansible_plugin/tests/test_adcm_state.py diff --git a/python/ansible/plugins/action/adcm_state.py b/python/ansible/plugins/action/adcm_state.py index cc810ee7af..8f5475924c 100644 --- a/python/ansible/plugins/action/adcm_state.py +++ b/python/ansible/plugins/action/adcm_state.py @@ -17,16 +17,6 @@ import adcm.init_django # noqa: F401, isort:skip -from ansible_plugin.utils import ( - ContextActionModule, - set_cluster_state, - set_component_state, - set_component_state_by_name, - set_host_state, - set_provider_state, - set_service_state, - set_service_state_by_name, -) ANSIBLE_METADATA = {"metadata_version": "1.1", "supported_by": "Arenadata"} @@ -44,6 +34,7 @@ choises: - cluster - service + - component - provider - host description: type of object which should be changed @@ -59,8 +50,18 @@ description: useful in cluster context only. In that context you are able to set the state value for a service belongs to the cluster. + - option-name: component_name + required: false + type: string + description: Name of the component + + - option-name: host_id + required: false + type: int + description: ID of the host + notes: - - If type is 'service', there is no needs to specify service_name + - If type is 'service' ('component') there is no needs to specify service_name (component_name) """ EXAMPLES = r""" @@ -82,76 +83,9 @@ """ -class ActionModule(ContextActionModule): - TRANSFERS_FILES = False - _VALID_ARGS = frozenset(("type", "service_name", "component_name", "state", "host_id")) - _MANDATORY_ARGS = ("type", "state") - - def _do_cluster(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call(set_cluster_state, context["cluster_id"], self._task.args["state"]) - res["state"] = self._task.args["state"] - return res - - def _do_service_by_name(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - set_service_state_by_name, - context["cluster_id"], - self._task.args["service_name"], - self._task.args["state"], - ) - res["state"] = self._task.args["state"] - return res - - def _do_service(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - set_service_state, - context["cluster_id"], - context["service_id"], - self._task.args["state"], - ) - res["state"] = self._task.args["state"] - return res - - def _do_host(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - set_host_state, - context["host_id"], - self._task.args["state"], - ) - res["state"] = self._task.args["state"] - return res - - def _do_host_from_provider(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - set_host_state, - self._task.args["host_id"], - self._task.args["state"], - ) - res["state"] = self._task.args["state"] - return res - - def _do_provider(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call(set_provider_state, context["provider_id"], self._task.args["state"]) - res["state"] = self._task.args["state"] - return res - - def _do_component_by_name(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - set_component_state_by_name, - context["cluster_id"], - context["service_id"], - self._task.args["component_name"], - self._task.args.get("service_name", None), - self._task.args["state"], - ) - res["state"] = self._task.args["state"] - return res - - def _do_component(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - set_component_state, - context["component_id"], - self._task.args["state"], - ) - res["state"] = self._task.args["state"] - return res +from ansible_plugin.base import ADCMAnsiblePlugin +from ansible_plugin.executors.state import ADCMStatePluginExecutor + + +class ActionModule(ADCMAnsiblePlugin): + executor_class = ADCMStatePluginExecutor diff --git a/python/ansible/plugins/lookup/adcm_state.py b/python/ansible/plugins/lookup/adcm_state.py index 360cb53063..1a880be63d 100644 --- a/python/ansible/plugins/lookup/adcm_state.py +++ b/python/ansible/plugins/lookup/adcm_state.py @@ -14,18 +14,14 @@ from ansible.errors import AnsibleError from ansible.plugins.lookup import LookupBase +from cm.models import ADCMEntity, Cluster, ClusterObject, Host, HostProvider +from cm.status_api import send_object_update_event sys.path.append("/adcm/python") import adcm.init_django # noqa: F401, isort:skip -from ansible_plugin.utils import ( - set_cluster_state, - set_host_state, - set_provider_state, - set_service_state, - set_service_state_by_name, -) +from ansible_plugin.utils import get_service_by_name from cm.logger import logger DOCUMENTATION = """ @@ -99,3 +95,34 @@ def run(self, terms, variables=None, **kwargs): raise AnsibleError(f"unknown object type: {terms[0]}") ret.append(res) return ret + + +def set_cluster_state(cluster_id, state): + obj = Cluster.obj.get(id=cluster_id) + return _set_object_state(obj, state) + + +def set_host_state(host_id, state): + obj = Host.obj.get(id=host_id) + return _set_object_state(obj, state) + + +def set_provider_state(provider_id, state): + obj = HostProvider.obj.get(id=provider_id) + return _set_object_state(obj, state) + + +def set_service_state_by_name(cluster_id, service_name, state): + obj = get_service_by_name(cluster_id, service_name) + return _set_object_state(obj, state) + + +def set_service_state(cluster_id, service_id, state): + obj = ClusterObject.obj.get(id=service_id, cluster__id=cluster_id, prototype__type="service") + return _set_object_state(obj, state) + + +def _set_object_state(obj: ADCMEntity, state: str) -> ADCMEntity: + obj.set_state(state) + send_object_update_event(object_=obj, changes={"state": state}) + return obj diff --git a/python/ansible_plugin/executors/_validators.py b/python/ansible_plugin/executors/_validators.py index 80f253d629..1359d9e3dc 100644 --- a/python/ansible_plugin/executors/_validators.py +++ b/python/ansible_plugin/executors/_validators.py @@ -14,7 +14,8 @@ from core.types import ADCMCoreType, CoreObjectDescriptor -from ansible_plugin.errors import PluginTargetError +from ansible_plugin.base import VarsContextSection +from ansible_plugin.errors import PluginTargetError, PluginValidationError _CLUSTER_TYPES = {ADCMCoreType.CLUSTER, ADCMCoreType.SERVICE, ADCMCoreType.COMPONENT} _HOSTPROVIDER_TYPES = {ADCMCoreType.HOSTPROVIDER, ADCMCoreType.HOST} @@ -51,3 +52,16 @@ def validate_target_allowed_for_context_owner( return PluginTargetError(message="Wrong context. One host can't be changed from another's context.") return None + + +def validate_type_is_present( + context_owner: CoreObjectDescriptor, + context: VarsContextSection, # noqa: ARG001 + raw_arguments: dict, +) -> PluginValidationError | None: + _ = context, context_owner + + if "type" not in raw_arguments: + return PluginValidationError(message="`type` is required") + + return None diff --git a/python/ansible_plugin/executors/config.py b/python/ansible_plugin/executors/config.py index f1bf298bfc..875141b3dd 100644 --- a/python/ansible_plugin/executors/config.py +++ b/python/ansible_plugin/executors/config.py @@ -33,11 +33,10 @@ PluginExecutorConfig, RuntimeEnvironment, TargetConfig, - VarsContextSection, from_arguments_root, ) -from ansible_plugin.errors import PluginIncorrectCallError, PluginTargetDetectionError, PluginValidationError -from ansible_plugin.executors._validators import validate_target_allowed_for_context_owner +from ansible_plugin.errors import PluginIncorrectCallError, PluginTargetDetectionError +from ansible_plugin.executors._validators import validate_target_allowed_for_context_owner, validate_type_is_present from ansible_plugin.utils import cast_to_type # don't want to typehint due to serialization problems and serialization priority @@ -97,19 +96,6 @@ class ChangeConfigReturn(TypedDict): value: dict[str, ParamValue] | ParamValue -def validate_type_is_present( - context_owner: CoreObjectDescriptor, - context: VarsContextSection, # noqa: ARG001 - raw_arguments: dict, -) -> PluginValidationError | None: - _ = context, context_owner - - if "type" not in raw_arguments: - return PluginValidationError(message="`type` is required") - - return None - - class ADCMConfigPluginExecutor(ADCMAnsiblePluginExecutor[ChangeConfigArguments, ChangeConfigReturn]): _config = PluginExecutorConfig( arguments=ArgumentsConfig(represent_as=ChangeConfigArguments), diff --git a/python/ansible_plugin/executors/state.py b/python/ansible_plugin/executors/state.py new file mode 100644 index 0000000000..34c32decb6 --- /dev/null +++ b/python/ansible_plugin/executors/state.py @@ -0,0 +1,74 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from contextlib import suppress +from typing import Collection, TypedDict + +from cm.converters import core_type_to_model +from cm.status_api import send_object_update_event +from core.types import CoreObjectDescriptor +from django.db.models import ObjectDoesNotExist +from pydantic import BaseModel + +from ansible_plugin.base import ( + ADCMAnsiblePluginExecutor, + ArgumentsConfig, + CallResult, + PluginExecutorConfig, + RuntimeEnvironment, + TargetConfig, + from_arguments_root, +) +from ansible_plugin.errors import PluginTargetDetectionError +from ansible_plugin.executors._validators import validate_target_allowed_for_context_owner, validate_type_is_present + + +class ChangeStateArguments(BaseModel): + state: str + + +class ChangeStateReturnValue(TypedDict): + state: str + + +class ADCMStatePluginExecutor(ADCMAnsiblePluginExecutor[ChangeStateArguments, ChangeStateReturnValue]): + _config = PluginExecutorConfig( + arguments=ArgumentsConfig(represent_as=ChangeStateArguments), + target=TargetConfig(detectors=(from_arguments_root,), validators=(validate_type_is_present,)), + ) + + def __call__( + self, + targets: Collection[CoreObjectDescriptor], + arguments: ChangeStateArguments, + runtime: RuntimeEnvironment, + ) -> CallResult[ChangeStateReturnValue]: + target, *_ = targets + + if error := validate_target_allowed_for_context_owner(context_owner=runtime.context_owner, target=target): + return CallResult(value={}, changed=False, error=error) + + try: + target_object = core_type_to_model(core_type=target.type).objects.get(pk=target.id) + except ObjectDoesNotExist: + return CallResult( + value=None, + changed=False, + error=PluginTargetDetectionError(message=f'Failed to locate {target.type} with id "{target.id}"'), + ) + + target_object.set_state(state=arguments.state) + + with suppress(Exception): + send_object_update_event(object_=target, changes={"state": arguments.state}) + + return CallResult(value=ChangeStateReturnValue(state=arguments.state), changed=True, error=None) diff --git a/python/ansible_plugin/tests/test_adcm_state.py b/python/ansible_plugin/tests/test_adcm_state.py new file mode 100644 index 0000000000..e5c560ba35 --- /dev/null +++ b/python/ansible_plugin/tests/test_adcm_state.py @@ -0,0 +1,117 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import TypeAlias + +from cm.models import Cluster, ClusterObject, Host, HostProvider, ServiceComponent +from cm.services.job.run.repo import JobRepoImpl + +from ansible_plugin.executors.state import ADCMStatePluginExecutor +from ansible_plugin.tests.base import BaseTestEffectsOfADCMAnsiblePlugins + +ADCM_OBJECT: TypeAlias = Cluster | ClusterObject | ServiceComponent | HostProvider | Host + + +class TestADCMStatePluginExecutor(BaseTestEffectsOfADCMAnsiblePlugins): + def setUp(self) -> None: + super().setUp() + + services = self.add_services_to_cluster(service_names=["service_1", "service_2"], cluster=self.cluster) + self.service = services.get(prototype__name="service_1") + self.component = self.service.servicecomponent_set.first() + + self.new_state = "brand new object's state" + + provider = self.add_provider(bundle=self.provider_bundle, name="Control provider") + cluster = self.add_cluster(bundle=self.cluster_bundle, name="Control cluster") + service_2 = services.get(prototype__name="service_2") + other_components = ServiceComponent.objects.filter(cluster=self.cluster).exclude(pk=self.component.pk) + self.control_objects = [cluster, service_2, *list(other_components), provider, self.host_2] + + def _execute_test( + self, owner: ADCM_OBJECT, target: ADCM_OBJECT, call_arguments: str | dict, expect_fail: bool = False + ) -> None: + old_state = target.state + + task = self.prepare_task(owner=owner, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMStatePluginExecutor, + call_arguments=call_arguments, + call_context=job, + ) + result = executor.execute() + + if expect_fail: + target_state = old_state + self.assertIsNotNone(result.error) + self.assertFalse(result.changed) + else: + target_state = self.new_state + self.assertIsNone(result.error) + self.assertTrue(result.changed) + + target.refresh_from_db() + self.assertEqual(target.state, target_state) + + def _check_control_group(self): + states = set() + for object_ in self.control_objects: + object_.refresh_from_db() + states.add(object_.state) + + self.assertEqual(states, {"created"}) + + def test_states(self): + for owner, target, call_args in ( + (self.cluster, self.cluster, {"type": "cluster", "state": self.new_state}), + ( + self.cluster, + self.service, + {"type": "service", "service_name": self.service.name, "state": self.new_state}, + ), + ( + self.cluster, + self.component, + { + "type": "component", + "service_name": self.service.name, + "component_name": self.component.name, + "state": self.new_state, + }, + ), + (self.service, self.cluster, {"type": "cluster", "state": self.new_state}), + (self.service, self.service, {"type": "service", "state": self.new_state}), + ( + self.service, + self.component, + {"type": "component", "component_name": self.component.name, "state": self.new_state}, + ), + (self.component, self.cluster, {"type": "cluster", "state": self.new_state}), + (self.component, self.service, {"type": "service", "state": self.new_state}), + (self.component, self.component, {"type": "component", "state": self.new_state}), + (self.provider, self.provider, {"type": "provider", "state": self.new_state}), + (self.provider, self.host_1, {"type": "host", "host_id": self.host_1.pk, "state": self.new_state}), + (self.host_1, self.provider, {"type": "provider", "state": self.new_state}), + (self.host_1, self.host_1, {"type": "host", "state": self.new_state}), + ): + with self.subTest(owner=owner, target=target, call_args=call_args): + self._execute_test(owner=owner, target=target, call_arguments=call_args) + self._check_control_group() + + def test_forbidden_owner_targert_pairs(self): + for owner, target, call_args in ( + (self.host_1, self.host_2, {"type": "host", "host_id": self.host_2.pk, "state": self.new_state}), + ): + with self.subTest(owner=owner, target=target, call_args=call_args): + self._execute_test(owner=owner, target=target, call_arguments=call_args, expect_fail=True) diff --git a/python/ansible_plugin/utils.py b/python/ansible_plugin/utils.py index 052e26ce29..7fd96f262c 100644 --- a/python/ansible_plugin/utils.py +++ b/python/ansible_plugin/utils.py @@ -241,47 +241,6 @@ def get_service_by_name(cluster_id, service_name): return ClusterObject.obj.get(cluster=cluster, prototype=proto) -def _set_object_state(obj: ADCMEntity, state: str) -> ADCMEntity: - obj.set_state(state) - send_object_update_event(object_=obj, changes={"state": state}) - return obj - - -def set_cluster_state(cluster_id, state): - obj = Cluster.obj.get(id=cluster_id) - return _set_object_state(obj, state) - - -def set_host_state(host_id, state): - obj = Host.obj.get(id=host_id) - return _set_object_state(obj, state) - - -def set_component_state(component_id, state): - obj = ServiceComponent.obj.get(id=component_id) - return _set_object_state(obj, state) - - -def set_component_state_by_name(cluster_id, service_id, component_name, service_name, state): - obj = get_component_by_name(cluster_id, service_id, component_name, service_name) - return _set_object_state(obj, state) - - -def set_provider_state(provider_id, state): - obj = HostProvider.obj.get(id=provider_id) - return _set_object_state(obj, state) - - -def set_service_state_by_name(cluster_id, service_name, state): - obj = get_service_by_name(cluster_id, service_name) - return _set_object_state(obj, state) - - -def set_service_state(cluster_id, service_id, state): - obj = ClusterObject.obj.get(id=service_id, cluster__id=cluster_id, prototype__type="service") - return _set_object_state(obj, state) - - def _set_object_multi_state(obj: ADCMEntity, multi_state: str) -> ADCMEntity: obj.set_multi_state(multi_state) return obj From e70475c05b7e7d4451e81067a16489a119752731 Mon Sep 17 00:00:00 2001 From: Pavel Nesterovkiy Date: Thu, 23 May 2024 13:22:15 +0000 Subject: [PATCH 118/208] feature/ADCM-5510 configurationTree added whiteSpaceLabel https://tracker.yandex.ru/ADCM-5510 --- .../ConfigurationTree/ConfigurationTree.constants.ts | 1 + .../ConfigurationTree/NodeContent/FieldNodeContent.tsx | 7 ++++++- adcm-web/app/src/utils/validationsUtils.ts | 4 ++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.constants.ts b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.constants.ts index 147f55dbb8..92b3ee40c3 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.constants.ts +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.constants.ts @@ -1,6 +1,7 @@ export const nullStub = ''; export const secretStub = ''; export const emptyStringStub = ''; +export const whiteSpaceStringStub = ''; export const rootNodeKey = '/'; export const rootNodeTitle = 'Configuration'; diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeContent.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeContent.tsx index 0eca2195a4..47396ef309 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeContent.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeContent.tsx @@ -1,7 +1,7 @@ import { useCallback, useRef, useMemo, useState } from 'react'; import { IconButton, Tooltip } from '@uikit'; import { ConfigurationField, ConfigurationNodeView } from '../../ConfigurationEditor.types'; -import { emptyStringStub, nullStub, secretStub } from '../ConfigurationTree.constants'; +import { emptyStringStub, nullStub, secretStub, whiteSpaceStringStub } from '../ConfigurationTree.constants'; import s from '../ConfigurationTree.module.scss'; import cn from 'classnames'; import ActivationAttribute from './ActivationAttribute/ActivationAttribute'; @@ -9,6 +9,7 @@ import SynchronizedAttribute from './SyncronizedAttribute/SynchronizedAttribute' import { ChangeConfigurationNodeHandler, ChangeFieldAttributesHandler } from '../ConfigurationTree.types'; import MarkerIcon from '@uikit/MarkerIcon/MarkerIcon'; import { isPrimitiveValueSet } from '@models/json'; +import { isWhiteSpaceOnly } from '@utils/validationsUtils.ts'; interface FieldNodeContentProps { node: ConfigurationNodeView; @@ -90,6 +91,10 @@ const FieldNodeContent = ({ return emptyStringStub; } + if (isWhiteSpaceOnly(fieldNodeData.value.toString())) { + return whiteSpaceStringStub; + } + if (adcmMeta.isSecret) { return secretStub; } diff --git a/adcm-web/app/src/utils/validationsUtils.ts b/adcm-web/app/src/utils/validationsUtils.ts index acfbaebfcd..328467c2d1 100644 --- a/adcm-web/app/src/utils/validationsUtils.ts +++ b/adcm-web/app/src/utils/validationsUtils.ts @@ -42,3 +42,7 @@ interface Named { export const isNameUniq = (name: string, items: T[]): boolean => { return !items.some((item) => item.name === name); }; + +export const isWhiteSpaceOnly = (value: string) => { + return /^\s+$/.test(value); +}; From add79495ed3da1b8d4f3409aeb85e1551991a2d6 Mon Sep 17 00:00:00 2001 From: Pavel Nesterovkiy Date: Thu, 23 May 2024 13:50:16 +0000 Subject: [PATCH 119/208] Feature/adcm 5312 v2 https://tracker.yandex.ru/ADCM-5312 --- adcm-web/app/.eslintrc.json | 2 + adcm-web/app/src/App.tsx | 1 + .../components/common/job/JobLog/JobLog.tsx | 10 +- .../JobLog/JobLogText/JobLogText.module.scss | 2 + .../job/JobLog/JobLogText/JobLogText.tsx | 56 ++++++- .../pages/JobsPage/JobPage/JobPage.tsx | 1 - .../JobPageChildJobsTable.module.scss | 7 + .../JobPageChildJobsTable.tsx | 147 +++++++++++++++--- .../JobPage/JobPageLog/JobPageLog.tsx | 82 +++++++--- .../JobPageLog/useRequestJobLogPage.ts | 20 ++- .../CodeHighlighterV2.module.scss | 84 +++++----- .../CodeHighlighterV2.stories.tsx | 2 +- .../CodeHighlighterV2/CodeHighlighterV2.tsx | 44 +++--- .../uikit/ScrollBar/ScrollBar.stories.tsx | 88 ++++++----- .../components/uikit/ScrollBar/ScrollBar.tsx | 8 +- .../uikit/ScrollBar/ScrollBarHelper.ts | 4 +- .../ScrollBar/ScrollBarStories.module.scss | 18 ++- .../uikit/ScrollBar/Scrollbar.module.scss | 23 ++- .../components/uikit/ScrollBar/Scroller.tsx | 29 ++++ .../uikit/ScrollBar/useScrollBar.ts | 22 ++- .../uikit/Switch/ExpandableSwitch.module.scss | 40 +++++ .../uikit/Switch/ExpandableSwitch.stories.tsx | 56 +++++++ .../uikit/Switch/ExpandableSwitch.tsx | 18 +++ adcm-web/app/src/hooks/useExpandableTable.ts | 31 +++- adcm-web/app/src/routes/routes.ts | 12 ++ adcm-web/app/src/scss/vars.scss | 1 + 26 files changed, 627 insertions(+), 181 deletions(-) create mode 100644 adcm-web/app/src/components/uikit/ScrollBar/Scroller.tsx create mode 100644 adcm-web/app/src/components/uikit/Switch/ExpandableSwitch.module.scss create mode 100644 adcm-web/app/src/components/uikit/Switch/ExpandableSwitch.stories.tsx create mode 100644 adcm-web/app/src/components/uikit/Switch/ExpandableSwitch.tsx diff --git a/adcm-web/app/.eslintrc.json b/adcm-web/app/.eslintrc.json index 16cdbbb961..043bfd561a 100644 --- a/adcm-web/app/.eslintrc.json +++ b/adcm-web/app/.eslintrc.json @@ -157,6 +157,8 @@ "ssl", "statusable", "stderr", + "scrollend", + "scroller", "stdout", "str", "svg", diff --git a/adcm-web/app/src/App.tsx b/adcm-web/app/src/App.tsx index 0df9293d9c..302187cfad 100644 --- a/adcm-web/app/src/App.tsx +++ b/adcm-web/app/src/App.tsx @@ -189,6 +189,7 @@ function App() { } /> } /> + } /> }> } /> } /> diff --git a/adcm-web/app/src/components/common/job/JobLog/JobLog.tsx b/adcm-web/app/src/components/common/job/JobLog/JobLog.tsx index 19f859c08a..ef0b9d74f1 100644 --- a/adcm-web/app/src/components/common/job/JobLog/JobLog.tsx +++ b/adcm-web/app/src/components/common/job/JobLog/JobLog.tsx @@ -7,22 +7,24 @@ import JobLogText from './JobLogText/JobLogText'; interface JobLogProps { job: AdcmJob; jobLog: AdcmJobLogItem; + isAutoScroll: boolean; + setIsAutoScroll?: (isAutoScroll: boolean) => void; } -const JobLog: React.FC = ({ job, jobLog }) => { +const JobLog: React.FC = ({ job, jobLog, isAutoScroll, setIsAutoScroll }) => { return ( <> - {renderLog({ job, jobLog })} + {renderLog({ job, jobLog, isAutoScroll, setIsAutoScroll })} {jobLog.content && } ); }; export default JobLog; -const renderLog = ({ job, jobLog }: JobLogProps) => { +const renderLog = ({ job, jobLog, isAutoScroll, setIsAutoScroll }: JobLogProps) => { if (jobLog.type === AdcmJobLogType.Check) { return ; } - return ; + return ; }; diff --git a/adcm-web/app/src/components/common/job/JobLog/JobLogText/JobLogText.module.scss b/adcm-web/app/src/components/common/job/JobLog/JobLogText/JobLogText.module.scss index 3a5d46bcd1..871108cdc9 100644 --- a/adcm-web/app/src/components/common/job/JobLog/JobLogText/JobLogText.module.scss +++ b/adcm-web/app/src/components/common/job/JobLog/JobLogText/JobLogText.module.scss @@ -1,3 +1,5 @@ .jobLogText { margin-top: var(--base-margin-v); + max-height: 400px; + display: flex; } diff --git a/adcm-web/app/src/components/common/job/JobLog/JobLogText/JobLogText.tsx b/adcm-web/app/src/components/common/job/JobLog/JobLogText/JobLogText.tsx index b110729168..59cc1a9f13 100644 --- a/adcm-web/app/src/components/common/job/JobLog/JobLogText/JobLogText.tsx +++ b/adcm-web/app/src/components/common/job/JobLog/JobLogText/JobLogText.tsx @@ -1,16 +1,64 @@ -import React from 'react'; +import React, { RefObject, useEffect, useRef } from 'react'; import CodeHighlighterV2 from '@uikit/CodeHighlighterV2/CodeHighlighterV2'; -import { AdcmJobLogItemCustom, AdcmJobLogItemStd } from '@models/adcm'; +import type { AdcmJobLogItemCustom, AdcmJobLogItemStd } from '@models/adcm'; import s from './JobLogText.module.scss'; interface JobLogTextProps { log: AdcmJobLogItemStd | AdcmJobLogItemCustom; + isAutoScroll: boolean; + setIsAutoScroll?: (isAutoScroll: boolean) => void; } -const JobLogText: React.FC = ({ log }) => { +const JobLogText: React.FC = ({ log, isAutoScroll, setIsAutoScroll }) => { const content = log.content?.trim() || ''; const language = log.format === 'json' ? 'json' : 'bash'; + const highlighterRef: RefObject = useRef(null); + const isUserScrollRef = useRef(true); + const timer = useRef(null); - return ; + useEffect(() => { + if (!highlighterRef?.current || !isAutoScroll) return; + + timer.current = window.setTimeout(() => { + if (!isAutoScroll) return; + requestAnimationFrame(() => { + isUserScrollRef.current = false; + highlighterRef?.current?.scrollTo({ left: 0, top: highlighterRef?.current.scrollHeight, behavior: 'smooth' }); + }); + }, 260); + + return () => { + if (!timer.current) return; + window.clearTimeout(timer.current); + }; + }, [highlighterRef, isAutoScroll, log]); + + useEffect(() => { + if (!setIsAutoScroll || !highlighterRef.current) return; + const onUserScrollHandler = () => { + if (isUserScrollRef.current) { + setIsAutoScroll(false); + } + }; + + const onEndHandler = () => { + isUserScrollRef.current = true; + }; + + const current = highlighterRef.current; + + if (isAutoScroll) { + current.addEventListener('scroll', onUserScrollHandler); + current.addEventListener('scrollend', onEndHandler); + } + + return () => { + current?.removeEventListener('scroll', onUserScrollHandler); + current?.removeEventListener('scrollend', onEndHandler); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAutoScroll, highlighterRef]); + + return ; }; export default JobLogText; diff --git a/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPage.tsx b/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPage.tsx index 2838b04bf1..d322ab83b6 100644 --- a/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPage.tsx +++ b/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPage.tsx @@ -10,7 +10,6 @@ import JobPageStopJobDialog from './Dialogs/JobPageStopJobDialog'; const JobPage: React.FC = () => { const { task, dispatch } = useRequestJobPage(); - useEffect(() => { if (task.displayName) { const jobBreadcrumbs = [{ href: '/jobs', label: 'Jobs' }, { label: task.displayName }]; diff --git a/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPageChildJobsTable/JobPageChildJobsTable.module.scss b/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPageChildJobsTable/JobPageChildJobsTable.module.scss index 350ac50033..61d4df58f6 100644 --- a/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPageChildJobsTable/JobPageChildJobsTable.module.scss +++ b/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPageChildJobsTable/JobPageChildJobsTable.module.scss @@ -14,3 +14,10 @@ font-weight: 500; } } + +.jobsPageAutoScroll { + position: fixed; + right: 20px; + bottom: 40px; + cursor: pointer; +} diff --git a/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPageChildJobsTable/JobPageChildJobsTable.tsx b/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPageChildJobsTable/JobPageChildJobsTable.tsx index 5836691019..1989fcef63 100644 --- a/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPageChildJobsTable/JobPageChildJobsTable.tsx +++ b/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPageChildJobsTable/JobPageChildJobsTable.tsx @@ -1,14 +1,18 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Table, ExpandableRowComponent } from '@uikit'; import { useDispatch, useExpandableTable, useStore } from '@hooks'; import { columns } from './JobPageChildJobsTable.constants'; import { setSortParams } from '@store/adcm/jobs/jobsTableSlice'; import { SortParams } from '@uikit/types/list.types'; import { openStopDialog } from '@store/adcm/jobs/jobsActionsSlice'; -import { AdcmJob } from '@models/adcm'; +import type { AdcmJob } from '@models/adcm'; +import { AdcmJobStatus } from '@models/adcm'; import JobPageLog from '../JobPageLog/JobPageLog'; import { orElseGet } from '@utils/checkUtils'; import TaskChildRow from './TaskChildRow/TaskChildRow'; +import ExpandableSwitch from '@uikit/Switch/ExpandableSwitch'; +import s from './JobPageChildJobsTable.module.scss'; +import { useParams } from 'react-router-dom'; const callForJob = (el: HTMLElement, callback: (jobId: number) => void) => { // eslint want that jobId (in camelCase), but JSX demands set data attributes in lowercase @@ -23,24 +27,118 @@ const JobPageChildJobsTable = () => { const dispatch = useDispatch(); const task = useStore((s) => s.adcm.jobs.task); const isTaskLoading = useStore((s) => s.adcm.jobs.isTaskLoading); + const [lastViewedJobId, setLastViewedJobId] = useState(null); + const { expandableRows, toggleRow, changeExpandedRowsState, setExpandableRows } = useExpandableTable(); - const { expandableRows, toggleRow } = useExpandableTable(); + const isUserScrollRef = useRef(false); + const isAutoScrollRef = useRef(true); + const [isAutoScrollState, setIsAutoScrollState] = useState(true); + const isTaskWasStartedRef = useRef(false); + const props = useParams(); + const { withAutoStop } = props; + + const setIsAutoScroll = (state: boolean) => { + if (!withAutoStop) return; + setIsAutoScrollState(state); + isAutoScrollRef.current = state; + }; + + const toggleIsAutoScroll = () => { + isAutoScrollRef.current = !isAutoScrollRef.current; + setIsAutoScrollState((prev) => !prev); + + if (!isAutoScrollRef.current || !lastViewedJobId) return; + setExpandableRows(new Set([lastViewedJobId])); + }; + + const updateRowsState = () => { + if (!task.childJobs || !isAutoScrollRef.current || !isTaskWasStartedRef.current) return; + + if (lastViewedJobId === null) { + const firstJobId = task.childJobs[0].id; + setLastViewedJobId(firstJobId); + changeExpandedRowsState([{ key: firstJobId, isExpand: true }]); + return; + } + + const lastViewedJob = task.childJobs.find((job) => job.id === lastViewedJobId); + + if ( + !lastViewedJob || + lastViewedJob.status === AdcmJobStatus.Running || + lastViewedJob.status === AdcmJobStatus.Created + ) { + return; + } + + const nextJob = + task.childJobs.findLast((child) => child.status === AdcmJobStatus.Running) || + task.childJobs.find((child) => child.status === AdcmJobStatus.Created); + + if (!nextJob) return; + setLastViewedJobId(nextJob.id); + setExpandableRows(new Set([nextJob.id])); + }; + + useEffect(() => { + const onUserScrollHandler = () => { + if (isUserScrollRef.current) { + setIsAutoScroll(false); + } + }; + + const onEndHandler = () => { + isUserScrollRef.current = true; + }; + + if (task.status === AdcmJobStatus.Running && isAutoScrollState) { + window.addEventListener('scroll', onUserScrollHandler); + window.addEventListener('scrollend', onEndHandler); + } + + return () => { + window.removeEventListener('scroll', onUserScrollHandler); + window.removeEventListener('scrollend', onEndHandler); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [task.status, isAutoScrollRef, isAutoScrollState]); + + useEffect(() => { + if (task.status === AdcmJobStatus.Running && lastViewedJobId === null && !isTaskWasStartedRef.current) { + isTaskWasStartedRef.current = true; + } + + if (task.status === AdcmJobStatus.Failed) { + const lastFailedJob = task.childJobs.findLast((child) => child.status === AdcmJobStatus.Running); + if (!lastFailedJob) return; + + changeExpandedRowsState([{ key: lastFailedJob.id, isExpand: true }]); + return; + } + + updateRowsState(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [task, lastViewedJobId, isAutoScrollState]); const handleExpandClick = useCallback( ({ currentTarget }: React.MouseEvent) => { callForJob(currentTarget, (jobId) => { toggleRow(jobId); + setIsAutoScroll(false); }); }, - [toggleRow], + // eslint-disable-next-line react-hooks/exhaustive-deps + [task, toggleRow], ); const handleStopClick = useCallback( ({ currentTarget }: React.MouseEvent) => { callForJob(currentTarget, (jobId) => { + setIsAutoScroll(false); dispatch(openStopDialog(jobId)); }); }, + // eslint-disable-next-line react-hooks/exhaustive-deps [dispatch], ); @@ -52,20 +150,33 @@ const JobPageChildJobsTable = () => { ); return ( - - {task.childJobs?.map((job) => { - return ( - } - > - - - ); - })} -
+ <> + + {task.childJobs?.map((job) => { + return ( + + } + > + + + ); + })} +
+
+ +
+ ); }; diff --git a/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPageLog/JobPageLog.tsx b/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPageLog/JobPageLog.tsx index cc11bc6bcd..e99da7e2ba 100644 --- a/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPageLog/JobPageLog.tsx +++ b/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPageLog/JobPageLog.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo, useEffect, RefObject, useRef, MutableRefObject } from 'react'; import { useStore } from '@hooks'; import { useRequestJobLogPage } from './useRequestJobLogPage'; import JobLog from '@commonComponents/job/JobLog/JobLog'; @@ -6,49 +6,93 @@ import JobLogsTabs from '@commonComponents/job/JobLogsTabs/JobLogsTabs'; import { AdcmJobLogItem } from '@models/adcm'; import s from './JobPageLog.module.scss'; import { Spinner } from '@uikit'; +import { defaultSpinnerDelay } from '@constants'; const defaultLogs: AdcmJobLogItem[] = []; interface JobPageLogProps { id: number; isLinkEmpty?: boolean; + isStarted?: boolean; + isAutoScroll?: boolean; + setIsAutoScroll?: (isAutoScroll: boolean) => void; + isUserScrollRef?: MutableRefObject; } -const JobPageLog: React.FC = ({ id }) => { +const JobPageLog: React.FC = ({ + id, + isAutoScroll = false, + setIsAutoScroll, + isUserScrollRef, + isStarted = false, +}) => { useRequestJobLogPage(id); - + const jobRef: RefObject = useRef(null); const childJob = useStore(({ adcm }) => adcm.jobs.task.childJobs.find((job) => job.id === id)); const logs = useStore(({ adcm }) => adcm.jobs.jobLogs[id] ?? defaultLogs); const [currentLogId, setCurrentLogId] = useState(null); const [isLoadedLogs, setIsLoadedLogs] = useState(false); + const [isMinDelayEnded, setIsMinDelayEnded] = useState(false); + + useEffect(() => { + setTimeout(() => { + setIsMinDelayEnded(true); + }, defaultSpinnerDelay); - useEffect( - () => () => { + return () => { setIsLoadedLogs(false); - }, - [], - ); + setIsMinDelayEnded(false); + }; + }, []); + + useEffect(() => { + if (!isAutoScroll || logs.length === 0 || !isUserScrollRef || !jobRef?.current || !isStarted) return; + + const parentTr = jobRef.current.closest('tr'); + const prevSiblingTr = parentTr?.previousSibling as HTMLDivElement; + const tableContainer = jobRef.current.closest('[class*="tableContainer"]') as HTMLDivElement; + + if (!parentTr || !tableContainer || !prevSiblingTr) return; + const scrollTopTo = tableContainer.offsetTop + parentTr.offsetTop - (window.innerHeight - parentTr.scrollHeight); + + isUserScrollRef.current = false; + + window.scrollTo({ + left: 0, + top: scrollTopTo, + behavior: 'smooth', + }); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [logs, jobRef, jobRef?.current?.scrollHeight]); useEffect(() => { - if (!isLoadedLogs && logs !== defaultLogs) { + if (isMinDelayEnded && !isLoadedLogs && logs !== defaultLogs) { setCurrentLogId(logs[0]?.id || null); setIsLoadedLogs(true); } - }, [logs, isLoadedLogs]); + }, [logs, isLoadedLogs, isMinDelayEnded]); const log = useMemo(() => { return logs.find(({ id }) => currentLogId === id); }, [logs, currentLogId]); + const onTabChange = (id: number | null) => { + setIsAutoScroll?.(false); + setCurrentLogId(id); + }; + return ( -
- +
+ {isLoadedLogs && ( + + )} {!isLoadedLogs && (
@@ -56,7 +100,9 @@ const JobPageLog: React.FC = ({ id }) => {
)} - {childJob && log && } + {childJob && log && ( + + )}
); }; diff --git a/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPageLog/useRequestJobLogPage.ts b/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPageLog/useRequestJobLogPage.ts index af0929aab6..1a1ad4a8b1 100644 --- a/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPageLog/useRequestJobLogPage.ts +++ b/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPageLog/useRequestJobLogPage.ts @@ -2,16 +2,34 @@ import { useDebounce, useDispatch, useRequestTimer, useStore } from '@hooks'; import { getJobLog } from '@store/adcm/jobs/jobsSlice'; import { defaultDebounceDelay } from '@constants'; import { AdcmJobStatus } from '@models/adcm'; +import { useMemo, useState } from 'react'; export const useRequestJobLogPage = (id: number | undefined) => { const dispatch = useDispatch(); const task = useStore(({ adcm }) => adcm.jobs.task); const requestFrequency = useStore(({ adcm }) => adcm.jobsTable.requestFrequency); + const [isLastUpdated, setIsLastUpdated] = useState(false); const debounceGetData = useDebounce(() => { if (!id) return; dispatch(getJobLog(id)); }, defaultDebounceDelay); - useRequestTimer(debounceGetData, debounceGetData, task.status === AdcmJobStatus.Running ? requestFrequency : 0, [id]); + const isNeedUpdate = useMemo(() => { + if (!task.childJobs || isLastUpdated) return false; + + const curJob = task.childJobs.find((job) => job.id == id); + + if (!curJob) return false; + if (curJob.status === AdcmJobStatus.Created) return true; + + if (curJob.status !== AdcmJobStatus.Running && task.status !== AdcmJobStatus.Running) { + setIsLastUpdated(true); + return true; + } + + return true; + }, [task, isLastUpdated, id]); + + useRequestTimer(debounceGetData, debounceGetData, isNeedUpdate ? requestFrequency : 0, [id]); }; diff --git a/adcm-web/app/src/components/uikit/CodeHighlighterV2/CodeHighlighterV2.module.scss b/adcm-web/app/src/components/uikit/CodeHighlighterV2/CodeHighlighterV2.module.scss index b1dccb1799..bb03f04411 100644 --- a/adcm-web/app/src/components/uikit/CodeHighlighterV2/CodeHighlighterV2.module.scss +++ b/adcm-web/app/src/components/uikit/CodeHighlighterV2/CodeHighlighterV2.module.scss @@ -1,39 +1,39 @@ :global { body.theme-dark { - --code-highlite-textV2: var(--color-xwhite-off); - --code-highlite-borderV2: var(--color-xdark); - --code-highlite-shadowV2: unset; - --code-highlite-numbersV2: var(--color-xgray-light); - --code-highlite-numbers-backgroundV2: #181619; - --code-highlite-scrollbar-thumbV2: var(--color-xdark); + --code-highlight-textV2: var(--color-xwhite-off); + --code-highlight-borderV2: var(--color-xdark); + --code-highlight-shadowV2: unset; + --code-highlight-numbersV2: var(--color-xgray-light); + --code-highlight-numbers-backgroundV2: #181619; + --code-highlight-scrollbar-thumbV2: var(--color-xdark); + --code-highlight-backgroundV2: rgba(17,15,18,1); + --code-highlight-background-hoverV2: rgba(50,50,57,0.5); } body.theme-light { - --code-highlite-textV2: var(--color-xdark); - --code-highlite-borderV2: var(--color-xgray); - --code-highlite-shadowV2: 0px 15px 20px rgba(169, 180, 203, 0.05), inset 0px 0px 3px rgba(119, 231, 255, 0.12); - --code-highlite-numbersV2: #c1bfbf; - --code-highlite-numbers-backgroundV2: var(--color-xgray-alt); - --code-highlite-scrollbar-thumbV2: var(--color-xgray-alt); + --code-highlight-textV2: var(--color-xdark); + --code-highlight-borderV2: var(--color-xgray); + --code-highlight-shadowV2: 0px 15px 20px rgba(169, 180, 203, 0.05), inset 0px 0px 3px rgba(119, 231, 255, 0.12); + --code-highlight-numbersV2: #c1bfbf; + --code-highlight-numbers-backgroundV2: var(--color-xgray-alt); + --code-highlight-scrollbar-thumbV2: var(--color-xgray-alt); + --code-highlight-backgroundV2: rgba(227, 234, 238, 0.5); + --code-highlight-background-hoverV2: rgba(255,255,255,1); } } body { - --code-highlite-lines-padding: 33px; + --code-highlight-lines-padding: 33px; } :global(body.theme-dark) { .codeHighlighter { - --code-higlite-calc-line-width: calc(var(--code-highlite-lines-width) + var(--code-highlite-lines-padding)); - --code-highlite-backgroundV2: linear-gradient(90deg, rgba(0,0,0,0) var(--code-higlite-calc-line-width), rgba(17,15,18,1) var(--code-higlite-calc-line-width)); - --code-highlite-background-hoverV2: linear-gradient(90deg, rgba(0,0,0,0) var(--code-higlite-calc-line-width), rgba(50,50,57,0.5) var(--code-higlite-calc-line-width)); + --code-higlite-calc-line-width: calc(var(--code-highlight-lines-width) + var(--code-highlight-lines-padding)); } } :global(body.theme-light) { .codeHighlighter { - --code-higlite-calc-line-width: calc(var(--code-highlite-lines-width) + var(--code-highlite-lines-padding)); - --code-highlite-backgroundV2: linear-gradient(90deg, rgba(0,0,0,0) var(--code-higlite-calc-line-width), rgba(227, 234, 238, 0.5) var(--code-higlite-calc-line-width)); - --code-highlite-background-hoverV2: linear-gradient(90deg, rgba(0,0,0,0) var(--code-higlite-calc-line-width), rgba(255,255,255,1) var(--code-higlite-calc-line-width)); + --code-higlite-calc-line-width: calc(var(--code-highlight-lines-width) + var(--code-highlight-lines-padding)); } } @@ -41,7 +41,10 @@ body { margin-bottom: 20px; position: relative; height: inherit; + border: 1px solid var(--code-highlight-borderV2); border-radius: 10px; + background: var(--code-highlight-backgroundV2); + overflow: hidden; & .codeHighlighter__showSecretBtn { display: none; @@ -59,13 +62,15 @@ body { } &:hover { + background: var(--code-highlight-background-hoverV2); + & .codeHighlighter__copyBtn { display: block; &:global(.isCopied) { &:hover + .codeHighlighterWrapper { - border-color: var(--code-highlite-borderV2); - background: var(--code-highlite-background-hoverV2); + border-color: var(--code-highlight-borderV2); + background: var(--code-highlight-background-hoverV2); } } @@ -78,26 +83,22 @@ body { display: block; } } + + div[data-scroll='scroll-track-horizontal'] { + left: calc(var(--default-scroll-width) + var(--code-higlite-calc-line-width)); + width: calc(100% - var(--code-higlite-calc-line-width) - calc(var(--default-scroll-width) * 2) + 4px); + z-index: 2; + } } .codeHighlighterWrapper { - max-height: 100%; - height: fit-content; - background: var(--code-highlite-backgroundV2); display: flex; width: 100%; - overflow: auto; justify-content: flex-start; font-weight: 400; font-size: 13px; - border: 1px solid var(--code-highlite-borderV2); - border-radius: 10px; position: relative; - &:hover { - background: var(--code-highlite-background-hoverV2); - } - * { line-height: 17px; font-family: 'JetBrains Mono', monospace; @@ -105,7 +106,7 @@ body { } .codeHighlighterLinesWrapper { - background: var(--code-highlite-numbers-backgroundV2); + background: var(--code-highlight-numbers-backgroundV2); z-index: 2; position: sticky; left: 0; @@ -114,9 +115,9 @@ body { .codeHighlighterLines { height: fit-content; padding: 24px 16px; - color: var(--code-highlite-numbersV2); - background: var(--code-highlite-numbers-backgroundV2); - border-right: 1px solid var(--code-highlite-borderV2); + color: var(--code-highlight-numbersV2); + background: var(--code-highlight-numbers-backgroundV2); + border-right: 1px solid var(--code-highlight-borderV2); box-sizing: border-box; } @@ -155,16 +156,3 @@ pre.codeHighlighterCode { font-weight: 400; font-size: 13px; } - -div.highlighterScrollVertical { - top: var(--default-scroll-height); - height: calc(100% - calc(var(--default-scroll-height) * 2)); - z-index: 2; -} - -div.highlighterScrollHorizontal { - left: calc(var(--default-scroll-width) + var(--code-higlite-calc-line-width) + 0px); - width: calc(100% - var(--code-higlite-calc-line-width) - calc(var(--default-scroll-width) * 2) + 1px); - z-index: 2; -} - diff --git a/adcm-web/app/src/components/uikit/CodeHighlighterV2/CodeHighlighterV2.stories.tsx b/adcm-web/app/src/components/uikit/CodeHighlighterV2/CodeHighlighterV2.stories.tsx index 31356c6ba5..6900b0c887 100644 --- a/adcm-web/app/src/components/uikit/CodeHighlighterV2/CodeHighlighterV2.stories.tsx +++ b/adcm-web/app/src/components/uikit/CodeHighlighterV2/CodeHighlighterV2.stories.tsx @@ -91,7 +91,7 @@ export const CodeHighlighterV2Example: Story = { }, render: (args) => { return ( -
+
); diff --git a/adcm-web/app/src/components/uikit/CodeHighlighterV2/CodeHighlighterV2.tsx b/adcm-web/app/src/components/uikit/CodeHighlighterV2/CodeHighlighterV2.tsx index e230b7f03f..e25b4428bb 100644 --- a/adcm-web/app/src/components/uikit/CodeHighlighterV2/CodeHighlighterV2.tsx +++ b/adcm-web/app/src/components/uikit/CodeHighlighterV2/CodeHighlighterV2.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useMemo, useRef, useState } from 'react'; +import React, { ReactNode, RefObject, useMemo, useRef, useState } from 'react'; import { refractor } from 'refractor'; import { getLines, getParsedCode } from '@uikit/CodeHighlighterV2/CodeHighlighterHelperV2'; import './CodeHighlighterTemeV2.scss'; @@ -6,8 +6,7 @@ import s from './CodeHighlighterV2.module.scss'; import cn from 'classnames'; import CopyButton from '@uikit/CodeHighlighter/CopyButton/CopyButton'; import IconButton from '@uikit/IconButton/IconButton'; -import ScrollBar from '@uikit/ScrollBar/ScrollBar'; -import ScrollBarWrapper from '@uikit/ScrollBar/ScrollBarWrapper'; +import Scroller from '@uikit/ScrollBar/Scroller'; export interface CodeHighlighterV2Props { code: string; @@ -17,6 +16,7 @@ export interface CodeHighlighterV2Props { className?: string; dataTestPrefix?: string; codeOverlay?: ReactNode; + contentRef?: RefObject; } const CodeHighlighterV2 = ({ @@ -27,10 +27,12 @@ const CodeHighlighterV2 = ({ className, dataTestPrefix = '', codeOverlay, + contentRef, }: CodeHighlighterV2Props) => { const [isSecretVisible, setIsSecretVisible] = useState(!isSecret); const prepCode = useMemo(() => (isSecretVisible ? code : code.replace(/./g, '*')), [code, isSecretVisible]); - const contentRef = useRef(null); + const localContentRef = useRef(null); + const internalContentRef = contentRef ? contentRef : localContentRef; const { parsedCode, lines, patchWidth } = useMemo(() => { const lines = getLines(prepCode); @@ -44,8 +46,8 @@ const CodeHighlighterV2 = ({ }, [prepCode, lang]); const wrapperStyles = { - maxHeight: '100%', - '--code-highlite-lines-width': `${patchWidth}px`, + animation: 'none', + '--code-highlight-lines-width': `${patchWidth}px`, }; const toggleShowSecret = () => { @@ -64,25 +66,21 @@ const CodeHighlighterV2 = ({ onClick={toggleShowSecret} /> )} -
-
-
- {lines.map((lineNum) => ( -
{lineNum}
- ))} + +
+
+
+ {lines.map((lineNum) => ( +
{lineNum}
+ ))} +
+
+
+
{parsedCode}
+ {codeOverlay &&
{codeOverlay}
}
-
-
{parsedCode}
- {codeOverlay &&
{codeOverlay}
} -
-
- - - - - - +
); }; diff --git a/adcm-web/app/src/components/uikit/ScrollBar/ScrollBar.stories.tsx b/adcm-web/app/src/components/uikit/ScrollBar/ScrollBar.stories.tsx index 4c82aae676..3ad0d2359f 100644 --- a/adcm-web/app/src/components/uikit/ScrollBar/ScrollBar.stories.tsx +++ b/adcm-web/app/src/components/uikit/ScrollBar/ScrollBar.stories.tsx @@ -1,9 +1,10 @@ -import React, { PropsWithChildren, RefObject, useRef } from 'react'; +import React, { PropsWithChildren, useRef } from 'react'; import { Meta, StoryObj } from '@storybook/react'; import ScrollBar from '@uikit/ScrollBar/ScrollBar'; import ScrollBarWrapper from '@uikit/ScrollBar/ScrollBarWrapper'; import s from './ScrollBarStories.module.scss'; import { Text } from '@uikit'; +import Scroller from '@uikit/ScrollBar/Scroller'; type Story = StoryObj; @@ -21,14 +22,10 @@ export const ScrollBarStory: Story = { render: () => , }; -interface TextContentProps extends PropsWithChildren { - contentRef: RefObject; -} - -const TextContent = ({ contentRef, children }: TextContentProps) => { +const TextContent = ({ children }: PropsWithChildren) => { return ( -
- Chicken Coder: the incredible coder journey! +
+ Chicken Coder: the incredible coder journey!

Once upon a time, in the bustling town of Techtopia, there lived a peculiar chicken named Cluckbert. Unlike the other chickens in the coop, Cluckbert was not content with the simple life of pecking at grains and strutting @@ -84,7 +81,7 @@ const TextContent = ({ contentRef, children }: TextContentProps) => { possibilities.

- Unchecked Lines: The Story of Matilda's Code Catastrophe + Unchecked Lines: The Story of Matilda's Code Catastrophe

Once upon a time, in the bustling world of tech, there was a small but talented mouse named Matilda who worked as a software engineer in a vibrant company called ByteTech Inc. Matilda was known for her exceptional coding @@ -135,38 +132,59 @@ const TextContent = ({ contentRef, children }: TextContentProps) => { ); }; +// Use 'Scroller' component if you ok with default scroll bar position with orientation as 'horizontal = bottom', 'vertical = right' +// Just put wrapper with your content as child. +// Also, you can customize track and thumb through passing class which override props by using something like +// +// .someClassName { +// div['scroll-track-${orientation}'] { some props } +// } + +// For use scroll bar without Scroller component, keep next structure: +//

+//
this wrapper should have scrolling content wrapper as only child +//
+// some content which we want to scroll +//
+//
+//
wrapper for set position for scroll bar +// +//
+//
+ const ScrollBarExample = () => { const contentRef = useRef(null); - const contentRefSecond = useRef(null); return ( <> - Default scrollbar + Scrollbar with "Scroller"
- - - - - - - - -
-

- "Test long text" is a phrase often used to verify the display and formatting of text in various contexts, - particularly in software development. It's a placeholder for content, allowing developers to assess how - text appears within a layout or interface before finalizing it with actual content. -

-
-
+ + +
Custom scroll bar, with container stretchable by width
+
+ +
+

+ "Test long text" is a phrase often used to verify the display and formatting of text in various + contexts, particularly in software development. It's a placeholder for content, allowing developers to + assess how text appears within a layout or interface before finalizing it with actual content. +

+
+
+
+
@@ -175,7 +193,7 @@ const ScrollBarExample = () => { @@ -184,20 +202,10 @@ const ScrollBarExample = () => { - - -
-

- "Test long text" is a phrase often used to verify the display and formatting of text in various contexts, - particularly in software development. It's a placeholder for content, allowing developers to assess how - text appears within a layout or interface before finalizing it with actual content. -

-
-
); diff --git a/adcm-web/app/src/components/uikit/ScrollBar/ScrollBar.tsx b/adcm-web/app/src/components/uikit/ScrollBar/ScrollBar.tsx index c41757a802..87cbafa253 100644 --- a/adcm-web/app/src/components/uikit/ScrollBar/ScrollBar.tsx +++ b/adcm-web/app/src/components/uikit/ScrollBar/ScrollBar.tsx @@ -18,7 +18,13 @@ const ScrollBar = ({ contentRef, orientation, trackClasses, thumbClasses, thumbC }; return ( -
+
); diff --git a/adcm-web/app/src/components/uikit/ScrollBar/ScrollBarHelper.ts b/adcm-web/app/src/components/uikit/ScrollBar/ScrollBarHelper.ts index 121ebd366e..14fee35bea 100644 --- a/adcm-web/app/src/components/uikit/ScrollBar/ScrollBarHelper.ts +++ b/adcm-web/app/src/components/uikit/ScrollBar/ScrollBarHelper.ts @@ -26,9 +26,9 @@ export const getScrollData = ({ contentRef, trackRef, thumbRef, orientation }: S if (contentMagnitude !== contentScrollMagnitude) { thumbRef.current.style[lowerCasedMagnitudeName] = `${(contentMagnitude * 100) / contentScrollMagnitude}%`; - trackRef.current.style.display = ''; + trackRef.current.style.visibility = ''; } else { - trackRef.current.style.display = 'none'; + trackRef.current.style.visibility = 'hidden'; } return { diff --git a/adcm-web/app/src/components/uikit/ScrollBar/ScrollBarStories.module.scss b/adcm-web/app/src/components/uikit/ScrollBar/ScrollBarStories.module.scss index 4bd804d297..ea9ed7b303 100644 --- a/adcm-web/app/src/components/uikit/ScrollBar/ScrollBarStories.module.scss +++ b/adcm-web/app/src/components/uikit/ScrollBar/ScrollBarStories.module.scss @@ -17,18 +17,20 @@ height: 500px; font-size: 18px; line-height: 30px; - margin-bottom: 30px; + margin-bottom: 40px; + margin-top: 10px; + border: 1px solid dimgray; + padding: 20px; +} +.scrollMainWrapper { + height: inherit; + overflow: auto; } .contentWrapper { - box-sizing: border-box; - overflow: auto; width: 100%; - border: 1px solid rebeccapurple; - padding: 40px; - position: relative; - height: 100%; + height: inherit; background: var(--scroll-content-wrapper); } @@ -37,7 +39,7 @@ } .stretchableBlock { - min-width: 700px; + min-width: 900px; width: 100%; } diff --git a/adcm-web/app/src/components/uikit/ScrollBar/Scrollbar.module.scss b/adcm-web/app/src/components/uikit/ScrollBar/Scrollbar.module.scss index c11c81887a..18f850cc12 100644 --- a/adcm-web/app/src/components/uikit/ScrollBar/Scrollbar.module.scss +++ b/adcm-web/app/src/components/uikit/ScrollBar/Scrollbar.module.scss @@ -49,7 +49,6 @@ body { .scrollBarWrapper { position: absolute; - &_top { top: 0; left: 0; @@ -78,3 +77,25 @@ body { height: 100%; } } + +.scrollerContainer { + overflow: auto; + height: inherit; + width: 100%; + + .scrollBarWrapper { + &_right, + &_left { + top: var(--default-scroll-height); + height: calc(100% - calc(var(--default-scroll-height) * 2)); + z-index: 2; + } + + &_top, + &_bottom { + left: var(--default-scroll-height); + width: calc(100% - calc(var(--default-scroll-width) * 2)); + z-index: 2; + } + } +} diff --git a/adcm-web/app/src/components/uikit/ScrollBar/Scroller.tsx b/adcm-web/app/src/components/uikit/ScrollBar/Scroller.tsx new file mode 100644 index 0000000000..472a9a409b --- /dev/null +++ b/adcm-web/app/src/components/uikit/ScrollBar/Scroller.tsx @@ -0,0 +1,29 @@ +import React, { PropsWithChildren, RefObject, useRef } from 'react'; +import ScrollBar from '@uikit/ScrollBar/ScrollBar'; +import ScrollBarWrapper from '@uikit/ScrollBar/ScrollBarWrapper'; +import cn from 'classnames'; +import s from './Scrollbar.module.scss'; + +interface ScrollerProps extends PropsWithChildren { + className?: string; + forwardRef?: RefObject; +} + +const Scroller = ({ children, className = '', forwardRef }: ScrollerProps) => { + const localRef = useRef(null); + const wrapperRef = forwardRef ? forwardRef : localRef; + + return ( +
+ {children} + + + + + + +
+ ); +}; + +export default Scroller; diff --git a/adcm-web/app/src/components/uikit/ScrollBar/useScrollBar.ts b/adcm-web/app/src/components/uikit/ScrollBar/useScrollBar.ts index c36f8a2cfd..6112c712a3 100644 --- a/adcm-web/app/src/components/uikit/ScrollBar/useScrollBar.ts +++ b/adcm-web/app/src/components/uikit/ScrollBar/useScrollBar.ts @@ -5,17 +5,25 @@ import { defaultScrollData, getScrollData, useObserver } from '@uikit/ScrollBar/ export const useScrollBar = ({ orientation, contentRef, thumbRef, trackRef }: ScrollDataProps) => { const [scrollData, setScrollData] = useState(defaultScrollData); const initialMousePosition = useRef({ x: 0, y: 0 }); + const [contentWrapper, setContentWrapper] = useState(null); + + // Problem place. Here used useEffect with useState instead of useMemo because with some reason + // contentWrapper = useMemo( () => contentRef?.current?.children[0] || null, [contentRef]); doesn't update, so + // contentWrapper always equal null, but useEffect + useState works well. + useEffect(() => { + setContentWrapper(contentRef?.current?.children[0] || null); + }, [contentRef]); const updateScrollData = useCallback(() => { - if (!contentRef.current || !thumbRef.current || !thumbRef.current) return; + if (!contentRef.current || !thumbRef.current || !trackRef.current) return; setScrollData(getScrollData({ contentRef, trackRef, thumbRef, orientation })); - }, [contentRef, orientation, trackRef, thumbRef]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contentRef?.current, orientation, trackRef, thumbRef]); const resizeObserver = useObserver(updateScrollData); const scrollHandler = useCallback(() => { if (!thumbRef?.current || !contentRef?.current) return; - thumbRef.current.style.transform = `translate${scrollData.upperCasedAxis}(${ contentRef.current[`scroll${scrollData.scrollTo}`] / scrollData.scrollFactor }px)`; @@ -71,13 +79,13 @@ export const useScrollBar = ({ orientation, contentRef, thumbRef, trackRef }: Sc }, [contentRef, thumbRef, clearDocumentHandlers, scrollHandler, onMouseDown]); useEffect(() => { - if (!contentRef?.current) return; - const content = contentRef.current; + if (contentWrapper === null) return; + const content = contentWrapper; resizeObserver.observe(content); return () => { - if (content) return; + if (!content) return; resizeObserver.unobserve(content); }; - }, [contentRef, resizeObserver]); + }, [contentWrapper, resizeObserver]); }; diff --git a/adcm-web/app/src/components/uikit/Switch/ExpandableSwitch.module.scss b/adcm-web/app/src/components/uikit/Switch/ExpandableSwitch.module.scss new file mode 100644 index 0000000000..915f9edba3 --- /dev/null +++ b/adcm-web/app/src/components/uikit/Switch/ExpandableSwitch.module.scss @@ -0,0 +1,40 @@ +:global { + body.theme-dark { + --expandable-switch-color: var(--color-xgray-lighter); + --expandable-switch-border-color: var(--color-xdark); + --expandable-switch-bg-color: var(--color-new-light); + --expandable-switch-shadow: 0px 20px 20px 0px rgba(2, 1, 17, 0.20); + } + body.theme-light { + --expandable-switch-color: var(--color-xgray-new-darker); + --expandable-switch-border-color: var(--color-stroke-light); + --expandable-switch-bg-color: var(--color-xgray-alt); + --expandable-switch-shadow: 0px 20px 20px 0px rgba(199, 196, 226, 0.20); + } +} + + +.expandableSwitch { + display: flex; + width: fit-content; + border-radius: 12px; + border: 1px solid var(--expandable-switch-border-color); + background: var(--expandable-switch-bg-color); + box-shadow: var(--expandable-switch-shadow); + padding: 8px; + gap: 10px; + color: var(--expandable-switch-color); + height: 20px; + justify-content: flex-end; + align-items: center; + overflow: hidden; + z-index: 1; + + &:hover > &__label { + display: initial; + } + + &__label { + display: none; + } +} diff --git a/adcm-web/app/src/components/uikit/Switch/ExpandableSwitch.stories.tsx b/adcm-web/app/src/components/uikit/Switch/ExpandableSwitch.stories.tsx new file mode 100644 index 0000000000..a4f30abb50 --- /dev/null +++ b/adcm-web/app/src/components/uikit/Switch/ExpandableSwitch.stories.tsx @@ -0,0 +1,56 @@ +import React, { useState } from 'react'; +import ExpandableSwitch, { ExpandableSwitchProps } from './ExpandableSwitch'; +import { Meta, StoryObj } from '@storybook/react'; + +type Story = StoryObj; + +export default { + title: 'uikit/Switch', + component: ExpandableSwitch, + argTypes: { + disabled: { + description: 'Disabled', + control: { type: 'boolean' }, + }, + size: { + defaultValue: 'medium', + options: ['medium', 'small'], + control: { type: 'radio' }, + }, + variant: { + defaultValue: 'green', + options: ['green', 'blue'], + control: { type: 'radio' }, + }, + }, +} as Meta; + +const SwitchWithHooks = ({ ...args }: Partial) => { + const [checked, setChecked] = useState(false); + + const handleChangeCheckedBox = (event: React.ChangeEvent) => { + setChecked(event.target.checked); + }; + + return ( + + ); +}; + +export const ExpandableSwitchStory: Story = { + args: { + size: 'medium', + variant: 'green', + disabled: false, + }, + render: ({ ...args }) => { + return ; + }, +}; diff --git a/adcm-web/app/src/components/uikit/Switch/ExpandableSwitch.tsx b/adcm-web/app/src/components/uikit/Switch/ExpandableSwitch.tsx new file mode 100644 index 0000000000..3d63005a0f --- /dev/null +++ b/adcm-web/app/src/components/uikit/Switch/ExpandableSwitch.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import Switch, { SwitchProps } from './Switch'; +import s from './ExpandableSwitch.module.scss'; + +export interface ExpandableSwitchProps extends SwitchProps { + label: string; +} + +const ExpandableSwitch = ({ label, ...rest }: ExpandableSwitchProps) => { + return ( +
+
{label}
+ +
+ ); +}; + +export default ExpandableSwitch; diff --git a/adcm-web/app/src/hooks/useExpandableTable.ts b/adcm-web/app/src/hooks/useExpandableTable.ts index 295dc3f51d..4f9a1dda00 100644 --- a/adcm-web/app/src/hooks/useExpandableTable.ts +++ b/adcm-web/app/src/hooks/useExpandableTable.ts @@ -6,12 +6,33 @@ export const useExpandableTable = () => { const toggleRow = useCallback( (key: T) => { setExpandableRows((prev) => { - if (prev.has(key)) { - prev.delete(key); + const prepState = new Set([...prev]); + + if (prepState.has(key)) { + prepState.delete(key); } else { - prev.add(key); + prepState.add(key); } - return new Set([...prev]); + return prepState; + }); + }, + [setExpandableRows], + ); + + const changeExpandedRowsState = useCallback( + (rows: { key: T; isExpand: boolean }[]) => { + setExpandableRows((prev) => { + const prepState = new Set([...prev]); + + rows.forEach((row) => { + if (prepState.has(row.key) && !row.isExpand) { + prepState.delete(row.key); + } else if (row.isExpand) { + prepState.add(row.key); + } + }); + + return prepState; }); }, [setExpandableRows], @@ -20,5 +41,7 @@ export const useExpandableTable = () => { return { expandableRows, toggleRow, + changeExpandedRowsState, + setExpandableRows, }; }; diff --git a/adcm-web/app/src/routes/routes.ts b/adcm-web/app/src/routes/routes.ts index 567bdc3e04..167992e58b 100644 --- a/adcm-web/app/src/routes/routes.ts +++ b/adcm-web/app/src/routes/routes.ts @@ -497,6 +497,18 @@ const routes: RoutesConfigs = { }, ], }, + '/jobs/:jobId/:withAutoStop': { + pageTitle: 'Jobs', + breadcrumbs: [ + { + href: '/jobs', + label: 'Jobs', + }, + { + label: ':jobId', + }, + ], + }, // Access manager '/access-manager': { diff --git a/adcm-web/app/src/scss/vars.scss b/adcm-web/app/src/scss/vars.scss index 5b5e08e4a8..069cc8bfff 100644 --- a/adcm-web/app/src/scss/vars.scss +++ b/adcm-web/app/src/scss/vars.scss @@ -33,6 +33,7 @@ --color-xgray-reading: #545E68; --color-xgray-medium: rgba(112, 122, 131, 0.2); --color-xgray: #e0e2e4; + --color-xgray-new-darker: #707179; --color-hijack: #47474F; From 0e6e1edabf86f995ae47477cf762d5363873ac69 Mon Sep 17 00:00:00 2001 From: Artem Starovoitov Date: Thu, 23 May 2024 22:15:12 +0000 Subject: [PATCH 120/208] ADCM-5543: Add DB options for PostgreSQL --- python/adcm/settings.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/python/adcm/settings.py b/python/adcm/settings.py index fabe6ded1b..400c8c20c4 100644 --- a/python/adcm/settings.py +++ b/python/adcm/settings.py @@ -10,6 +10,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from json import JSONDecodeError from pathlib import Path import os import sys @@ -157,6 +158,18 @@ }, } + +def get_db_options() -> dict: + db_options = os.getenv("DB_OPTIONS", "{}") + try: + parsed = json.loads(db_options) + except JSONDecodeError as json_error: + raise RuntimeError("Failed to decode DB_OPTIONS as JSON") from json_error + if not isinstance(parsed, dict): + raise RuntimeError("DB_OPTIONS should be dict") # noqa: TRY004 + return parsed + + DB_PASS = os.getenv("DB_PASS") DB_NAME = os.getenv("DB_NAME") DB_USER = os.getenv("DB_USER") @@ -172,6 +185,7 @@ "HOST": DB_HOST, "PORT": DB_PORT, "CONN_MAX_AGE": 60, + "OPTIONS": get_db_options(), } else: DB_DEFAULT = { From cd8ad47f56560eea1a2bd0faebf46db9f3af9292 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Fri, 24 May 2024 09:18:30 +0000 Subject: [PATCH 121/208] ADCM-5431 Rework `adcm_custom_log` --- .../ansible/plugins/action/adcm_custom_log.py | 80 ++++++---------- python/ansible_plugin/base.py | 6 +- .../executors/add_host_to_cluster.py | 2 +- python/ansible_plugin/executors/custom_log.py | 88 +++++++++++++++++ .../tests/test_adcm_add_host_to_cluster.py | 2 +- .../tests/test_adcm_custom_log.py | 95 +++++++++++++++++++ 6 files changed, 218 insertions(+), 55 deletions(-) create mode 100644 python/ansible_plugin/executors/custom_log.py create mode 100644 python/ansible_plugin/tests/test_adcm_custom_log.py diff --git a/python/ansible/plugins/action/adcm_custom_log.py b/python/ansible/plugins/action/adcm_custom_log.py index 55350a21b0..464652b0a5 100644 --- a/python/ansible/plugins/action/adcm_custom_log.py +++ b/python/ansible/plugins/action/adcm_custom_log.py @@ -54,64 +54,40 @@ content: It is text """ -RETURN = r""" -""" +RETURN = "" from binascii import Error +from pathlib import Path +from typing import Any import sys import base64 -from ansible.plugins.action import ActionBase - sys.path.append("/adcm/python") import adcm.init_django # noqa: F401, isort:skip -from ansible_plugin.utils import create_custom_log -from cm.errors import AdcmEx -from cm.logger import logger - - -class ActionModule(ActionBase): - _VALID_ARGS = frozenset(("name", "format", "path", "content")) - - def run(self, tmp=None, task_vars=None): - super().run(tmp, task_vars) - if task_vars is not None and "job" in task_vars or "id" in task_vars["job"]: - job_id = task_vars["job"]["id"] - - name = self._task.args.get("name") - log_format = self._task.args.get("format") - path = self._task.args.get("path") - content = self._task.args.get("content") - if not name and log_format and (path or content): - return { - "failed": True, - "msg": "name, format and path or content are mandatory args of adcm_custom_log", - } - - try: - if path is None: - logger.debug("ansible adcm_custom_log: %s, %s, %s, %s", job_id, name, log_format, content) - create_custom_log(job_id=job_id, name=name, log_format=log_format, body=content) - else: - logger.debug("ansible adcm_custom_log: %s, %s, %s, %s", job_id, name, log_format, path) - slurp_return = self._execute_module( - module_name="slurp", module_args={"src": path}, task_vars=task_vars, tmp=tmp - ) - if "failed" in slurp_return and slurp_return["failed"]: - raise AdcmEx("UNKNOWN_ERROR", msg=slurp_return["msg"]) - - try: - body = base64.standard_b64decode(slurp_return["content"]).decode() - except Error as error: - raise AdcmEx("UNKNOWN_ERROR", msg="Error b64decode for slurp module") from error - except UnicodeDecodeError as error: - raise AdcmEx("UNKNOWN_ERROR", msg="Error UnicodeDecodeError for slurp module") from error - - create_custom_log(job_id=job_id, name=name, log_format=log_format, body=body) - - except AdcmEx as e: - return {"failed": True, "msg": f"{e.code}: {e.msg}"} - - return {"failed": False, "changed": False} + +from ansible_plugin.base import ADCMAnsiblePlugin +from ansible_plugin.errors import PluginRuntimeError +from ansible_plugin.executors.custom_log import ADCMCustomLogPluginExecutor + + +class ActionModule(ADCMAnsiblePlugin): + executor_class = ADCMCustomLogPluginExecutor + + def _get_executor(self, tmp: Any, task_vars: Any) -> ADCMCustomLogPluginExecutor: + def retrieve_from_path_impl(_, path: Path) -> str: + slurp_return = self._execute_module( + module_name="slurp", module_args={"src": str(path)}, task_vars=task_vars, tmp=tmp + ) + if slurp_return.get("failed"): + raise PluginRuntimeError(message=slurp_return["msg"]) + + try: + return base64.standard_b64decode(slurp_return["content"]).decode() + except Error as error: + raise PluginRuntimeError(message="Error `b64decode` for slurp module") from error + except UnicodeDecodeError as error: + raise PluginRuntimeError(message="Error `UnicodeDecodeError` for slurp module") from error + + return self.executor_class[retrieve_from_path_impl](arguments=self._task.args, runtime_vars=task_vars) diff --git a/python/ansible_plugin/base.py b/python/ansible_plugin/base.py index 616d2517e7..eb9937715b 100644 --- a/python/ansible_plugin/base.py +++ b/python/ansible_plugin/base.py @@ -481,7 +481,7 @@ def run(self, tmp=None, task_vars=None): with (settings.RUN_DIR / str(task_vars["job"]["id"]) / "config.json").open(encoding="utf-8") as file: fcntl.flock(file.fileno(), fcntl.LOCK_EX) - executor = self.executor_class(arguments=self._task.args, runtime_vars=task_vars) + executor = self._get_executor(tmp=tmp, task_vars=task_vars) execution_result = executor.execute() if execution_result.error: @@ -494,3 +494,7 @@ def run(self, tmp=None, task_vars=None): result_value["value"] = execution_result.value return {"changed": execution_result.changed, **result_value} + + def _get_executor(self, tmp: Any, task_vars: Any) -> ADCMAnsiblePluginExecutor: + _ = tmp + return self.executor_class(arguments=self._task.args, runtime_vars=task_vars) diff --git a/python/ansible_plugin/executors/add_host_to_cluster.py b/python/ansible_plugin/executors/add_host_to_cluster.py index 224df0543c..518d8b7309 100644 --- a/python/ansible_plugin/executors/add_host_to_cluster.py +++ b/python/ansible_plugin/executors/add_host_to_cluster.py @@ -39,7 +39,7 @@ class AddHostToClusterArguments(BaseModel): def check_either_is_specified(self) -> Self: # won't filter out empty strings or 0 `host_id`, leave it to plugin logic to handle if self.fqdn is None and self.host_id is None: - message = "either `fqdn` or `host_id` have to be specified" + message = "either `fqdn` or `host_id` has to be specified" raise ValueError(message) return self diff --git a/python/ansible_plugin/executors/custom_log.py b/python/ansible_plugin/executors/custom_log.py new file mode 100644 index 0000000000..362882ac7d --- /dev/null +++ b/python/ansible_plugin/executors/custom_log.py @@ -0,0 +1,88 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Callable, Collection + +from cm.models import LogStorage +from core.types import CoreObjectDescriptor +from django.db.transaction import atomic +from pydantic import BaseModel, model_validator +from typing_extensions import Self + +from ansible_plugin.base import ( + ADCMAnsiblePluginExecutor, + ArgumentsConfig, + CallResult, + PluginExecutorConfig, + RuntimeEnvironment, +) +from ansible_plugin.utils import assign_view_logstorage_permissions_by_job + + +class CustomLogArguments(BaseModel): + name: str + format: str + path: Path | None = None + content: str | None = None + + @model_validator(mode="after") + def check_either_is_specified(self) -> Self: + if self.path is None and self.content is None: + message = "either `path` or `content` has to be specified" + raise ValueError(message) + + return self + + +class ADCMCustomLogPluginExecutor(ADCMAnsiblePluginExecutor[CustomLogArguments, None], ABC): + _config = PluginExecutorConfig( + arguments=ArgumentsConfig(represent_as=CustomLogArguments), + ) + + def __call__( + self, + targets: Collection[CoreObjectDescriptor], + arguments: CustomLogArguments, + runtime: RuntimeEnvironment, + ) -> CallResult[None]: + _ = targets + + body = arguments.content + if arguments.path: + body = self.retrieve_from_path(arguments.path) + + with atomic(): + log = LogStorage.objects.create( + job_id=runtime.vars.job.id, name=arguments.name, type="custom", format=arguments.format, body=body + ) + assign_view_logstorage_permissions_by_job(log_storage=log) + + return CallResult(value=None, changed=False, error=None) + + def __class_getitem__(cls, item: Callable[[Self, Path], str]): + class ConfiguredADCMCustomLogPluginExecutor(cls): + def retrieve_from_path(self, path: Path) -> str: + return item(self, path) + + return ConfiguredADCMCustomLogPluginExecutor + + @abstractmethod + def retrieve_from_path(self, path: Path) -> str: + """ + This function will be called if `path` is specified in arguments + in priority to `content` either it's specified or not. + + Executor implementations can be either subclassed and implemented + or build with `ADCMCustomLogPluginExecutor[retrieve_from_path_implementation]` + """ diff --git a/python/ansible_plugin/tests/test_adcm_add_host_to_cluster.py b/python/ansible_plugin/tests/test_adcm_add_host_to_cluster.py index 5c670224e9..eae120168a 100644 --- a/python/ansible_plugin/tests/test_adcm_add_host_to_cluster.py +++ b/python/ansible_plugin/tests/test_adcm_add_host_to_cluster.py @@ -116,7 +116,7 @@ def test_absent_arguments_call_fail(self) -> None: result = executor.execute() self.assertIsInstance(result.error, PluginValidationError) - self.assertIn("either `fqdn` or `host_id` have to be specified", result.error.message) + self.assertIn("either `fqdn` or `host_id` has to be specified", result.error.message) def test_incorrect_context_call_fail(self) -> None: for object_ in (self.provider, self.host_1): diff --git a/python/ansible_plugin/tests/test_adcm_custom_log.py b/python/ansible_plugin/tests/test_adcm_custom_log.py new file mode 100644 index 0000000000..fc46bddec6 --- /dev/null +++ b/python/ansible_plugin/tests/test_adcm_custom_log.py @@ -0,0 +1,95 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest.mock import patch + +from cm.models import LogStorage +from cm.services.job.run.repo import JobRepoImpl + +from ansible_plugin.executors.custom_log import ADCMCustomLogPluginExecutor +from ansible_plugin.tests.base import BaseTestEffectsOfADCMAnsiblePlugins + +EXECUTOR_MODULE = "ansible_plugin.executors.custom_log" + + +class TestEffectsOfADCMAnsiblePlugins(BaseTestEffectsOfADCMAnsiblePlugins): + EXECUTOR_CLASS = ADCMCustomLogPluginExecutor[lambda _, path: str(path)] + + def test_add_custom_log_by_content_success(self) -> None: + name = "cool name" + format_ = "txt" + content = "bestcontent ever !!!" + + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=self.EXECUTOR_CLASS, + call_arguments=f""" + name: {name} + format: {format_} + content: "{content}" + """, + call_context=job, + ) + + with patch(f"{EXECUTOR_MODULE}.assign_view_logstorage_permissions_by_job") as permissions_mock: + result = executor.execute() + + self.assertIsNone(result.error) + self.assertTrue( + LogStorage.objects.filter(job_id=job.id, type="custom", format=format_, name=name, body=content).exists() + ) + permissions_mock.assert_called_once() + + def test_path_content(self) -> None: + name = "cool name" + format_ = "txt" + path = "/some/path" + + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=self.EXECUTOR_CLASS, + call_arguments={"name": name, "format": format_, "path": path}, + call_context=job, + ) + + result = executor.execute() + + self.assertIsNone(result.error) + log = LogStorage.objects.filter(job_id=job.id, type="custom", format=format_, name=name).get() + self.assertEqual(log.body, path) + + def test_path_priority_over_content(self) -> None: + name = "cool name" + format_ = "txt" + content = "bestcontent ever !!!" + path = "/some/path" + + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=self.EXECUTOR_CLASS, + call_arguments={"name": name, "format": format_, "content": content, "path": path}, + call_context=job, + ) + + with patch(f"{EXECUTOR_MODULE}.assign_view_logstorage_permissions_by_job") as permissions_mock: + result = executor.execute() + + self.assertIsNone(result.error) + log = LogStorage.objects.filter(job_id=job.id, type="custom", format=format_, name=name).get() + self.assertEqual(log.body, path) + permissions_mock.assert_called_once() From 395c29c825644cec1b0d6b913ea41568ec0c9b2d Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Fri, 24 May 2024 12:42:18 +0000 Subject: [PATCH 122/208] ADCM-5582 & ADCM-5583 Remove `reverse` from `test_service` and `test_tasks` --- python/api_v2/tests/test_service.py | 202 ++++++---------------------- python/api_v2/tests/test_tasks.py | 98 +++++--------- 2 files changed, 76 insertions(+), 224 deletions(-) diff --git a/python/api_v2/tests/test_service.py b/python/api_v2/tests/test_service.py index 2078299521..c3ee418ffb 100644 --- a/python/api_v2/tests/test_service.py +++ b/python/api_v2/tests/test_service.py @@ -30,7 +30,6 @@ from cm.services.job.action import ActionRunPayload, run_action from cm.services.status.client import FullStatusMap from cm.tests.mocks.task_runner import RunTaskMock -from django.urls import reverse from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, @@ -55,9 +54,7 @@ def setUp(self) -> None: self.action = Action.objects.filter(prototype=self.service_2.prototype).first() def test_list_success(self): - response = self.client.get( - path=reverse(viewname="v2:service-list", kwargs={"cluster_pk": self.cluster_1.pk}), - ) + response = self.client.v2[self.cluster_1, "services"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 2) @@ -65,20 +62,14 @@ def test_list_success(self): def test_adcm_4544_list_service_name_ordering_success(self): service_3 = self.add_services_to_cluster(service_names=["service_3_manual_add"], cluster=self.cluster_1).get() service_list = [self.service_1.display_name, self.service_2.display_name, service_3.display_name] - response = self.client.get( - path=reverse(viewname="v2:service-list", kwargs={"cluster_pk": self.cluster_1.pk}), - data={"ordering": "displayName"}, - ) + response = self.client.v2[self.cluster_1, "services"].get(query={"ordering": "displayName"}) self.assertListEqual( [service["displayName"] for service in response.json()["results"]], service_list, ) - response = self.client.get( - path=reverse(viewname="v2:service-list", kwargs={"cluster_pk": self.cluster_1.pk}), - data={"ordering": "-displayName"}, - ) + response = self.client.v2[self.cluster_1, "services"].get(query={"ordering": "-displayName"}) self.assertListEqual( [service["displayName"] for service in response.json()["results"]], @@ -86,22 +77,14 @@ def test_adcm_4544_list_service_name_ordering_success(self): ) def test_retrieve_success(self): - response = self.client.get( - path=reverse( - viewname="v2:service-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_2.pk} - ), - ) + response = self.client.v2[self.service_2].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["id"], self.service_2.pk) self.assertEqual(response.json()["description"], self.service_2.description) def test_delete_success(self): - response = self.client.delete( - path=reverse( - viewname="v2:service-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_2.pk} - ), - ) + response = self.client.v2[self.service_2].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.assertFalse(ClusterObject.objects.filter(pk=self.service_2.pk).exists()) @@ -110,11 +93,7 @@ def test_delete_failed(self): self.service_2.state = "non_created" self.service_2.save(update_fields=["state"]) - response = self.client.delete( - path=reverse( - viewname="v2:service-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_2.pk} - ), - ) + response = self.client.v2[self.service_2].delete() self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertTrue(ClusterObject.objects.filter(pk=self.service_2.pk).exists()) @@ -123,10 +102,7 @@ def test_create_success(self): initial_service_count = ClusterObject.objects.count() manual_add_service_proto = Prototype.objects.get(type=ObjectType.SERVICE, name="service_3_manual_add") - response = self.client.post( - path=reverse(viewname="v2:service-list", kwargs={"cluster_pk": self.cluster_1.pk}), - data=[{"prototypeId": manual_add_service_proto.pk}], - ) + response = self.client.v2[self.cluster_1, "services"].post(data=[{"prototypeId": manual_add_service_proto.pk}]) self.assertEqual(response.status_code, HTTP_201_CREATED) data = response.json() @@ -140,10 +116,7 @@ def test_add_one_success(self): initial_service_count = ClusterObject.objects.count() manual_add_service_proto = Prototype.objects.get(type=ObjectType.SERVICE, name="service_3_manual_add") - response = self.client.post( - path=reverse(viewname="v2:service-list", kwargs={"cluster_pk": self.cluster_1.pk}), - data={"prototypeId": manual_add_service_proto.pk}, - ) + response = self.client.v2[self.cluster_1, "services"].post(data={"prototypeId": manual_add_service_proto.pk}) self.assertEqual(response.status_code, HTTP_201_CREATED) data = response.json() @@ -156,28 +129,19 @@ def test_create_wrong_data_fail(self): initial_service_count = ClusterObject.objects.count() manual_add_service_proto = Prototype.objects.get(type=ObjectType.SERVICE, name="service_3_manual_add") - response = self.client.post( - path=reverse(viewname="v2:service-list", kwargs={"cluster_pk": self.cluster_1.pk}), - data={"somekey": manual_add_service_proto.pk}, - ) + response = self.client.v2[self.cluster_1, "services"].post(data={"somekey": manual_add_service_proto.pk}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.assertEqual(ClusterObject.objects.count(), initial_service_count) def test_filter_by_name_success(self): - response = self.client.get( - path=reverse(viewname="v2:service-list", kwargs={"cluster_pk": self.cluster_1.pk}), - data={"name": "service_1"}, - ) + response = self.client.v2[self.cluster_1, "services"].get(query={"name": "service_1"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) def test_filter_by_display_name_success(self): - response = self.client.get( - path=reverse(viewname="v2:service-list", kwargs={"cluster_pk": self.cluster_1.pk}), - data={"display_name": "vice_1"}, - ) + response = self.client.v2[self.cluster_1, "services"].get(query={"display_name": "vice_1"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) @@ -197,72 +161,40 @@ def test_filter_by_status_success(self): ) with patch("api_v2.filters.retrieve_status_map", return_value=status_map): - response = self.client.get( - path=reverse(viewname="v2:service-list", kwargs={"cluster_pk": self.cluster_1.pk}), - data={"status": ADCMEntityStatus.UP}, - ) + response = self.client.v2[self.cluster_1, "services"].get(query={"status": ADCMEntityStatus.UP}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()["results"]), 1) self.assertEqual(response.json()["results"][0]["id"], self.service_2.pk) def test_limit_offset_success(self): - response = self.client.get( - path=reverse(viewname="v2:service-list", kwargs={"cluster_pk": self.cluster_1.pk}), - data={"limit": 1, "offset": 1}, - ) + response = self.client.v2[self.cluster_1, "services"].get(query={"limit": 1, "offset": 1}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()["results"]), 1) def test_change_mm(self): - response = self.client.post( - path=reverse( - viewname="v2:service-maintenance-mode", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_2.pk}, - ), - data={"maintenance_mode": MaintenanceMode.ON}, + response = self.client.v2[self.service_2, "maintenance-mode"].post( + data={"maintenance_mode": MaintenanceMode.ON} ) self.assertEqual(response.status_code, HTTP_200_OK) def test_action_list_success(self): - response = self.client.get( - path=reverse( - viewname="v2:service-action-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_2.pk}, - ), - ) + response = self.client.v2[self.service_2, "actions"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()), 2) def test_action_retrieve_success(self): - response = self.client.get( - path=reverse( - viewname="v2:service-action-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_2.pk, - "pk": self.action.pk, - }, - ), - ) + response = self.client.v2[self.service_2, "actions", self.action].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertTrue(response.json()) def test_action_run_success(self): with RunTaskMock() as run_task: - response = self.client.post( - path=reverse( - viewname="v2:service-action-run", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_2.pk, - "pk": self.action.pk, - }, - ), + response = self.client.v2[self.service_2, "actions", self.action, "run"].post( data={"hostComponentMap": [], "config": {}, "adcmMeta": {}, "isVerbose": False}, ) @@ -299,12 +231,8 @@ def test_delete_service_do_not_abort_cluster_actions_fail(self) -> None: self.assertTrue(self.service_to_delete.concerns.filter(type=ConcernType.LOCK).exists()) with patch("subprocess.Popen", return_value=FakePopenResponse(3)), patch("os.kill", return_type=None): - response = self.client.delete( - path=reverse( - viewname="v2:service-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_to_delete.pk}, - ) - ) + response = self.client.v2[self.service_to_delete].delete() + self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertEqual(response.json()["code"], "LOCK_ERROR") @@ -314,12 +242,8 @@ def test_delete_service_abort_own_actions_success(self) -> None: self.assertTrue(self.service_to_delete.concerns.filter(type=ConcernType.LOCK).exists()) with patch("subprocess.Popen", return_value=FakePopenResponse(3)), patch("os.kill", return_type=None): - response = self.client.delete( - path=reverse( - viewname="v2:service-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_to_delete.pk}, - ) - ) + response = self.client.v2[self.service_to_delete].delete() + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) service_concerns_qs = self.service_to_delete.concerns.filter(type=ConcernType.LOCK) @@ -357,24 +281,16 @@ def setUp(self) -> None: self.test_user = self.create_user(**self.test_user_credentials) def test_change_mm_success(self): - response = self.client.post( - path=reverse( - viewname="v2:service-maintenance-mode", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_1_cl_1.pk}, - ), - data={"maintenance_mode": MaintenanceMode.ON}, + response = self.client.v2[self.service_1_cl_1, "maintenance-mode"].post( + data={"maintenance_mode": MaintenanceMode.ON} ) self.assertEqual(response.status_code, HTTP_200_OK) def test_adcm_5277_change_mm_service_service_administrator_success(self): with self.grant_permissions(to=self.test_user, on=self.service_1_cl_1, role_name="Service Administrator"): - response = self.client.post( - path=reverse( - viewname="v2:service-maintenance-mode", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_1_cl_1.pk}, - ), - data={"maintenance_mode": MaintenanceMode.ON}, + response = self.client.v2[self.service_1_cl_1, "maintenance-mode"].post( + data={"maintenance_mode": MaintenanceMode.ON} ) self.service_1_cl_1.refresh_from_db() @@ -383,16 +299,8 @@ def test_adcm_5277_change_mm_service_service_administrator_success(self): def test_adcm_5277_change_mm_component_service_administrator_success(self): with self.grant_permissions(to=self.test_user, on=self.service_1_cl_1, role_name="Service Administrator"): - response = self.client.post( - path=reverse( - "v2:component-maintenance-mode", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1_cl_1.pk, - "pk": self.component_1_s_1_cl1.pk, - }, - ), - data={"maintenance_mode": MaintenanceMode.ON}, + response = self.client.v2[self.component_1_s_1_cl1, "maintenance-mode"].post( + data={"maintenance_mode": MaintenanceMode.ON} ) self.component_1_s_1_cl1.refresh_from_db() @@ -400,12 +308,8 @@ def test_adcm_5277_change_mm_component_service_administrator_success(self): self.assertEqual(self.component_1_s_1_cl1.maintenance_mode, MaintenanceMode.ON) def test_change_mm_not_available_fail(self): - response = self.client.post( - path=reverse( - "v2:service-maintenance-mode", - kwargs={"cluster_pk": self.cluster_2.pk, "pk": self.service_cl_2.pk}, - ), - data={"maintenance_mode": MaintenanceMode.ON}, + response = self.client.v2[self.service_cl_2, "maintenance-mode"].post( + data={"maintenance_mode": MaintenanceMode.ON} ) self.assertEqual(response.status_code, HTTP_409_CONFLICT) @@ -445,69 +349,43 @@ def setUp(self) -> None: ) def test_adcm_5278_cluster_hosts_restriction_by_service_administrator_ownership_success(self): - response_list = self.client.get( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), - ) + response_list = self.client.v2[self.cluster_1, "hosts"].get() - response_detail = self.client.get( - path=reverse( - viewname="v2:host-cluster-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.host_with_component.pk}, - ), - ) + response_detail = self.client.v2[self.cluster_1, "hosts", self.host_with_component].get() self.assertEqual(response_list.status_code, HTTP_200_OK) self.assertEqual(response_list.json()["count"], 2) self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.service, role_name="Service Administrator"): - response = self.client.get( - path=reverse(viewname="v2:host-cluster-list", kwargs={"cluster_pk": self.cluster_1.pk}), - ) + response = self.client.v2[self.cluster_1, "hosts"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) self.assertDictEqual(response_list.json()["results"][1], response.json()["results"][0]) - response = self.client.get( - path=reverse( - viewname="v2:host-cluster-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.host_with_component.pk}, - ), - ) + response = self.client.v2[self.cluster_1, "hosts", self.host_with_component].get() + self.assertEqual(response.status_code, HTTP_200_OK) self.assertDictEqual(response_detail.json(), response.json()) def test_adcm_5278_hosts_restriction_by_service_administrator_ownership_success(self): - response_list = self.client.get( - path=reverse(viewname="v2:host-list"), - ) + response_list = (self.client.v2 / "hosts").get() - response_detail = self.client.get( - path=reverse( - viewname="v2:host-detail", - kwargs={"pk": self.host_with_component.pk}, - ), - ) + response_detail = self.client.v2[self.host_with_component].get() self.assertEqual(response_list.status_code, HTTP_200_OK) self.assertEqual(response_list.json()["count"], 2) self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.service, role_name="Service Administrator"): - response = self.client.get( - path=reverse(viewname="v2:host-list"), - ) + response = (self.client.v2 / "hosts").get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) self.assertDictEqual(response_list.json()["results"][1], response.json()["results"][0]) - response = self.client.get( - path=reverse( - viewname="v2:host-detail", - kwargs={"pk": self.host_with_component.pk}, - ), - ) + response = self.client.v2[self.host_with_component].get() + self.assertEqual(response.status_code, HTTP_200_OK) self.assertDictEqual(response_detail.json(), response.json()) diff --git a/python/api_v2/tests/test_tasks.py b/python/api_v2/tests/test_tasks.py index dc12b71859..1f28b55c49 100644 --- a/python/api_v2/tests/test_tasks.py +++ b/python/api_v2/tests/test_tasks.py @@ -32,7 +32,6 @@ from core.job.dto import TaskPayloadDTO from core.types import ADCMCoreType, CoreObjectDescriptor from django.contrib.contenttypes.models import ContentType -from django.urls import reverse from django.utils import timezone from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND @@ -87,27 +86,27 @@ def setUp(self) -> None: ) def test_task_list_success(self): - response = self.client.get(path=reverse(viewname="v2:tasklog-list")) + response = (self.client.v2 / "tasks").get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.data["results"]), 4) def test_task_filter_by_job_name(self): - response = self.client.get(path=reverse(viewname="v2:tasklog-list"), data={"jobName": "comp"}) + response = (self.client.v2 / "tasks").get(query={"jobName": "comp"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) self.assertEqual(response.json()["results"][0]["id"], self.component_task.pk) def test_task_filter_by_object_name(self): - response = self.client.get(path=reverse(viewname="v2:tasklog-list"), data={"objectName": "service_1"}) + response = (self.client.v2 / "tasks").get(query={"objectName": "service_1"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) self.assertEqual(response.json()["results"][0]["id"], self.service_task.pk) def test_task_filter_by_job_name_multiple_found_success(self): - response = self.client.get(path=reverse(viewname="v2:tasklog-list"), data={"jobName": "action"}) + response = (self.client.v2 / "tasks").get(query={"jobName": "action"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 3) @@ -117,9 +116,7 @@ def test_task_filter_by_job_name_multiple_found_success(self): self.assertEqual(tasks[2]["id"], self.cluster_task.pk) def test_task_filter_by_job_name_and_object_name(self): - response = self.client.get( - path=reverse(viewname="v2:tasklog-list"), data={"jobName": "action", "objectName": "cluster"} - ) + response = (self.client.v2 / "tasks").get(query={"jobName": "action", "objectName": "cluster"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) @@ -128,42 +125,36 @@ def test_task_filter_by_job_name_and_object_name(self): def test_task_retrieve_success(self): task_object = {"type": self.cluster_1.content_type.name, "id": self.cluster_1.pk, "name": self.cluster_1.name} - response = self.client.get( - path=reverse(viewname="v2:tasklog-detail", kwargs={"pk": self.cluster_task.pk}), - ) + response = self.client.v2[self.cluster_task].get() self.assertEqual(response.data["id"], self.cluster_task.pk) self.assertEqual(response.data["objects"], [task_object]) self.assertEqual(response.status_code, HTTP_200_OK) def test_task_retrieve_not_found_fail(self): - response = self.client.get( - path=reverse(viewname="v2:tasklog-detail", kwargs={"pk": self.get_non_existent_pk(TaskLog)}), - ) + response = (self.client.v2 / "tasks" / self.get_non_existent_pk(TaskLog)).get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_task_log_download_success(self): with patch("api_v2.task.views.get_task_download_archive_file_handler", return_value=BytesIO(b"content")): - response = self.client.get( - path=reverse(viewname="v2:tasklog-download", kwargs={"pk": self.cluster_task.pk}) - ) + response = self.client.v2[self.cluster_task, "logs", "download"].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_adcm_5158_adcm_task_view_for_not_superuser_fail(self): self.client.login(username="admin", password="admin") - response = self.client.get(path=reverse(viewname="v2:tasklog-detail", kwargs={"pk": self.adcm_task.pk})) + response = self.client.v2[self.adcm_task].get() self.assertEqual(response.status_code, HTTP_200_OK) - response = self.client.get(path=reverse(viewname="v2:tasklog-list")) + response = (self.client.v2 / "tasks").get() self.assertIn(self.adcm_task.pk, [task["id"] for task in response.json()["results"]]) self.client.login(**self.test_user_credentials) - response = self.client.get(path=reverse(viewname="v2:tasklog-detail", kwargs={"pk": self.adcm_task.pk})) + response = self.client.v2[self.adcm_task].get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) - response = self.client.get(path=reverse(viewname="v2:tasklog-list")) + response = (self.client.v2 / "tasks").get() self.assertNotIn(self.adcm_task.pk, [task["id"] for task in response.json()["results"]]) def test_visibility_after_object_deletion(self): @@ -180,15 +171,7 @@ def test_visibility_after_object_deletion(self): # run action as service admin (create all permissions we interested in) self.client.login(**service_admin_credentials) with RunTaskMock() as run_task: - response = self.client.post( - path=reverse( - viewname="v2:service-action-run", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "pk": self.service_1_action.pk, - }, - ), + response = self.client.v2[self.service_1, "actions", self.service_1_action, "run"].post( data={"hostComponentMap": [], "config": {}, "adcmMeta": {}, "isVerbose": False}, ) @@ -199,26 +182,23 @@ def test_visibility_after_object_deletion(self): service_task_pk = response.json()["id"] child_job_pk = response.json()["childJobs"][0]["id"] + task_endpoint = self.client.v2 / "tasks" / service_task_pk + log_list_endpoint = self.client.v2 / "jobs" / child_job_pk / "logs" + # check tasklog visibility for cluster admin self.client.login(**cluster_admin_credentials) - cluster_admin_response = self.client.get( - path=reverse(viewname="v2:tasklog-detail", kwargs={"pk": service_task_pk}), - ) + cluster_admin_response = task_endpoint.get() self.assertEqual(cluster_admin_response.status_code, HTTP_200_OK) - cluster_admin_response = self.client.get( - path=reverse(viewname="v2:log-list", kwargs={"job_pk": child_job_pk}), - ) + + cluster_admin_response = log_list_endpoint.get() self.assertSetEqual({log["type"] for log in cluster_admin_response.json()}, {"stdout", "stderr"}) # check tasklog visibility for service admin self.client.login(**service_admin_credentials) - service_admin_response = self.client.get( - path=reverse(viewname="v2:tasklog-detail", kwargs={"pk": service_task_pk}), - ) + service_admin_response = task_endpoint.get() self.assertEqual(service_admin_response.status_code, HTTP_200_OK) - service_admin_response = self.client.get( - path=reverse(viewname="v2:log-list", kwargs={"job_pk": child_job_pk}), - ) + + service_admin_response = log_list_endpoint.get() self.assertSetEqual({log["type"] for log in service_admin_response.json()}, {"stdout", "stderr"}) # delete service @@ -226,24 +206,18 @@ def test_visibility_after_object_deletion(self): # check tasklog visibility for cluster admin self.client.login(**cluster_admin_credentials) - cluster_admin_response = self.client.get( - path=reverse(viewname="v2:tasklog-detail", kwargs={"pk": service_task_pk}), - ) + cluster_admin_response = task_endpoint.get() self.assertEqual(cluster_admin_response.status_code, HTTP_200_OK) - cluster_admin_response = self.client.get( - path=reverse(viewname="v2:log-list", kwargs={"job_pk": child_job_pk}), - ) + + cluster_admin_response = log_list_endpoint.get() self.assertSetEqual({log["type"] for log in cluster_admin_response.json()}, {"stdout", "stderr"}) # check tasklog visibility for service admin self.client.login(**service_admin_credentials) - service_admin_response = self.client.get( - path=reverse(viewname="v2:tasklog-detail", kwargs={"pk": service_task_pk}), - ) + service_admin_response = task_endpoint.get() self.assertEqual(service_admin_response.status_code, HTTP_200_OK) - service_admin_response = self.client.get( - path=reverse(viewname="v2:log-list", kwargs={"job_pk": child_job_pk}), - ) + + service_admin_response = log_list_endpoint.get() self.assertSetEqual({log["type"] for log in service_admin_response.json()}, {"stdout", "stderr"}) @@ -280,56 +254,56 @@ def setUp(self) -> None: def test_cluster_task_objects_success(self) -> None: task = self.create_task(object_=self.cluster_1, action_name="action") - response = self.client.get(path=reverse("v2:tasklog-detail", kwargs={"pk": task.pk})) + response = self.client.v2[task].get() self.assertEqual(response.status_code, HTTP_200_OK) objects = sorted(response.json()["objects"], key=itemgetter("type")) self.assertEqual(objects, [self.cluster_object]) def test_service_task_objects_success(self) -> None: task = self.create_task(object_=self.service_1, action_name="action") - response = self.client.get(path=reverse("v2:tasklog-detail", kwargs={"pk": task.pk})) + response = self.client.v2[task].get() self.assertEqual(response.status_code, HTTP_200_OK) objects = sorted(response.json()["objects"], key=itemgetter("type")) self.assertEqual(objects, [self.cluster_object, self.service_object]) def test_component_task_objects_success(self) -> None: task = self.create_task(object_=self.component_1, action_name="action_1_comp_1") - response = self.client.get(path=reverse("v2:tasklog-detail", kwargs={"pk": task.pk})) + response = self.client.v2[task].get() self.assertEqual(response.status_code, HTTP_200_OK) objects = sorted(response.json()["objects"], key=itemgetter("type")) self.assertEqual(objects, [self.cluster_object, self.component_object, self.service_object]) def test_provider_task_objects_success(self) -> None: task = self.create_task(object_=self.provider, action_name="provider_action") - response = self.client.get(path=reverse("v2:tasklog-detail", kwargs={"pk": task.pk})) + response = self.client.v2[task].get() self.assertEqual(response.status_code, HTTP_200_OK) objects = sorted(response.json()["objects"], key=itemgetter("type")) self.assertEqual(objects, [self.provider_object]) def test_host_task_objects_success(self) -> None: task = self.create_task(object_=self.host, action_name="host_action") - response = self.client.get(path=reverse("v2:tasklog-detail", kwargs={"pk": task.pk})) + response = self.client.v2[task].get() self.assertEqual(response.status_code, HTTP_200_OK) objects = sorted(response.json()["objects"], key=itemgetter("type")) self.assertEqual(objects, [self.host_object, self.provider_object]) def test_host_task_of_cluster_action_objects_success(self) -> None: task = self.create_task(object_=self.cluster_1, action_name="cluster_on_host", host=self.host) - response = self.client.get(path=reverse("v2:tasklog-detail", kwargs={"pk": task.pk})) + response = self.client.v2[task].get() self.assertEqual(response.status_code, HTTP_200_OK) objects = sorted(response.json()["objects"], key=itemgetter("type")) self.assertEqual(objects, [self.cluster_object, self.host_object]) def test_host_task_of_service_action_objects_success(self) -> None: task = self.create_task(object_=self.service_1, action_name="service_on_host", host=self.host) - response = self.client.get(path=reverse("v2:tasklog-detail", kwargs={"pk": task.pk})) + response = self.client.v2[task].get() self.assertEqual(response.status_code, HTTP_200_OK) objects = sorted(response.json()["objects"], key=itemgetter("type")) self.assertEqual(objects, [self.cluster_object, self.host_object, self.service_object]) def test_host_task_of_component_action_objects_success(self) -> None: task = self.create_task(object_=self.component_1, action_name="component_on_host", host=self.host) - response = self.client.get(path=reverse("v2:tasklog-detail", kwargs={"pk": task.pk})) + response = self.client.v2[task].get() self.assertEqual(response.status_code, HTTP_200_OK) objects = sorted(response.json()["objects"], key=itemgetter("type")) self.assertEqual(objects, [self.cluster_object, self.component_object, self.host_object, self.service_object]) From 481d513bef6bf2889fa84c28cd5cacf8f270a20c Mon Sep 17 00:00:00 2001 From: Artem Starovoitov Date: Fri, 24 May 2024 13:06:08 +0000 Subject: [PATCH 123/208] ADCM-5585: Rework unittests `test_user.py` --- python/adcm/tests/client.py | 3 + python/api_v2/tests/test_user.py | 178 +++++++++++++------------------ 2 files changed, 75 insertions(+), 106 deletions(-) diff --git a/python/adcm/tests/client.py b/python/adcm/tests/client.py index 41edaad7a0..8c798a33f3 100644 --- a/python/adcm/tests/client.py +++ b/python/adcm/tests/client.py @@ -26,6 +26,7 @@ ServiceComponent, TaskLog, ) +from rbac.models import Policy, User from rest_framework.response import Response from rest_framework.test import APIClient @@ -88,6 +89,8 @@ class V2RootNode(RootNode): Host: "hosts", TaskLog: "tasks", JobLog: "jobs", + Policy: "rbac/policies", + User: "rbac/users", } def __getitem__(self, item: PathObject | tuple[PathObject, str | int | WithID, ...]) -> APINode: diff --git a/python/api_v2/tests/test_user.py b/python/api_v2/tests/test_user.py index c639d8608e..cc76e742e1 100644 --- a/python/api_v2/tests/test_user.py +++ b/python/api_v2/tests/test_user.py @@ -12,11 +12,11 @@ import datetime +from adcm.tests.client import ADCMTestClient from django.conf import settings from django.contrib.auth.models import Group as AuthGroup from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType -from django.urls import reverse from django.utils.timezone import now from rbac.models import Group, OriginType, Role, User from rbac.services.policy import policy_create @@ -30,7 +30,6 @@ HTTP_404_NOT_FOUND, HTTP_409_CONFLICT, ) -from rest_framework.test import APIClient import pytz from api_v2.rbac.user.constants import UserTypeChoices @@ -57,7 +56,7 @@ def _grant_permissions(self, user: User) -> None: user.user_permissions.add(*(view_user_permission, change_user_permission)) def test_list_success(self): - response = self.client.get(path=reverse(viewname="v2:rbac:user-list")) + response = (self.client.v2 / "rbac" / "users").get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) @@ -84,7 +83,7 @@ def test_list_no_perms_empty_list_success(self) -> None: self.create_user(user_data={"username": "test_user", "password": "test_user_password"}) self.client.login(username="test_user", password="test_user_password") - response = self.client.get(path=reverse(viewname="v2:rbac:user-list")) + response = (self.client.v2 / "rbac" / "users").get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 0) @@ -94,13 +93,12 @@ def test_retrieve_no_perms_not_found_fail(self) -> None: user = self.create_user(user_data={"username": "test_user", "password": "test_user_password"}) self.client.login(username="test_user", password="test_user_password") - response = self.client.get(path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk})) + response = self.client.v2[user].get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_create_success(self): - response = self.client.post( - path=reverse(viewname="v2:rbac:user-list"), + response = (self.client.v2 / "rbac" / "users").post( data={ "username": "test_user_username", "password": "test_user_password", @@ -123,8 +121,7 @@ def test_create_success(self): self.assertEqual(data["groups"][0]["displayName"], self.group.display_name) def test_create_required_fields_success(self): - response = self.client.post( - path=reverse(viewname="v2:rbac:user-list"), + response = (self.client.v2 / "rbac" / "users").post( data={"username": "test_user_username_1", "password": "test_user_password_1"}, ) @@ -132,7 +129,7 @@ def test_create_required_fields_success(self): self.assertTrue(User.objects.filter(username="test_user_username_1").exists()) def test_create_required_fields_fail(self): - response = self.client.post(path=reverse(viewname="v2:rbac:user-list"), data={"username": "test_user_username"}) + response = (self.client.v2 / "rbac" / "users").post(data={"username": "test_user_username"}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -140,15 +137,13 @@ def test_create_required_fields_fail(self): ) def test_create_password_does_not_meet_requirements_fail(self) -> None: - response = self.client.post( - path=reverse(viewname="v2:rbac:user-list"), data={"username": "test_user_username", "password": "1"} - ) + response = (self.client.v2 / "rbac" / "users").post(data={"username": "test_user_username", "password": "1"}) self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertEqual(response.json()["code"], "USER_PASSWORD_ERROR") - response = self.client.post( - path=reverse(viewname="v2:rbac:user-list"), data={"username": "test_user_username", "password": "1" * 1000} + response = (self.client.v2 / "rbac" / "users").post( + data={"username": "test_user_username", "password": "1" * 1000} ) self.assertEqual(response.status_code, HTTP_409_CONFLICT) @@ -157,14 +152,12 @@ def test_create_password_does_not_meet_requirements_fail(self) -> None: def test_create_taken_email_fail(self) -> None: email = "em@ai.il" - response = self.client.post( - path=reverse(viewname="v2:rbac:user-list"), + response = (self.client.v2 / "rbac" / "users").post( data={"username": "test_user_username_1", "password": "test_user_password_1", "email": email}, ) self.assertEqual(response.status_code, HTTP_201_CREATED) - response = self.client.post( - path=reverse(viewname="v2:rbac:user-list"), + response = (self.client.v2 / "rbac" / "users").post( data={"username": "test_user_username_2", "password": "test_user_password_1", "email": email}, ) @@ -175,14 +168,13 @@ def test_create_taken_email_fail(self) -> None: def test_create_taken_username_fail(self) -> None: username = "cooluserisbest" - response = self.client.post( - path=reverse(viewname="v2:rbac:user-list"), + response = (self.client.v2 / "rbac" / "users").post( data={"username": username, "password": "test_user_password_1"}, ) + self.assertEqual(response.status_code, HTTP_201_CREATED) - response = self.client.post( - path=reverse(viewname="v2:rbac:user-list"), + response = (self.client.v2 / "rbac" / "users").post( data={"username": username, "password": "test_user_password_2"}, ) @@ -193,39 +185,36 @@ def test_create_taken_username_fail(self) -> None: def test_create_empty_email_success(self) -> None: email = "" - response = self.client.post( - path=reverse(viewname="v2:rbac:user-list"), + response = (self.client.v2 / "rbac" / "users").post( data={"username": "test_user_username_1", "password": "test_user_password_1", "email": email}, ) self.assertEqual(response.status_code, HTTP_201_CREATED) - response = self.client.post( - path=reverse(viewname="v2:rbac:user-list"), + response = (self.client.v2 / "rbac" / "users").post( data={"username": "test_user_username_2", "password": "test_user_password_1", "email": email}, ) - self.assertEqual(response.status_code, HTTP_201_CREATED) def test_permissions_granted_on_user_creation_with_group_success(self) -> None: creds = {"username": "test_user_username", "password": "test_user_password"} policy_create(name="ADCM User group", role=Role.objects.get(name="ADCM User"), group=[self.group]) - response = self.client.post( - path=reverse(viewname="v2:rbac:user-list"), + response = (self.client.v2 / "rbac" / "users").post( data={**creds, "groups": [self.group.pk]}, ) + self.assertEqual(response.status_code, HTTP_201_CREATED) self.client.login(**creds) - response = self.client.get(path=reverse(viewname="v2:cluster-detail", kwargs={"pk": self.cluster_1.pk})) + response = self.client.v2[self.cluster_1].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_retrieve_success(self): user = self.create_user() - response = self.client.get(path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk})) + response = self.client.v2[user].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertListEqual( @@ -263,7 +252,7 @@ def test_listfield_create_update_serializers_group_success(self): def test_retrieve_not_found_fail(self): wrong_pk = self.get_non_existent_pk(model=User) - response = self.client.get(path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": wrong_pk})) + response = (self.client.v2 / "rbac" / "users" / wrong_pk).get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -271,8 +260,7 @@ def test_update_by_superuser_success(self): group = Group.objects.create(name="group") user = self.create_user(user_data={"username": "test_user", "password": "test_user_password"}) - response = self.client.patch( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk}), + response = self.client.v2[user].patch( data={ "password": "newtestpassword", "email": "test_user@mail.ru", @@ -280,7 +268,7 @@ def test_update_by_superuser_success(self): "lastName": "test_user_last_name", "isSuperUser": True, "groups": [group.pk], - }, + } ) user.refresh_from_db() @@ -299,8 +287,7 @@ def test_update_by_superuser_success(self): self.assertEqual(len(data["groups"]), 1) self.assertDictEqual(data["groups"][0], expected_group_data) - response = self.client.patch( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk}), + response = self.client.v2[user].patch( data={"lastName": "WholeNewName"}, ) @@ -319,10 +306,10 @@ def test_update_no_change_email_success(self) -> None: email = "one@em.ail" user = self.create_user(user_data={"username": "test_user", "password": "test_user_password", "email": email}) - response = self.client.patch( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk}), + response = self.client.v2[user].patch( data={"email": email, "firstName": "test_user_first_name"}, ) + self.assertEqual(response.status_code, HTTP_200_OK) def test_update_self_by_regular_user_success(self): @@ -335,14 +322,13 @@ def test_update_self_by_regular_user_success(self): self._grant_permissions(user=user) self.client.login(username="test_user", password="test_user_password") - response = self.client.patch( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk}), + response = self.client.v2[user].patch( data={ "email": "test_user@mail.ru", "firstName": "test_user_first_name", "lastName": "test_user_last_name", "groups": [group.pk], - }, + } ) user.refresh_from_db() @@ -377,10 +363,7 @@ def test_update_not_self_by_regular_user_success(self): "lastName": "new_test_user2_last_name", "groups": [group.pk], } - response = self.client.patch( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": second_user.pk}), - data=new_data, - ) + response = self.client.v2[second_user].patch(data=new_data) second_user.refresh_from_db() self.assertEqual(response.status_code, HTTP_200_OK) @@ -398,7 +381,7 @@ def test_update_no_permission_fail(self) -> None: user = self.create_user(user_data=creds) self.client.login(**creds) - response = self.client.patch(path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk}), data={}) + response = self.client.v2[user].patch(data={}) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -412,7 +395,7 @@ def test_update_view_permission_fail(self) -> None: user.user_permissions.add(view_user_permission) self.client.login(**creds) - response = self.client.patch(path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk}), data={}) + response = self.client.v2[user].patch(data={}) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -423,10 +406,7 @@ def test_update_email_taken_fail(self) -> None: user_data={"username": "test_user_2", "password": "test_user_password", "email": "custom@em.ail"} ) - response = self.client.patch( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user_to_change.pk}), - data={"email": email}, - ) + response = self.client.v2[user_to_change].patch(data={"email": email}) self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertEqual(response.json()["code"], "USER_CONFLICT") @@ -435,16 +415,14 @@ def test_update_email_taken_fail(self) -> None: def test_update_incorrect_password_fail(self) -> None: user = self.create_user(user_data={"username": "test_user", "password": "test_user_password"}) - response = self.client.patch( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk}), + response = self.client.v2[user].patch( data={"password": "1"}, ) self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertEqual(response.json()["code"], "USER_PASSWORD_ERROR") - response = self.client.patch( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk}), + response = self.client.v2[user].patch( data={"password": "1" * 1000}, ) @@ -466,7 +444,7 @@ def test_update_password_self_by_profile_fail(self): self.client.login(username="test_user", password="test_user_password") - response = self.client.patch(path=reverse(viewname="v2:profile"), data={"newPassword": "newtestpassword"}) + response = (self.client.v2 / "profile").patch(data={"newPassword": "newtestpassword"}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.assertDictEqual( response.json(), @@ -484,8 +462,7 @@ def test_update_ldap_user_fail(self) -> None: for field in ("password", "email", "first_name", "last_name"): with self.subTest(f"Change {field}"): - response = self.client.patch( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk}), + response = self.client.v2[user].patch( data={field: "someval@ui.ie"}, ) @@ -498,8 +475,7 @@ def test_update_add_to_ldap_group_fail(self) -> None: self.group.type = OriginType.LDAP self.group.save() - response = self.client.patch( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk}), + response = self.client.v2[user].patch( data={"groups": [self.group.pk]}, ) @@ -513,8 +489,7 @@ def test_update_removed_from_ldap_group_fail(self) -> None: self.group.save() self.group.user_set.add(user) - response = self.client.patch( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk}), + response = self.client.v2[user].patch( data={"groups": []}, ) @@ -525,8 +500,7 @@ def test_update_removed_from_ldap_group_fail(self) -> None: def test_update_add_to_non_existing_group_fail(self) -> None: user = self.create_user(user_data={"username": "somebody", "password": "very_long_veryvery"}) - response = self.client.patch( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk}), + response = self.client.v2[user].patch( data={"groups": [1000]}, ) @@ -552,29 +526,27 @@ def test_permissions_updated_on_group_change_success(self) -> None: user = self.create_user(user_data={**creds, "groups": [{"id": self.group.pk}]}) - user_client = APIClient() + user_client = ADCMTestClient() user_client.login(**creds) self.assertEqual( - user_client.get(path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk})).status_code, + self.client.v2[user].get().status_code, HTTP_200_OK, ) self.assertEqual( - user_client.get(path=reverse(viewname="v2:cluster-detail", kwargs={"pk": self.cluster_1.pk})).status_code, + user_client.v2[self.cluster_1].get().status_code, HTTP_404_NOT_FOUND, ) - self.client.patch( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk}), + self.client.v2[user].patch( data={"groups": [group_2.pk]}, ) - self.assertEqual( - user_client.get(path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk})).status_code, + user_client.v2[user].get().status_code, HTTP_404_NOT_FOUND, ) self.assertEqual( - user_client.get(path=reverse(viewname="v2:cluster-detail", kwargs={"pk": self.cluster_1.pk})).status_code, + self.client.v2[self.cluster_1].get().status_code, HTTP_200_OK, ) @@ -585,22 +557,22 @@ def test_update_remove_from_groups_bug_adcm_5355(self) -> None: ) self.assertEqual(user.groups.count(), 1) - update_path = reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk}) - response = self.client.patch(path=update_path, data={"groups": []}) + update_path = self.client.v2[user] + response = update_path.patch(data={"groups": []}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()["groups"]), 0) user.refresh_from_db() self.assertEqual(user.groups.count(), 0) - response = self.client.patch(path=update_path, data={"groups": [self.group.pk, group_2.pk]}) + response = update_path.patch(data={"groups": [self.group.pk, group_2.pk]}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()["groups"]), 2) user.refresh_from_db() self.assertEqual(user.groups.count(), 2) - response = self.client.patch(path=update_path, data={"groups": [group_2.pk]}) + response = update_path.patch(data={"groups": [group_2.pk]}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()["groups"]), 1) @@ -611,9 +583,8 @@ def test_update_remove_from_groups_bug_adcm_5355(self) -> None: def test_delete_success(self): user = self.create_user() - response = self.client.delete( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk}), - ) + response = self.client.v2[user].delete() + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.assertIsNone(response.data) @@ -625,9 +596,8 @@ def test_delete_built_in_fail(self): user.built_in = True user.save(update_fields=["built_in"]) - response = self.client.delete( - path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": user.pk}), - ) + response = self.client.v2[user].delete() + self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertDictEqual( response.json(), @@ -640,9 +610,7 @@ def test_unblock_success(self): user.failed_login_attempts = 5 user.save(update_fields=["blocked_at", "failed_login_attempts"]) - response = self.client.post( - path=reverse(viewname="v2:rbac:user-unblock", kwargs={"pk": user.pk}), - ) + response = self.client.v2[user, "unblock"].post(data=None) user.refresh_from_db() self.assertEqual(response.status_code, HTTP_200_OK) @@ -677,7 +645,7 @@ def test_ordering_success(self): for data in user_data: self.create_user(user_data=data) - response = self.client.get(path=reverse(viewname="v2:rbac:user-list"), data={"ordering": "-username"}) + response = (self.client.v2 / "rbac" / "users").get(query={"ordering": "-username"}) self.assertEqual(response.status_code, HTTP_200_OK) response_usernames = [user["username"] for user in response.json()["results"]] @@ -689,7 +657,7 @@ def test_ordering_success(self): self.assertListEqual(response_usernames, db_usernames) def test_ordering_wrong_params_fail(self): - response = self.client.get(path=reverse(viewname="v2:rbac:user-list"), data={"ordering": "param"}) + response = (self.client.v2 / "rbac" / "users").get(query={"ordering": "param"}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -721,7 +689,7 @@ def test_filtering_by_username_success(self): for data in user_data: self.create_user(user_data=data) - response = self.client.get(path=reverse(viewname="v2:rbac:user-list"), data={"username": "username1"}) + response = (self.client.v2 / "rbac" / "users").get(query={"username": "username1"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()["results"]), 1) self.assertEqual(response.json()["results"][0]["username"], "username1") @@ -761,7 +729,7 @@ def test_filtering_by_status_success(self): target_user2.is_active = False target_user2.save(update_fields=["is_active"]) - response = self.client.get(path=reverse(viewname="v2:rbac:user-list"), data={"status": "blocked"}) + response = (self.client.v2 / "rbac" / "users").get(query={"status": "blocked"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()["results"]), 2) self.assertEqual(response.json()["results"][0]["username"], target_user1.username) @@ -791,9 +759,7 @@ def test_filtering_by_type_success(self): target_user.type = OriginType.LDAP target_user.save(update_fields=["type"]) - response = self.client.get( - path=reverse(viewname="v2:rbac:user-list"), data={"type": UserTypeChoices.LDAP.value} - ) + response = (self.client.v2 / "rbac" / "users").get(query={"type": UserTypeChoices.LDAP.value}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()["results"]), 1) self.assertEqual(response.json()["results"][0]["username"], target_user.username) @@ -833,14 +799,14 @@ def setUp(self) -> None: self.user_with_edit.user_permissions.add( Permission.objects.get(content_type=ContentType.objects.get_for_model(model=User), codename="view_user") ) - self.edit_client = APIClient() + self.edit_client = ADCMTestClient() self.edit_client.login(**creds) def test_retrieve_blocked_by_login_attempts(self) -> None: self.user.blocked_at = datetime.datetime.now(tz=pytz.UTC) self.user.save() - response = self.client.get(path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": self.user.pk})) + response = self.client.v2[self.user].get() self.assertEqual(response.status_code, HTTP_200_OK) data = response.json() @@ -851,7 +817,7 @@ def test_retrieve_blocked_manually(self) -> None: self.user.is_active = False self.user.save() - response = self.client.get(path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": self.user.pk})) + response = self.client.v2[self.user].get() self.assertEqual(response.status_code, HTTP_200_OK) data = response.json() @@ -863,7 +829,7 @@ def test_retrieve_blocked_both_ways(self) -> None: self.user.blocked_at = datetime.datetime.now(tz=pytz.UTC) self.user.save() - response = self.client.get(path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": self.user.pk})) + response = self.client.v2[self.user].get() self.assertEqual(response.status_code, HTTP_200_OK) data = response.json() @@ -874,7 +840,7 @@ def test_retrieve_not_blocked(self) -> None: self.user.failed_login_attempts = 10 self.user.save() - response = self.client.get(path=reverse(viewname="v2:rbac:user-detail", kwargs={"pk": self.user.pk})) + response = self.client.v2[self.user].get() self.assertEqual(response.status_code, HTTP_200_OK) data = response.json() @@ -882,7 +848,7 @@ def test_retrieve_not_blocked(self) -> None: self.assertEqual(data["blockingReason"], None) def test_block_manually_success(self) -> None: - response = self.client.post(path=reverse(viewname="v2:rbac:user-block", kwargs={"pk": self.user.pk})) + response = self.client.v2[self.user, "block"].post(data=None) self.assertEqual(response.status_code, HTTP_200_OK) @@ -891,7 +857,7 @@ def test_block_manually_success(self) -> None: self.assertFalse(self.user.is_active) def test_block_manually_self_fail(self) -> None: - response = self.client.post(path=reverse(viewname="v2:rbac:user-block", kwargs={"pk": self.admin.pk})) + response = self.client.v2[self.admin, "block"].post(data=None) self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertEqual(response.json()["desc"], "You can't block yourself.") @@ -901,7 +867,7 @@ def test_block_manually_self_fail(self) -> None: self.assertTrue(self.admin.is_active) def test_block_not_superuser_fail(self) -> None: - response = self.edit_client.post(path=reverse(viewname="v2:rbac:user-block", kwargs={"pk": self.user.pk})) + response = (self.edit_client.v2 / "rbac" / "users" / self.user.pk / "block").post(data=None) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.assertEqual(response.json()["detail"], "You do not have permission to perform this action.") @@ -914,7 +880,7 @@ def test_block_ldap_user_fail(self) -> None: self.user.type = OriginType.LDAP self.user.save() - response = self.client.post(path=reverse(viewname="v2:rbac:user-block", kwargs={"pk": self.user.pk})) + response = self.client.v2[self.user, "block"].post(data=None) self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertEqual(response.json()["desc"], "You can't block LDAP users.") @@ -924,7 +890,7 @@ def test_unblock_success(self) -> None: self.user.failed_login_attempts = 10 self.user.save() - response = self.client.post(path=reverse(viewname="v2:rbac:user-unblock", kwargs={"pk": self.user.pk})) + response = self.client.v2[self.user, "unblock"].post(data=None) self.assertEqual(response.status_code, HTTP_200_OK) self.user.refresh_from_db() @@ -939,7 +905,7 @@ def test_unblock_ldap_success(self) -> None: self.user.type = OriginType.LDAP self.user.save() - response = self.client.post(path=reverse(viewname="v2:rbac:user-unblock", kwargs={"pk": self.user.pk})) + response = self.client.v2[self.user, "unblock"].post(data=None) self.assertEqual(response.status_code, HTTP_200_OK) self.user.refresh_from_db() @@ -948,7 +914,7 @@ def test_unblock_ldap_success(self) -> None: self.assertEqual(self.user.failed_login_attempts, 0) def test_unblock_not_superuser_fail(self) -> None: - response = self.edit_client.post(path=reverse(viewname="v2:rbac:user-unblock", kwargs={"pk": self.user.pk})) + response = (self.edit_client.v2 / "rbac" / "users" / self.user.pk / "unblock").post(data=None) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.assertEqual(response.json()["detail"], "You do not have permission to perform this action.") From 237dab02e0275d36a6311403ec7c0223c63cffe6 Mon Sep 17 00:00:00 2001 From: Artem Starovoitov Date: Fri, 24 May 2024 13:06:18 +0000 Subject: [PATCH 124/208] ADCM-5578: Rework unittests `test_policy.py` --- python/adcm/tests/client.py | 3 +++ python/api_v2/tests/test_policy.py | 37 +++++++++++------------------- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/python/adcm/tests/client.py b/python/adcm/tests/client.py index 41edaad7a0..8c798a33f3 100644 --- a/python/adcm/tests/client.py +++ b/python/adcm/tests/client.py @@ -26,6 +26,7 @@ ServiceComponent, TaskLog, ) +from rbac.models import Policy, User from rest_framework.response import Response from rest_framework.test import APIClient @@ -88,6 +89,8 @@ class V2RootNode(RootNode): Host: "hosts", TaskLog: "tasks", JobLog: "jobs", + Policy: "rbac/policies", + User: "rbac/users", } def __getitem__(self, item: PathObject | tuple[PathObject, str | int | WithID, ...]) -> APINode: diff --git a/python/api_v2/tests/test_policy.py b/python/api_v2/tests/test_policy.py index 2fd0823209..ddc766ff27 100644 --- a/python/api_v2/tests/test_policy.py +++ b/python/api_v2/tests/test_policy.py @@ -10,7 +10,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from django.urls import reverse from rbac.models import Group, Policy, Role from rbac.services.policy import policy_create from rbac.services.role import role_create @@ -50,7 +49,7 @@ def setUp(self) -> None: ) def test_list_policy_success(self) -> None: - response = self.client.get(path=reverse(viewname="v2:rbac:policy-list")) + response = (self.client.v2 / "rbac" / "policies").get() self.assertEqual(response.status_code, HTTP_200_OK) data = response.json() @@ -60,9 +59,7 @@ def test_list_policy_success(self) -> None: self.assertTrue(all(set(policy).issuperset({"id", "name", "objects", "groups"}) for policy in policies)) def test_retrieve_policy_success(self) -> None: - response = self.client.get( - path=reverse(viewname="v2:rbac:policy-detail", kwargs={"pk": self.create_user_policy.pk}) - ) + response = self.client.v2[self.create_user_policy].get() self.assertEqual(response.status_code, HTTP_200_OK) data = response.json() @@ -88,14 +85,13 @@ def test_retrieve_policy_success(self) -> None: ) def test_create_parametrized_policy_only_required_fields_success(self) -> None: - response = self.client.post( - path=reverse(viewname="v2:rbac:policy-list"), + response = (self.client.v2 / "rbac" / "policies").post( data={ "name": "New Policy", "role": {"id": self.remove_hostprovider_role.pk}, "objects": [{"id": self.provider.pk, "type": "provider"}], "groups": [self.group_1.pk], - }, + } ) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -125,10 +121,7 @@ def test_update_policy_every_field_success(self) -> None: "objects": [], "groups": [self.group_2.pk], } - response = self.client.patch( - path=reverse(viewname="v2:rbac:policy-detail", kwargs={"pk": self.remove_hostprovider_policy.pk}), - data=new_data, - ) + response = self.client.v2[self.remove_hostprovider_policy].patch(data=new_data) self.assertEqual(response.status_code, HTTP_200_OK) data = response.json() @@ -141,43 +134,39 @@ def test_update_policy_every_field_success(self) -> None: ) def test_delete_policy_success(self) -> None: - response = self.client.delete( - path=reverse(viewname="v2:rbac:policy-detail", kwargs={"pk": self.create_user_policy.pk}) - ) + response = self.client.v2[self.create_user_policy].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.assertFalse(Policy.objects.filter(pk=self.create_user_policy.pk).exists()) def test_create_policy_no_group_fail(self): - response = self.client.post( - path=reverse(viewname="v2:rbac:policy-list"), + response = (self.client.v2 / "rbac" / "policies").post( data={ "name": "test_policy_new", "description": "description", "role": self.create_user_role.pk, "objects": [{"type": "cluster", "id": self.cluster_1.pk}], - }, + } ) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) def test_update_policy_no_operation_success(self): - response = self.client.patch( - path=reverse(viewname="v2:rbac:policy-detail", kwargs={"pk": self.create_user_policy.pk}), + response = self.client.v2[self.create_user_policy].patch( data={}, ) self.assertEqual(response.status_code, HTTP_200_OK) def test_update_policy_wrong_object_fail(self): - response = self.client.patch( - path=reverse(viewname="v2:rbac:policy-detail", kwargs={"pk": self.create_user_policy.pk}), - data={"objects": [{"type": "role", "id": self.create_user_role.pk}]}, + response = self.client.v2[self.create_user_policy].patch( + data={"objects": [{"type": "role", "id": self.create_user_role.pk}]} ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) def test_adcm_5103_policy_ordering_success(self) -> None: for name in ("Test", "Best", "Good", "Class"): policy_create(name=name, role=self.create_user_role, group=[self.group_1], object=[]) - response = self.client.get(path=reverse(viewname="v2:rbac:policy-list")) + response = (self.client.v2 / "rbac" / "policies").get() self.assertEqual(response.status_code, HTTP_200_OK) policies = [p["name"] for p in response.json()["results"]] From 1be5c72b026befa3b3d05ce580fbe701e0aca041 Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Mon, 27 May 2024 07:02:21 +0000 Subject: [PATCH 125/208] ADCM-5579: remove reverse from test_prototype.py --- python/adcm/tests/client.py | 4 ++- python/api_v2/tests/test_prototype.py | 41 +++++++-------------------- 2 files changed, 14 insertions(+), 31 deletions(-) diff --git a/python/adcm/tests/client.py b/python/adcm/tests/client.py index 8c798a33f3..26c3cfbbf7 100644 --- a/python/adcm/tests/client.py +++ b/python/adcm/tests/client.py @@ -23,6 +23,7 @@ HostProvider, JobLog, LogStorage, + Prototype, ServiceComponent, TaskLog, ) @@ -30,7 +31,7 @@ from rest_framework.response import Response from rest_framework.test import APIClient -_RootPathObject = Bundle | Cluster | HostProvider | Host | TaskLog | JobLog +_RootPathObject = Bundle | Cluster | HostProvider | Host | TaskLog | JobLog | Prototype PathObject = _RootPathObject | ClusterObject | ServiceComponent | LogStorage | GroupConfig @@ -89,6 +90,7 @@ class V2RootNode(RootNode): Host: "hosts", TaskLog: "tasks", JobLog: "jobs", + Prototype: "prototypes", Policy: "rbac/policies", User: "rbac/users", } diff --git a/python/api_v2/tests/test_prototype.py b/python/api_v2/tests/test_prototype.py index 5eec935836..dd57ebb234 100644 --- a/python/api_v2/tests/test_prototype.py +++ b/python/api_v2/tests/test_prototype.py @@ -13,7 +13,6 @@ from operator import itemgetter from cm.models import Bundle, ObjectType, ProductCategory, Prototype -from rest_framework.reverse import reverse from rest_framework.status import ( HTTP_200_OK, HTTP_400_BAD_REQUEST, @@ -39,39 +38,31 @@ def setUp(self) -> None: self.prototype_ids = list(Prototype.objects.exclude(name="ADCM").values_list("pk", flat=True)) def test_list_success(self): - response = self.client.get(path=reverse(viewname="v2:prototype-list")) + response = (self.client.v2 / "prototypes").get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], len(self.prototype_ids)) def test_versions_cluster_success(self): - response = self.client.get( - path=reverse(viewname="v2:prototype-versions"), data={"type": ObjectType.CLUSTER.value} - ) + response = (self.client.v2 / "prototypes" / "versions").get(query={"type": ObjectType.CLUSTER.value}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()), 1) self.assertEqual(len(response.json()[0]["versions"]), 2) def test_retrieve_success(self): - response = self.client.get( - path=reverse(viewname="v2:prototype-detail", kwargs={"pk": self.cluster_1_prototype.pk}) - ) + response = self.client.v2[self.cluster_1_prototype].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["id"], self.cluster_1_prototype.pk) def test_retrieve_not_found_fail(self): - response = self.client.get( - path=reverse(viewname="v2:prototype-detail", kwargs={"pk": max(self.prototype_ids) + 1}) - ) + response = (self.client.v2 / "prototypes" / str(self.get_non_existent_pk(model=Prototype))).get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_accept_license_success(self): - response = self.client.post( - path=reverse(viewname="v2:prototype-accept-license", kwargs={"pk": self.cluster_1_prototype.pk}) - ) + response = self.client.v2[self.cluster_1_prototype, "license", "accept"].post(data=None) self.assertEqual(response.status_code, HTTP_200_OK) self.cluster_1_prototype.refresh_from_db(fields=["license"]) @@ -79,16 +70,12 @@ def test_accept_license_success(self): def test_accept_non_existing_license_fail(self): prototype_without_license = Prototype.objects.exclude(name="ADCM").filter(license="absent").first() - response = self.client.post( - path=reverse(viewname="v2:prototype-accept-license", kwargs={"pk": prototype_without_license.pk}) - ) + response = self.client.v2[prototype_without_license, "license", "accept"].post(data=None) self.assertEqual(response.status_code, HTTP_409_CONFLICT) def test_filter_by_bundle_id_and_type_cluster(self): - response = self.client.get( - path=reverse(viewname="v2:prototype-list"), data={"bundleId": self.bundle_1.id, "type": "cluster"} - ) + response = (self.client.v2 / "prototypes").get(query={"bundleId": self.bundle_1.id, "type": "cluster"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.data["count"], 1) @@ -166,9 +153,7 @@ def setUp(self): ) def test_absent_cluster_candidate_bug_4851(self): - response = self.client.get( - path=reverse(viewname="v2:prototype-versions"), data={"type": ObjectType.CLUSTER.value} - ) + response = (self.client.v2 / "prototypes" / "versions").get(query={"type": ObjectType.CLUSTER.value}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertListEqual( @@ -177,9 +162,7 @@ def test_absent_cluster_candidate_bug_4851(self): ) def test_absent_hostprovider_candidate_bug_4851(self): - response = self.client.get( - path=reverse(viewname="v2:prototype-versions"), data={"type": ObjectType.PROVIDER.value} - ) + response = (self.client.v2 / "prototypes" / "versions").get(query={"type": ObjectType.PROVIDER.value}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertListEqual( @@ -190,9 +173,7 @@ def test_absent_hostprovider_candidate_bug_4851(self): def test_child_filters_disallowed_failed(self): for disallowed_type in (ObjectType.ADCM, ObjectType.SERVICE, ObjectType.COMPONENT, ObjectType.HOST): with self.subTest(msg=disallowed_type.value): - response = self.client.get( - path=reverse(viewname="v2:prototype-versions"), data={"type": disallowed_type.value} - ) + response = (self.client.v2 / "prototypes" / "versions").get(query={"type": disallowed_type.value}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.assertIn(f"{disallowed_type.value} is not one of the available choices", response.json()["desc"]) @@ -205,7 +186,7 @@ def test_no_filter_success(self): ) Prototype.objects.create(bundle=bundle, type="provider", name=name, display_name=name, version="3") - response = self.client.get(path=reverse(viewname="v2:prototype-versions")) + response = (self.client.v2 / "prototypes" / "versions").get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertListEqual( From 1ef3b850f15b44d0dce646f052e73b4c602ed850 Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Mon, 27 May 2024 07:03:37 +0000 Subject: [PATCH 126/208] ADCM-5435: Rework and add unittests for `adcm_multi_state_set` --- .../plugins/action/adcm_multi_state_set.py | 94 +++--------------- python/ansible/plugins/action/adcm_state.py | 2 +- python/ansible_plugin/base.py | 20 +++- .../executors/multi_state_set.py | 55 +++++++++++ python/ansible_plugin/executors/state.py | 31 ++---- ....py => test_adcm_state_multi_state_set.py} | 96 +++++++++++++++---- 6 files changed, 170 insertions(+), 128 deletions(-) create mode 100644 python/ansible_plugin/executors/multi_state_set.py rename python/ansible_plugin/tests/{test_adcm_state.py => test_adcm_state_multi_state_set.py} (54%) diff --git a/python/ansible/plugins/action/adcm_multi_state_set.py b/python/ansible/plugins/action/adcm_multi_state_set.py index 195e5aa621..95e2d2e621 100644 --- a/python/ansible/plugins/action/adcm_multi_state_set.py +++ b/python/ansible/plugins/action/adcm_multi_state_set.py @@ -17,16 +17,6 @@ import adcm.init_django # noqa: F401, isort:skip -from ansible_plugin.utils import ( - ContextActionModule, - set_cluster_multi_state, - set_component_multi_state, - set_component_multi_state_by_name, - set_host_multi_state, - set_provider_multi_state, - set_service_multi_state, - set_service_multi_state_by_name, -) ANSIBLE_METADATA = {"metadata_version": "1.1", "supported_by": "Arenadata"} @@ -65,6 +55,11 @@ type: string description: useful in cluster and component context only. In that context you are able to set the state for a component belongs to the service + + - option-name: host_id + required: false + type: int + description: ID of the host. Useful in provider context only """ EXAMPLES = r""" @@ -97,76 +92,9 @@ """ -class ActionModule(ContextActionModule): - TRANSFERS_FILES = False - _VALID_ARGS = frozenset(("type", "service_name", "component_name", "state", "host_id")) - _MANDATORY_ARGS = ("type", "state") - - def _do_cluster(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call(set_cluster_multi_state, context["cluster_id"], self._task.args["state"]) - res["state"] = self._task.args["state"] - return res - - def _do_service_by_name(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - set_service_multi_state_by_name, - context["cluster_id"], - self._task.args["service_name"], - self._task.args["state"], - ) - res["state"] = self._task.args["state"] - return res - - def _do_service(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - set_service_multi_state, - context["cluster_id"], - context["service_id"], - self._task.args["state"], - ) - res["state"] = self._task.args["state"] - return res - - def _do_host(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - set_host_multi_state, - context["host_id"], - self._task.args["state"], - ) - res["state"] = self._task.args["state"] - return res - - def _do_provider(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call(set_provider_multi_state, context["provider_id"], self._task.args["state"]) - res["state"] = self._task.args["state"] - return res - - def _do_host_from_provider(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - set_host_multi_state, - self._task.args["host_id"], - self._task.args["state"], - ) - res["state"] = self._task.args["state"] - return res - - def _do_component_by_name(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - set_component_multi_state_by_name, - context["cluster_id"], - context["service_id"], - self._task.args["component_name"], - self._task.args.get("service_name", None), - self._task.args["state"], - ) - res["state"] = self._task.args["state"] - return res - - def _do_component(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - set_component_multi_state, - context["component_id"], - self._task.args["state"], - ) - res["state"] = self._task.args["state"] - return res +from ansible_plugin.base import ADCMAnsiblePlugin +from ansible_plugin.executors.multi_state_set import ADCMMultiStateSetPluginExecutor + + +class ActionModule(ADCMAnsiblePlugin): + executor_class = ADCMMultiStateSetPluginExecutor diff --git a/python/ansible/plugins/action/adcm_state.py b/python/ansible/plugins/action/adcm_state.py index 8f5475924c..09479db6d7 100644 --- a/python/ansible/plugins/action/adcm_state.py +++ b/python/ansible/plugins/action/adcm_state.py @@ -58,7 +58,7 @@ - option-name: host_id required: false type: int - description: ID of the host + description: ID of the host. Useful in provider context only notes: - If type is 'service' ('component') there is no needs to specify service_name (component_name) diff --git a/python/ansible_plugin/base.py b/python/ansible_plugin/base.py index eb9937715b..d67bdaa8fd 100644 --- a/python/ansible_plugin/base.py +++ b/python/ansible_plugin/base.py @@ -12,15 +12,17 @@ from abc import abstractmethod from dataclasses import dataclass -from typing import Any, Collection, Generic, Literal, Mapping, Protocol, TypeVar +from typing import Any, Collection, Generic, Literal, Mapping, Protocol, TypeAlias, TypeVar import fcntl from ansible.errors import AnsibleActionFail from ansible.module_utils._text import to_native from ansible.plugins.action import ActionBase -from cm.models import ClusterObject, ServiceComponent +from cm.converters import core_type_to_model +from cm.models import Cluster, ClusterObject, Host, HostProvider, ServiceComponent from core.types import ADCMCoreType, CoreObjectDescriptor, ObjectID from django.conf import settings +from django.db.models import ObjectDoesNotExist from pydantic import BaseModel, ValidationError, field_validator from ansible_plugin.errors import ( @@ -35,6 +37,7 @@ TargetTypeLiteral = Literal["cluster", "service", "component", "provider", "host"] +ProductORMObject: TypeAlias = Cluster | ClusterObject | ServiceComponent | HostProvider | Host class VarsContextSection(BaseModel): @@ -224,6 +227,15 @@ def _from_target_description( raise PluginRuntimeError(message=message) +def retrieve_orm_object( + object_: CoreObjectDescriptor, error_class: type[ADCMPluginError] = PluginTargetDetectionError +) -> ProductORMObject: + try: + return core_type_to_model(core_type=object_.type).objects.get(pk=object_.id) + except ObjectDoesNotExist: + raise error_class(message=f'Failed to locate {object_.type} with id "{object_.id}"') from None + + # Plugin CallArguments = TypeVar("CallArguments", bound=BaseModel) @@ -236,6 +248,10 @@ class NoArguments(BaseModel): """ +class SingleStateArgument(BaseModel): + state: str + + @dataclass(frozen=True, slots=True) class CallResult(Generic[ReturnValue]): # If value is a mapping of some sort, it'll be unpacked into return dict. diff --git a/python/ansible_plugin/executors/multi_state_set.py b/python/ansible_plugin/executors/multi_state_set.py new file mode 100644 index 0000000000..bf2c4c8e2e --- /dev/null +++ b/python/ansible_plugin/executors/multi_state_set.py @@ -0,0 +1,55 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Collection, TypedDict + +from core.types import CoreObjectDescriptor + +from ansible_plugin.base import ( + ADCMAnsiblePluginExecutor, + ArgumentsConfig, + CallResult, + PluginExecutorConfig, + RuntimeEnvironment, + SingleStateArgument, + TargetConfig, + from_arguments_root, + retrieve_orm_object, +) +from ansible_plugin.executors._validators import validate_target_allowed_for_context_owner, validate_type_is_present + + +class ChangeMultiStateReturnValue(TypedDict): + state: str + + +class ADCMMultiStateSetPluginExecutor(ADCMAnsiblePluginExecutor[SingleStateArgument, ChangeMultiStateReturnValue]): + _config = PluginExecutorConfig( + arguments=ArgumentsConfig(represent_as=SingleStateArgument), + target=TargetConfig(detectors=(from_arguments_root,), validators=(validate_type_is_present,)), + ) + + def __call__( + self, + targets: Collection[CoreObjectDescriptor], + arguments: SingleStateArgument, + runtime: RuntimeEnvironment, + ) -> CallResult[ChangeMultiStateReturnValue]: + target, *_ = targets + + if error := validate_target_allowed_for_context_owner(context_owner=runtime.context_owner, target=target): + return CallResult(value=None, changed=False, error=error) + + target_object = retrieve_orm_object(object_=target) + target_object.set_multi_state(arguments.state) + + return CallResult(value=ChangeMultiStateReturnValue(state=arguments.state), changed=True, error=None) diff --git a/python/ansible_plugin/executors/state.py b/python/ansible_plugin/executors/state.py index 34c32decb6..9f865f53a8 100644 --- a/python/ansible_plugin/executors/state.py +++ b/python/ansible_plugin/executors/state.py @@ -13,11 +13,8 @@ from contextlib import suppress from typing import Collection, TypedDict -from cm.converters import core_type_to_model from cm.status_api import send_object_update_event from core.types import CoreObjectDescriptor -from django.db.models import ObjectDoesNotExist -from pydantic import BaseModel from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, @@ -25,50 +22,38 @@ CallResult, PluginExecutorConfig, RuntimeEnvironment, + SingleStateArgument, TargetConfig, from_arguments_root, + retrieve_orm_object, ) -from ansible_plugin.errors import PluginTargetDetectionError from ansible_plugin.executors._validators import validate_target_allowed_for_context_owner, validate_type_is_present -class ChangeStateArguments(BaseModel): - state: str - - class ChangeStateReturnValue(TypedDict): state: str -class ADCMStatePluginExecutor(ADCMAnsiblePluginExecutor[ChangeStateArguments, ChangeStateReturnValue]): +class ADCMStatePluginExecutor(ADCMAnsiblePluginExecutor[SingleStateArgument, ChangeStateReturnValue]): _config = PluginExecutorConfig( - arguments=ArgumentsConfig(represent_as=ChangeStateArguments), + arguments=ArgumentsConfig(represent_as=SingleStateArgument), target=TargetConfig(detectors=(from_arguments_root,), validators=(validate_type_is_present,)), ) def __call__( self, targets: Collection[CoreObjectDescriptor], - arguments: ChangeStateArguments, + arguments: SingleStateArgument, runtime: RuntimeEnvironment, ) -> CallResult[ChangeStateReturnValue]: target, *_ = targets if error := validate_target_allowed_for_context_owner(context_owner=runtime.context_owner, target=target): - return CallResult(value={}, changed=False, error=error) - - try: - target_object = core_type_to_model(core_type=target.type).objects.get(pk=target.id) - except ObjectDoesNotExist: - return CallResult( - value=None, - changed=False, - error=PluginTargetDetectionError(message=f'Failed to locate {target.type} with id "{target.id}"'), - ) + return CallResult(value=None, changed=False, error=error) + target_object = retrieve_orm_object(object_=target) target_object.set_state(state=arguments.state) - with suppress(Exception): - send_object_update_event(object_=target, changes={"state": arguments.state}) + send_object_update_event(object_=target_object, changes={"state": arguments.state}) return CallResult(value=ChangeStateReturnValue(state=arguments.state), changed=True, error=None) diff --git a/python/ansible_plugin/tests/test_adcm_state.py b/python/ansible_plugin/tests/test_adcm_state_multi_state_set.py similarity index 54% rename from python/ansible_plugin/tests/test_adcm_state.py rename to python/ansible_plugin/tests/test_adcm_state_multi_state_set.py index e5c560ba35..be4392527a 100644 --- a/python/ansible_plugin/tests/test_adcm_state.py +++ b/python/ansible_plugin/tests/test_adcm_state_multi_state_set.py @@ -15,13 +15,15 @@ from cm.models import Cluster, ClusterObject, Host, HostProvider, ServiceComponent from cm.services.job.run.repo import JobRepoImpl +from ansible_plugin.base import ADCMAnsiblePluginExecutor +from ansible_plugin.executors.multi_state_set import ADCMMultiStateSetPluginExecutor from ansible_plugin.executors.state import ADCMStatePluginExecutor from ansible_plugin.tests.base import BaseTestEffectsOfADCMAnsiblePlugins -ADCM_OBJECT: TypeAlias = Cluster | ClusterObject | ServiceComponent | HostProvider | Host +ADCMObject: TypeAlias = Cluster | ClusterObject | ServiceComponent | HostProvider | Host -class TestADCMStatePluginExecutor(BaseTestEffectsOfADCMAnsiblePlugins): +class TestADCMStateMultiStatePluginExecutors(BaseTestEffectsOfADCMAnsiblePlugins): def setUp(self) -> None: super().setUp() @@ -29,7 +31,7 @@ def setUp(self) -> None: self.service = services.get(prototype__name="service_1") self.component = self.service.servicecomponent_set.first() - self.new_state = "brand new object's state" + self.new_state = "brand new object's (multi)state" provider = self.add_provider(bundle=self.provider_bundle, name="Control provider") cluster = self.add_cluster(bundle=self.cluster_bundle, name="Control cluster") @@ -38,41 +40,57 @@ def setUp(self) -> None: self.control_objects = [cluster, service_2, *list(other_components), provider, self.host_2] def _execute_test( - self, owner: ADCM_OBJECT, target: ADCM_OBJECT, call_arguments: str | dict, expect_fail: bool = False + self, + owner: ADCMObject, + target: ADCMObject, + call_arguments: str | dict, + executor_class: type[ADCMAnsiblePluginExecutor], + expected_value: str | list[str] | None = None, + expect_fail: bool = False, ) -> None: - old_state = target.state + match executor_class.__name__: + case ADCMStatePluginExecutor.__name__: + model_field = "state" + control_value = ["created"] + case ADCMMultiStateSetPluginExecutor.__name__: + model_field = "multi_state" + control_value = [[]] + case _: + raise NotImplementedError(str(executor_class)) task = self.prepare_task(owner=owner, name="dummy") job, *_ = JobRepoImpl.get_task_jobs(task.id) executor = self.prepare_executor( - executor_type=ADCMStatePluginExecutor, + executor_type=executor_class, call_arguments=call_arguments, call_context=job, ) result = executor.execute() if expect_fail: - target_state = old_state self.assertIsNotNone(result.error) self.assertFalse(result.changed) else: - target_state = self.new_state self.assertIsNone(result.error) self.assertTrue(result.changed) target.refresh_from_db() - self.assertEqual(target.state, target_state) + expected_value = control_value[0] if expect_fail else expected_value + self.assertEqual(getattr(target, model_field), expected_value) - def _check_control_group(self): - states = set() + if expect_fail: + return + + # check control objects' states + states = [] for object_ in self.control_objects: object_.refresh_from_db() - states.add(object_.state) + states.append(getattr(object_, model_field)) - self.assertEqual(states, {"created"}) + self.assertListEqual(states, control_value * len(self.control_objects)) - def test_states(self): + def test_set_states(self): for owner, target, call_args in ( (self.cluster, self.cluster, {"type": "cluster", "state": self.new_state}), ( @@ -105,13 +123,53 @@ def test_states(self): (self.host_1, self.provider, {"type": "provider", "state": self.new_state}), (self.host_1, self.host_1, {"type": "host", "state": self.new_state}), ): - with self.subTest(owner=owner, target=target, call_args=call_args): - self._execute_test(owner=owner, target=target, call_arguments=call_args) - self._check_control_group() + for executor_class, expected_value in ( + (ADCMStatePluginExecutor, self.new_state), + (ADCMMultiStateSetPluginExecutor, [self.new_state]), + ): + with self.subTest( + owner=owner, + target=target, + call_args=call_args, + executor_class=executor_class, + expected_value=expected_value, + ): + self._execute_test( + owner=owner, + target=target, + call_arguments=call_args, + executor_class=executor_class, + expected_value=expected_value, + ) def test_forbidden_owner_targert_pairs(self): for owner, target, call_args in ( (self.host_1, self.host_2, {"type": "host", "host_id": self.host_2.pk, "state": self.new_state}), ): - with self.subTest(owner=owner, target=target, call_args=call_args): - self._execute_test(owner=owner, target=target, call_arguments=call_args, expect_fail=True) + for executor_class in (ADCMStatePluginExecutor, ADCMMultiStateSetPluginExecutor): + with self.subTest(owner=owner, target=target, call_args=call_args, executor_class=executor_class): + self._execute_test( + owner=owner, + target=target, + call_arguments=call_args, + executor_class=executor_class, + expect_fail=True, + ) + + def test_multi_state_adds_value(self): + self._execute_test( + owner=self.service, + target=self.cluster, + call_arguments={"type": "cluster", "state": self.new_state}, + executor_class=ADCMMultiStateSetPluginExecutor, + expected_value=[self.new_state], + ) + + another_state = "another state" + self._execute_test( + owner=self.component, + target=self.cluster, + call_arguments={"type": "cluster", "state": another_state}, + executor_class=ADCMMultiStateSetPluginExecutor, + expected_value=sorted([self.new_state, another_state]), + ) From 64f358fdd2d8755feb5b389b41107e4ffcff2cff Mon Sep 17 00:00:00 2001 From: Aleksandr Alferov Date: Mon, 27 May 2024 07:48:39 +0000 Subject: [PATCH 127/208] ADCM-5604 Prepare prototype for new collect_statistics command --- python/cm/collect_statistics/__init__.py | 11 +++ python/cm/collect_statistics/collectors.py | 69 ++++++++++++++++++ python/cm/collect_statistics/encoders.py | 23 ++++++ python/cm/collect_statistics/senders.py | 21 ++++++ python/cm/collect_statistics/storages.py | 38 ++++++++++ python/cm/collect_statistics/types.py | 48 +++++++++++++ .../commands/collect_statistics_new.py | 72 +++++++++++++++++++ 7 files changed, 282 insertions(+) create mode 100644 python/cm/collect_statistics/__init__.py create mode 100644 python/cm/collect_statistics/collectors.py create mode 100644 python/cm/collect_statistics/encoders.py create mode 100644 python/cm/collect_statistics/senders.py create mode 100644 python/cm/collect_statistics/storages.py create mode 100644 python/cm/collect_statistics/types.py create mode 100644 python/cm/management/commands/collect_statistics_new.py diff --git a/python/cm/collect_statistics/__init__.py b/python/cm/collect_statistics/__init__.py new file mode 100644 index 0000000000..824dd6c8fe --- /dev/null +++ b/python/cm/collect_statistics/__init__.py @@ -0,0 +1,11 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/python/cm/collect_statistics/collectors.py b/python/cm/collect_statistics/collectors.py new file mode 100644 index 0000000000..7d07f0b351 --- /dev/null +++ b/python/cm/collect_statistics/collectors.py @@ -0,0 +1,69 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pydantic import BaseModel + +from cm.collect_statistics.types import Collector + + +class BundleData(BaseModel): + name: str + version: str + edition: str + date: str + + +class HostComponentData(BaseModel): + host_name: str + component_name: str + service_name: str + + +class ClusterData(BaseModel): + name: str + host_count: int + bundle: dict + host_component_map: list[dict] + + +class HostProviderData(BaseModel): + name: str + host_count: int + bundle: dict + + +class UserData(BaseModel): + email: str + date_joined: str + + +class RoleData(BaseModel): + name: str + built_in: bool + + +class ADCMEntities(BaseModel): + clusters: list[ClusterData] + bundles: list[BundleData] + providers: list[HostProviderData] + users: list[UserData] + roles: list[RoleData] + + +class CommunityBundleCollector(Collector): + def __call__(self) -> ADCMEntities: + pass + + +class EnterpriseBundleCollector(Collector): + def __call__(self) -> ADCMEntities: + pass diff --git a/python/cm/collect_statistics/encoders.py b/python/cm/collect_statistics/encoders.py new file mode 100644 index 0000000000..9f519069dc --- /dev/null +++ b/python/cm/collect_statistics/encoders.py @@ -0,0 +1,23 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path + +from cm.collect_statistics.types import Encoder + + +class TarFileEncoder(Encoder[Path]): + def encode(self, data: Path) -> Path: + pass + + def decode(self, data: Path) -> Path: + pass diff --git a/python/cm/collect_statistics/senders.py b/python/cm/collect_statistics/senders.py new file mode 100644 index 0000000000..5872f42fcf --- /dev/null +++ b/python/cm/collect_statistics/senders.py @@ -0,0 +1,21 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path +from typing import Collection + +from cm.collect_statistics.types import Sender + + +class StatisticSender(Sender[Path]): + def send(self, targets: Collection[Path]) -> None: + pass diff --git a/python/cm/collect_statistics/storages.py b/python/cm/collect_statistics/storages.py new file mode 100644 index 0000000000..abd7021c2c --- /dev/null +++ b/python/cm/collect_statistics/storages.py @@ -0,0 +1,38 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path + +from pydantic import BaseModel + +from cm.collect_statistics.types import Storage + + +class JSONFile(BaseModel): + filename: str + data: dict + + +class TarFileWithJSONFileStorage(Storage[JSONFile]): + def add(self, data: JSONFile) -> None: + pass + + def gather(self) -> Path: + pass + + +class TarFileWithTarFileStorage(Storage[Path]): + def add(self, data: Path) -> None: + pass + + def gather(self) -> Path: + pass diff --git a/python/cm/collect_statistics/types.py b/python/cm/collect_statistics/types.py new file mode 100644 index 0000000000..482f5d82c4 --- /dev/null +++ b/python/cm/collect_statistics/types.py @@ -0,0 +1,48 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Collection, Generic, Protocol, TypeVar + +T = TypeVar("T") + + +class Collector(Protocol): + def __call__(self) -> T: + pass + + +class Storage(Generic[T], ABC): + @abstractmethod + def add(self, data: T) -> None: + pass + + @abstractmethod + def gather(self) -> Path: + pass + + +class Sender(Generic[T], ABC): + @abstractmethod + def send(self, targets: Collection[T]) -> None: + pass + + +class Encoder(Generic[T], ABC): + @abstractmethod + def encode(self, data: T) -> T: + pass + + @abstractmethod + def decode(self, data: T) -> T: + pass diff --git a/python/cm/management/commands/collect_statistics_new.py b/python/cm/management/commands/collect_statistics_new.py new file mode 100644 index 0000000000..63adb6b5c1 --- /dev/null +++ b/python/cm/management/commands/collect_statistics_new.py @@ -0,0 +1,72 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from django.conf import settings +from django.core.management import BaseCommand + +from cm.collect_statistics.collectors import CommunityBundleCollector, EnterpriseBundleCollector +from cm.collect_statistics.encoders import TarFileEncoder +from cm.collect_statistics.senders import StatisticSender +from cm.collect_statistics.storages import JSONFile, TarFileWithJSONFileStorage, TarFileWithTarFileStorage +from cm.models import ADCM + + +def is_internal() -> bool: + return True # TODO: implement logic + + +class Command(BaseCommand): + help = "Collect data and send to Statistic Server" + + def add_arguments(self, parser): + parser.add_argument("--full", action="store_true", help="collect all data") + parser.add_argument("--send", action="store_true", help="send data to Statistic Server") + parser.add_argument("--encode", action="store_true", help="encode data") + + def handle(self, *_, full: bool, send: bool, encode: bool, **__): + statistics_data = { + "adcm": { + "uuid": str(ADCM.objects.values_list("uuid", flat=True).get()), + "version": settings.ADCM_VERSION, + "is_internal": is_internal(), + }, + "format_version": "0.2", + } + + community_bundle_data = CommunityBundleCollector()() + community_storage = TarFileWithJSONFileStorage() + + community_storage.add(JSONFile(filename="community.json", data={**statistics_data, **community_bundle_data})) + community_archive = community_storage.gather() + + final_storage = TarFileWithTarFileStorage() + final_storage.add(community_archive) + + if full: + enterprise_bundle_data = EnterpriseBundleCollector()() + enterprise_storage = TarFileWithJSONFileStorage() + + enterprise_storage.add( + JSONFile(filename="enterprise.json", data={**statistics_data, **enterprise_bundle_data}) + ) + final_storage.add(enterprise_storage.gather()) + + final_archive = final_storage.gather() + + if encode: + encoder = TarFileEncoder() + encoder.encode(final_archive) + + if send: + sender = StatisticSender() + sender.send([community_archive]) From 0622203e51fc30b56cda5459a8afc9a7cf81bb27 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Mon, 27 May 2024 07:50:31 +0000 Subject: [PATCH 128/208] ADCM-5569 Remove `reverse` from `test_config` --- python/api_v2/tests/test_config.py | 1236 ++++------------------------ 1 file changed, 167 insertions(+), 1069 deletions(-) diff --git a/python/api_v2/tests/test_config.py b/python/api_v2/tests/test_config.py index f8587cdecd..9c1a8879f7 100644 --- a/python/api_v2/tests/test_config.py +++ b/python/api_v2/tests/test_config.py @@ -31,7 +31,6 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from rest_framework.response import Response -from rest_framework.reverse import reverse from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, @@ -45,6 +44,9 @@ from api_v2.config.utils import convert_adcm_meta_to_attr, convert_attr_to_adcm_meta from api_v2.tests.base import BaseAPITestCase +CONFIGS = "configs" +CONFIG_SCHEMA = "config-schema" + class TestClusterConfig(BaseAPITestCase): def setUp(self) -> None: @@ -58,9 +60,7 @@ def setUp(self) -> None: self.test_user = self.create_user(**self.test_user_credentials) def test_list_success(self): - response = self.client.get( - path=reverse(viewname="v2:cluster-config-list", kwargs={"cluster_pk": self.cluster_1.pk}) - ) + response = self.client.v2[self.cluster_1, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) @@ -70,12 +70,7 @@ def test_list_success(self): ) def test_retrieve_success(self): - response = self.client.get( - path=reverse( - viewname="v2:cluster-config-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.cluster_1_config.pk}, - ) - ) + response = self.client.v2[self.cluster_1, CONFIGS, self.cluster_1_config].get() self.assertEqual(response.status_code, HTTP_200_OK) data = { @@ -106,9 +101,7 @@ def test_create_success(self): "adcmMeta": {"/activatable_group": {"isActive": False}}, "description": "new config", } - response = self.client.post( - path=reverse(viewname="v2:cluster-config-list", kwargs={"cluster_pk": self.cluster_1.pk}), data=data - ) + response = self.client.v2[self.cluster_1, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) response_data = response.json() @@ -130,9 +123,7 @@ def test_create_bad_attr_fail(self): "adcmMeta": {"bad_key": "bad_value"}, "description": "new config", } - response = self.client.post( - path=reverse(viewname="v2:cluster-config-list", kwargs={"cluster_pk": self.cluster_1.pk}), data=data - ) + response = self.client.v2[self.cluster_1, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -157,9 +148,7 @@ def test_create_bad_and_good_attr_fail(self): "adcmMeta": {"/activatable_group": {"isActive": False}, "/bad_key": {"isActive": False}}, "description": "new config", } - response = self.client.post( - path=reverse(viewname="v2:cluster-config-list", kwargs={"cluster_pk": self.cluster_1.pk}), data=data - ) + response = self.client.v2[self.cluster_1, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -172,7 +161,7 @@ def test_create_bad_and_good_attr_fail(self): ) def test_schema(self): - response = self.client.get(path=reverse(viewname="v2:cluster-config-schema", kwargs={"pk": self.cluster_1.pk})) + response = self.client.v2[self.cluster_1, CONFIG_SCHEMA].get() expected_data = json.loads( (self.test_files_dir / "responses" / "config_schemas" / "for_cluster.json").read_text(encoding="utf-8") @@ -184,75 +173,50 @@ def test_schema(self): def test_schema_permissions_model_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object configuration"): - response = self.client.get( - path=reverse(viewname="v2:cluster-config-schema", kwargs={"pk": self.cluster_1.pk}) - ) + response = self.client.v2[self.cluster_1, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_schema_permissions_object_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="Cluster Administrator"): - response = self.client.get( - path=reverse(viewname="v2:cluster-config-schema", kwargs={"pk": self.cluster_1.pk}) - ) + response = self.client.v2[self.cluster_1, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_schema_permissions_another_object_role_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.service_1, role_name="Service Administrator"): - response = self.client.get( - path=reverse( - viewname="v2:cluster-config-schema", - kwargs={"pk": self.cluster_1.pk}, - ) - ) + response = self.client.v2[self.cluster_1, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_model_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object configuration"): - response = self.client.get( - path=reverse(viewname="v2:cluster-config-list", kwargs={"cluster_pk": self.cluster_1.pk}) - ) + response = self.client.v2[self.cluster_1, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_permissions_object_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="Cluster Administrator"): - response = self.client.get( - path=reverse(viewname="v2:cluster-config-list", kwargs={"cluster_pk": self.cluster_1.pk}) - ) + response = self.client.v2[self.cluster_1, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_permissions_another_object_role_list_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.service_1, role_name="Service Administrator"): - response = self.client.get( - path=reverse(viewname="v2:cluster-config-list", kwargs={"cluster_pk": self.cluster_1.pk}) - ) + response = self.client.v2[self.cluster_1, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_another_object_role_retrieve_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.service_1, role_name="Service Administrator"): - response = self.client.get( - path=reverse( - viewname="v2:cluster-config-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.cluster_1_config.pk}, - ) - ) + response = self.client.v2[self.cluster_1, CONFIGS, self.cluster_1_config].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_schema_permissions_another_model_role_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object import"): - response = self.client.get( - path=reverse( - viewname="v2:cluster-config-schema", - kwargs={"pk": self.cluster_1.pk}, - ) - ) + response = self.client.v2[self.cluster_1, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_schema_permissions_another_model_and_object_role_denied(self): @@ -260,31 +224,19 @@ def test_schema_permissions_another_model_and_object_role_denied(self): with self.grant_permissions(to=self.test_user, on=[], role_name="View any object import"): with self.grant_permissions(to=self.test_user, on=self.cluster_2, role_name="Cluster Administrator"): - response = self.client.get( - path=reverse( - viewname="v2:cluster-config-schema", - kwargs={"pk": self.cluster_1.pk}, - ) - ) + response = self.client.v2[self.cluster_1, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_another_object_role_create_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.service_1, role_name="Service Administrator"): - response = self.client.post( - path=reverse(viewname="v2:cluster-config-list", kwargs={"cluster_pk": self.cluster_1.pk}), data={} - ) + response = self.client.v2[self.cluster_1, CONFIGS].post(data={}) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_model_role_list_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object import"): - response = self.client.get( - path=reverse( - viewname="v2:cluster-config-list", - kwargs={"cluster_pk": self.cluster_1.pk}, - ) - ) + response = self.client.v2[self.cluster_1, CONFIGS].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -295,12 +247,7 @@ def test_schema_cluster_permissions_another_object_role_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="Map hosts"): with self.grant_permissions(to=self.test_user, on=host_1, role_name="Manage Maintenance mode"): - response = self.client.get( - path=reverse( - viewname="v2:cluster-config-schema", - kwargs={"pk": self.cluster_1.pk}, - ) - ) + response = self.client.v2[self.cluster_1, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -315,13 +262,7 @@ def setUp(self) -> None: ).get() def test_save_empty_config_success(self): - response = self.client.post( - path=reverse( - viewname="v2:service-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service.pk}, - ), - data={"config": {}, "adcmMeta": {}}, - ) + response = self.client.v2[self.service, CONFIGS].post(data={"config": {}, "adcmMeta": {}}) self.assertEqual(response.status_code, HTTP_201_CREATED) self.service.refresh_from_db() @@ -330,11 +271,7 @@ def test_save_empty_config_success(self): self.assertDictEqual(current_config, {}) def test_save_config_without_not_required_map_in_group_success(self): - response = self.client.post( - path=reverse( - viewname="v2:service-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service.pk}, - ), + response = self.client.v2[self.service, CONFIGS].post( data={ "config": { "map_not_required": {"key": "value"}, @@ -376,12 +313,7 @@ def setUp(self) -> None: self.test_user = self.create_user(**self.test_user_credentials) def test_list_success(self): - response = self.client.get( - path=reverse( - viewname="v2:cluster-group-config-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "group_config_pk": self.group_config.pk}, - ) - ) + response = self.client.v2[self.group_config, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) self.assertListEqual( @@ -390,16 +322,7 @@ def test_list_success(self): ) def test_retrieve_success(self): - response = self.client.get( - path=reverse( - viewname="v2:cluster-group-config-config-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "group_config_pk": self.group_config.pk, - "pk": self.group_config_config.pk, - }, - ) - ) + response = self.client.v2[self.group_config, CONFIGS, self.group_config_config].get() self.assertEqual(response.status_code, HTTP_200_OK) data = { "id": self.group_config_config.pk, @@ -444,13 +367,7 @@ def test_create_success(self): "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:cluster-group-config-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "group_config_pk": self.group_config.pk}, - ), - data=data, - ) + response = self.client.v2[self.group_config, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -481,13 +398,7 @@ def test_adcm_5219_create_non_superuser_privileged_success(self): "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:cluster-group-config-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "group_config_pk": self.group_config.pk}, - ), - data=data, - ) + response = self.client.v2[self.group_config, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -519,25 +430,10 @@ def test_create_no_permissions_fail(self): ): self.client.login(username=user_with_view_rights.username, password=user_password) - response = self.client.get( - path=reverse( - viewname="v2:cluster-group-config-config-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "group_config_pk": self.group_config.pk, - "pk": self.group_config_config.pk, - }, - ) - ) + response = self.client.v2[self.group_config, CONFIGS, self.group_config_config].get() self.assertEqual(response.status_code, HTTP_200_OK) - response = self.client.post( - path=reverse( - viewname="v2:cluster-group-config-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "group_config_pk": self.group_config.pk}, - ), - data=data, - ) + response = self.client.v2[self.group_config, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.assertSetEqual(initial_configlog_ids, set(ConfigLog.objects.values_list("id", flat=True))) @@ -579,13 +475,7 @@ def test_cancel_sync(self): "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:cluster-group-config-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "group_config_pk": self.group_config.pk}, - ), - data=data, - ) + response = self.client.v2[self.group_config, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -616,9 +506,7 @@ def test_primary_config_update(self): "adcmMeta": {"/activatable_group": {"isActive": False}}, "description": "new config", } - response = self.client.post( - path=reverse(viewname="v2:cluster-config-list", kwargs={"cluster_pk": self.cluster_1.pk}), data=data - ) + response = self.client.v2[self.cluster_1, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -629,13 +517,11 @@ def test_primary_config_update(self): self.assertFalse(config_log.attr["activatable_group"]["active"]) def test_adcm_4894_duplicate_name_fail(self): - self.client.post( - path=reverse(viewname="v2:cluster-group-config-list", kwargs={"cluster_pk": self.cluster_1.pk}), - data={"name": "group-config-new", "description": "group-config-new"}, + self.client.v2[self.cluster_1, "config-groups"].post( + data={"name": "group-config-new", "description": "group-config-new"} ) - response = self.client.post( - path=reverse(viewname="v2:cluster-group-config-list", kwargs={"cluster_pk": self.cluster_1.pk}), - data={"name": "group-config-new", "description": "group-config-new"}, + response = self.client.v2[self.cluster_1, "config-groups"].post( + data={"name": "group-config-new", "description": "group-config-new"} ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -663,13 +549,7 @@ def test_create_bad_attr_fail(self): "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:cluster-group-config-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "group_config_pk": self.group_config.pk}, - ), - data=data, - ) + response = self.client.v2[self.group_config, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -702,13 +582,7 @@ def test_create_bad_and_good_fail(self): "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:cluster-group-config-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "group_config_pk": self.group_config.pk}, - ), - data=data, - ) + response = self.client.v2[self.group_config, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -717,12 +591,7 @@ def test_create_bad_and_good_fail(self): ) def test_schema(self): - response = self.client.get( - path=reverse( - viewname="v2:cluster-group-config-config-schema", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.group_config.pk}, - ) - ) + response = self.client.v2[self.group_config, CONFIG_SCHEMA].get() expected_data = json.loads( (self.test_files_dir / "responses" / "config_schemas" / "for_cluster_group_config.json").read_text( @@ -736,34 +605,19 @@ def test_schema(self): def test_schema_permissions_model_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object configuration"): - response = self.client.get( - path=reverse( - viewname="v2:cluster-group-config-config-schema", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.group_config.pk}, - ) - ) + response = self.client.v2[self.group_config, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_schema_permissions_object_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="Cluster Administrator"): - response = self.client.get( - path=reverse( - viewname="v2:cluster-group-config-config-schema", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.group_config.pk}, - ) - ) + response = self.client.v2[self.group_config, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_permissions_object_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="Cluster Administrator"): - response = self.client.get( - path=reverse( - viewname="v2:cluster-group-config-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "group_config_pk": self.group_config.pk}, - ) - ) + response = self.client.v2[self.group_config, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) @@ -779,12 +633,7 @@ def setUp(self) -> None: self.test_user = self.create_user(**self.test_user_credentials) def test_list_success(self): - response = self.client.get( - path=reverse( - viewname="v2:service-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_1.pk}, - ) - ) + response = self.client.v2[self.service_1, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) @@ -794,16 +643,7 @@ def test_list_success(self): ) def test_retrieve_success(self): - response = self.client.get( - path=reverse( - viewname="v2:service-config-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "pk": self.service_1_initial_config.pk, - }, - ) - ) + response = self.client.v2[self.service_1, CONFIGS, self.service_1_initial_config].get() self.assertEqual(response.status_code, HTTP_200_OK) expected_data = { @@ -833,13 +673,7 @@ def test_create_success(self): "adcmMeta": {"/activatable_group": {"isActive": True}}, "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:service-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_1.pk}, - ), - data=data, - ) + response = self.client.v2[self.service_1, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) response_data = response.json() @@ -851,11 +685,7 @@ def test_create_success(self): self.assertEqual(response_data["isCurrent"], True) def test_schema(self): - response = self.client.get( - path=reverse( - viewname="v2:service-config-schema", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_1.pk} - ) - ) + response = self.client.v2[self.service_1, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) expected_data = json.loads( @@ -870,56 +700,31 @@ def test_schema(self): def test_schema_permissions_model_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object configuration"): - response = self.client.get( - path=reverse( - viewname="v2:service-config-schema", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_1.pk}, - ) - ) + response = self.client.v2[self.service_1, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_schema_permissions_object_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="Cluster Administrator"): - response = self.client.get( - path=reverse( - viewname="v2:service-config-schema", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_1.pk}, - ) - ) + response = self.client.v2[self.service_1, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_permissions_object_role_list_fail(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_2, role_name="View cluster configurations"): - response = self.client.get( - path=reverse( - viewname="v2:service-config-schema", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_1.pk}, - ) - ) + response = self.client.v2[self.service_1, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_permissions_model_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object configuration"): - response = self.client.get( - path=reverse( - viewname="v2:service-config-schema", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_2.pk}, - ) - ) + response = self.client.v2[self.service_2, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_permissions_object_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="Cluster Administrator"): - response = self.client.get( - path=reverse( - viewname="v2:service-config-schema", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_2.pk}, - ) - ) + response = self.client.v2[self.service_2, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_schema_permissions_another_object_role_denied(self): @@ -928,12 +733,7 @@ def test_schema_permissions_another_object_role_denied(self): to=self.test_user, on=self.service_2, role_name="Service Action: action_1_service_2" ): with self.grant_permissions(to=self.test_user, on=self.service_1, role_name="Service Administrator"): - response = self.client.get( - path=reverse( - viewname="v2:service-config-schema", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service_2.pk}, - ) - ) + response = self.client.v2[self.service_2, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_another_object_role_create_denied(self): @@ -942,13 +742,7 @@ def test_permissions_another_object_role_create_denied(self): to=self.test_user, on=self.service_2, role_name="Service Action: action_1_service_2" ): with self.grant_permissions(to=self.test_user, on=self.service_1, role_name="Service Administrator"): - response = self.client.post( - path=reverse( - viewname="v2:service-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_2.pk}, - ), - data={}, - ) + response = self.client.v2[self.service_2, CONFIGS].post(data={}) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_another_object_role_retrieve_denied(self): @@ -957,16 +751,7 @@ def test_permissions_another_object_role_retrieve_denied(self): to=self.test_user, on=self.service_2, role_name="Service Action: action_1_service_2" ): with self.grant_permissions(to=self.test_user, on=self.service_1, role_name="Service Administrator"): - response = self.client.get( - path=reverse( - viewname="v2:service-config-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_2.pk, - "pk": self.service_2.config.pk, - }, - ) - ) + response = self.client.v2[self.service_2, CONFIGS, self.service_2.config.current].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_another_object_role_list_denied(self): @@ -975,12 +760,7 @@ def test_permissions_another_object_role_list_denied(self): to=self.test_user, on=self.service_2, role_name="Service Action: action_1_service_2" ): with self.grant_permissions(to=self.test_user, on=self.service_1, role_name="Service Administrator"): - response = self.client.get( - path=reverse( - viewname="v2:service-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_2.pk}, - ) - ) + response = self.client.v2[self.service_2, CONFIGS].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -991,27 +771,13 @@ def test_schema_permissions_another_model_and_object_role_denied(self): with self.grant_permissions( to=self.test_user, on=self.service_2, role_name="Service Action: action_1_service_2" ): - response = self.client.get( - path=reverse( - viewname="v2:service-config-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_2.pk, - "pk": self.service_2.config.pk, - }, - ) - ) + response = self.client.v2[self.service_2, CONFIGS, self.service_2.config.current].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_model_role_list_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object import"): - response = self.client.get( - path=reverse( - viewname="v2:service-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_2.pk}, - ) - ) + response = self.client.v2[self.service_2, CONFIGS].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -1032,16 +798,7 @@ def setUp(self) -> None: self.test_user = self.create_user(**self.test_user_credentials) def test_list_success(self): - response = self.client.get( - path=reverse( - viewname="v2:service-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.group_config.pk, - }, - ) - ) + response = self.client.v2[self.group_config, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) self.assertListEqual( @@ -1050,17 +807,7 @@ def test_list_success(self): ) def test_retrieve_success(self): - response = self.client.get( - path=reverse( - viewname="v2:service-group-config-config-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.group_config.pk, - "pk": self.group_config_config.pk, - }, - ) - ) + response = self.client.v2[self.group_config, CONFIGS, self.group_config_config].get() self.assertEqual(response.status_code, HTTP_200_OK) expected_data = { "id": self.group_config_config.pk, @@ -1099,17 +846,7 @@ def test_create_success(self): "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:service-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.group_config.pk, - }, - ), - data=data, - ) + response = self.client.v2[self.group_config, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -1145,30 +882,10 @@ def test_create_no_permissions_fail(self): ): self.client.login(username=user_with_view_rights.username, password=user_password) - response = self.client.get( - path=reverse( - viewname="v2:service-group-config-config-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.group_config.pk, - "pk": self.group_config_config.pk, - }, - ) - ) + response = self.client.v2[self.group_config, CONFIGS, self.group_config_config].get() self.assertEqual(response.status_code, HTTP_200_OK) - response = self.client.post( - path=reverse( - viewname="v2:service-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.group_config.pk, - }, - ), - data=data, - ) + response = self.client.v2[self.group_config, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.assertSetEqual(initial_configlog_ids, set(ConfigLog.objects.values_list("id", flat=True))) @@ -1205,17 +922,7 @@ def test_cancel_sync(self): "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:service-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.group_config.pk, - }, - ), - data=data, - ) + response = self.client.v2[self.group_config, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -1246,16 +953,7 @@ def test_primary_config_update(self): "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:service-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - }, - ), - data=data, - ) + response = self.client.v2[self.service_1, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -1280,17 +978,7 @@ def test_create_bad_attr_fail(self): "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:service-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.group_config.pk, - }, - ), - data=data, - ) + response = self.client.v2[self.group_config, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -1319,17 +1007,7 @@ def test_create_bad_and_good_fail(self): "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:service-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.group_config.pk, - }, - ), - data=data, - ) + response = self.client.v2[self.group_config, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -1338,16 +1016,7 @@ def test_create_bad_and_good_fail(self): ) def test_schema(self): - response = self.client.get( - path=reverse( - viewname="v2:service-group-config-config-schema", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "pk": self.group_config.pk, - }, - ) - ) + response = self.client.v2[self.group_config, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) expected_data = json.loads( @@ -1365,76 +1034,31 @@ def test_schema(self): def test_schema_permissions_model_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object configuration"): - response = self.client.get( - path=reverse( - viewname="v2:service-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.group_config.pk, - }, - ), - ) + response = self.client.v2[self.group_config, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_schema_permissions_object_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="Cluster Administrator"): - response = self.client.get( - path=reverse( - viewname="v2:service-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.group_config.pk, - }, - ), - ) + response = self.client.v2[self.group_config, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_permissions_model_role_list_fail(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_2, role_name="View cluster configurations"): - response = self.client.get( - path=reverse( - viewname="v2:service-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.group_config.pk, - }, - ), - ) + response = self.client.v2[self.group_config, CONFIGS].get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_permissions_model_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object configuration"): - response = self.client.get( - path=reverse( - viewname="v2:service-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.group_config.pk, - }, - ) - ) + response = self.client.v2[self.group_config, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_permissions_object_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="Cluster Administrator"): - response = self.client.get( - path=reverse( - viewname="v2:service-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "group_config_pk": self.group_config.pk, - }, - ) - ) + response = self.client.v2[self.group_config, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) @@ -1460,16 +1084,7 @@ def setUp(self) -> None: self.test_user = self.create_user(**self.test_user_credentials) def test_list_success(self): - response = self.client.get( - path=reverse( - viewname="v2:component-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - }, - ) - ) + response = self.client.v2[self.component_1, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) @@ -1479,17 +1094,7 @@ def test_list_success(self): ) def test_retrieve_success(self): - response = self.client.get( - path=reverse( - viewname="v2:component-config-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "pk": self.component_1_initial_config.pk, - }, - ) - ) + response = self.client.v2[self.component_1, CONFIGS, self.component_1_initial_config].get() self.assertEqual(response.status_code, HTTP_200_OK) expected_data = { @@ -1522,17 +1127,7 @@ def test_create_success(self): "adcmMeta": {"/activatable_group": {"isActive": True}}, "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:component-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - }, - ), - data=data, - ) + response = self.client.v2[self.component_1, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) response_data = response.json() @@ -1547,12 +1142,7 @@ def test_create_success(self): self.assertEqual(response_data["isCurrent"], True) def test_schema(self): - response = self.client.get( - path=reverse( - viewname="v2:component-config-schema", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_1.pk, "pk": self.component_1.pk}, - ) - ) + response = self.client.v2[self.component_1, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) expected_data = json.loads( @@ -1575,46 +1165,19 @@ def test_schema_permissions_object_role_denied(self): with self.grant_permissions( to=self.test_user, on=self.component_1, role_name="View component configurations" ): - response = self.client.get( - path=reverse( - viewname="v2:component-config-schema", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "pk": self.component_2.pk, - }, - ) - ) + response = self.client.v2[self.component_2, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_model_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object configuration"): - response = self.client.get( - path=reverse( - viewname="v2:component-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - }, - ) - ) + response = self.client.v2[self.component_1, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_permissions_object_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="Cluster Administrator"): - response = self.client.get( - path=reverse( - viewname="v2:component-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - }, - ) - ) + response = self.client.v2[self.component_1, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_schema_permissions_model_role_denied(self): @@ -1623,31 +1186,13 @@ def test_schema_permissions_model_role_denied(self): with self.grant_permissions( to=self.test_user, on=self.component_1, role_name="View component configurations" ): - response = self.client.get( - path=reverse( - viewname="v2:component-config-schema", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "pk": self.component_2.pk, - }, - ) - ) + response = self.client.v2[self.component_2, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_schema_permissions_object_role_list_fail(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.service_2, role_name="View service configurations"): - response = self.client.get( - path=reverse( - viewname="v2:component-config-schema", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "pk": self.component_2.pk, - }, - ) - ) + response = self.client.v2[self.component_2, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_schema_permissions_model_and_object_role_denied(self): @@ -1656,16 +1201,7 @@ def test_schema_permissions_model_and_object_role_denied(self): with self.grant_permissions( to=self.test_user, on=self.component_2, role_name="Component Action: action_1_comp_2" ): - response = self.client.get( - path=reverse( - viewname="v2:component-config-schema", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "pk": self.component_2.pk, - }, - ) - ) + response = self.client.v2[self.component_2, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_another_object_role_create_denied(self): @@ -1676,17 +1212,7 @@ def test_permissions_another_object_role_create_denied(self): with self.grant_permissions( to=self.test_user, on=self.component_1, role_name="View component configurations" ): - response = self.client.post( - path=reverse( - viewname="v2:component-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_2.pk, - }, - ), - data={}, - ) + response = self.client.v2[self.component_2, CONFIGS].post(data={}) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_another_object_role_retrieve_denied(self): @@ -1697,17 +1223,7 @@ def test_permissions_another_object_role_retrieve_denied(self): with self.grant_permissions( to=self.test_user, on=self.component_1, role_name="View component configurations" ): - response = self.client.get( - path=reverse( - viewname="v2:component-config-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_2.pk, - "pk": self.component_2_initial_config.pk, - }, - ) - ) + response = self.client.v2[self.component_2, CONFIGS, self.component_2_initial_config].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_another_object_role_list_denied(self): @@ -1718,32 +1234,14 @@ def test_permissions_another_object_role_list_denied(self): with self.grant_permissions( to=self.test_user, on=self.component_1, role_name="View component configurations" ): - response = self.client.get( - path=reverse( - viewname="v2:component-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_2.pk, - }, - ) - ) + response = self.client.v2[self.component_2, CONFIGS].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_model_role_list_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object host-components"): - response = self.client.get( - path=reverse( - viewname="v2:component-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_2.pk, - }, - ) - ) + response = self.client.v2[self.component_2, CONFIGS].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -1767,17 +1265,7 @@ def setUp(self) -> None: self.test_user = self.create_user(**self.test_user_credentials) def test_list_success(self): - response = self.client.get( - path=reverse( - viewname="v2:component-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "group_config_pk": self.group_config.pk, - }, - ) - ) + response = self.client.v2[self.group_config, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) self.assertListEqual( @@ -1786,18 +1274,7 @@ def test_list_success(self): ) def test_retrieve_success(self): - response = self.client.get( - path=reverse( - viewname="v2:component-group-config-config-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "group_config_pk": self.group_config.pk, - "pk": self.group_config_config.pk, - }, - ) - ) + response = self.client.v2[self.group_config, CONFIGS, self.group_config_config].get() self.assertEqual(response.status_code, HTTP_200_OK) expected_data = { "id": self.group_config_config.pk, @@ -1839,18 +1316,7 @@ def test_create_success(self): "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "group_config_pk": self.group_config.pk, - }, - ), - data=data, - ) + response = self.client.v2[self.group_config, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -1889,32 +1355,10 @@ def test_create_no_permissions_fail(self): ): self.client.login(username=user_with_view_rights.username, password=user_password) - response = self.client.get( - path=reverse( - viewname="v2:component-group-config-config-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "group_config_pk": self.group_config.pk, - "pk": self.group_config_config.pk, - }, - ) - ) + response = self.client.v2[self.group_config, CONFIGS, self.group_config_config].get() self.assertEqual(response.status_code, HTTP_200_OK) - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "group_config_pk": self.group_config.pk, - }, - ), - data=data, - ) + response = self.client.v2[self.group_config, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.assertSetEqual(initial_configlog_ids, set(ConfigLog.objects.values_list("id", flat=True))) @@ -1950,18 +1394,7 @@ def test_cancel_sync(self): "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "group_config_pk": self.group_config.pk, - }, - ), - data=data, - ) + response = self.client.v2[self.group_config, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -2009,17 +1442,7 @@ def test_primary_config_update(self): "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:component-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - }, - ), - data=data, - ) + response = self.client.v2[self.component_1, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -2064,18 +1487,7 @@ def test_create_bad_attr_fail(self): "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "group_config_pk": self.group_config.pk, - }, - ), - data=data, - ) + response = self.client.v2[self.group_config, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -2104,18 +1516,7 @@ def test_create_bad_and_good_fail(self): "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "group_config_pk": self.group_config.pk, - }, - ), - data=data, - ) + response = self.client.v2[self.group_config, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -2124,17 +1525,7 @@ def test_create_bad_and_good_fail(self): ) def test_schema(self): - response = self.client.get( - path=reverse( - viewname="v2:component-group-config-config-schema", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "pk": self.group_config.pk, - }, - ) - ) + response = self.client.v2[self.group_config, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) expected_data = json.loads( @@ -2154,81 +1545,31 @@ def test_schema(self): def test_schema_permissions_model_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object configuration"): - response = self.client.get( - path=reverse( - viewname="v2:component-group-config-config-schema", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "pk": self.group_config.pk, - }, - ) - ) + response = self.client.v2[self.group_config, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_schema_permissions_object_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="Cluster Administrator"): - response = self.client.get( - path=reverse( - viewname="v2:component-group-config-config-schema", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "pk": self.group_config.pk, - }, - ) - ) + response = self.client.v2[self.group_config, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_schema_permissions_object_role_list_fail(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_2, role_name="View cluster configurations"): - response = self.client.get( - path=reverse( - viewname="v2:component-group-config-config-schema", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "pk": self.group_config.pk, - }, - ) - ) + response = self.client.v2[self.group_config, CONFIGS].get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_permissions_model_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object configuration"): - response = self.client.get( - path=reverse( - viewname="v2:component-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "group_config_pk": self.group_config.pk, - }, - ) - ) + response = self.client.v2[self.group_config, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_permissions_object_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="Cluster Administrator"): - response = self.client.get( - path=reverse( - viewname="v2:component-group-config-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "group_config_pk": self.group_config.pk, - }, - ) - ) + response = self.client.v2[self.group_config, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) @@ -2244,14 +1585,7 @@ def setUp(self) -> None: self.test_user = self.create_user(**self.test_user_credentials) def test_list_success(self): - response = self.client.get( - path=reverse( - viewname="v2:provider-config-list", - kwargs={ - "hostprovider_pk": self.provider.pk, - }, - ) - ) + response = self.client.v2[self.provider, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) @@ -2261,15 +1595,7 @@ def test_list_success(self): ) def test_retrieve_success(self): - response = self.client.get( - path=reverse( - viewname="v2:provider-config-detail", - kwargs={ - "hostprovider_pk": self.provider.pk, - "pk": self.provider_initial_config.pk, - }, - ) - ) + response = self.client.v2[self.provider, CONFIGS, self.provider_initial_config].get() self.assertEqual(response.status_code, HTTP_200_OK) expected_data = { @@ -2299,27 +1625,17 @@ def test_retrieve_success(self): self.assertDictEqual(actual_data, expected_data) def test_retrieve_wrong_pk_fail(self): - response = self.client.get( - path=reverse( - viewname="v2:provider-config-detail", - kwargs={ - "hostprovider_pk": self.provider.pk, - "pk": self.get_non_existent_pk(model=ConfigLog), - }, - ) - ) + response = self.client.v2[self.provider, CONFIGS, self.get_non_existent_pk(model=ConfigLog)].get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_retrieve_wrong_provider_pk_fail(self): - response = self.client.get( - path=reverse( - viewname="v2:provider-config-detail", - kwargs={ - "hostprovider_pk": self.get_non_existent_pk(model=HostProvider), - "pk": self.provider_initial_config.pk, - }, - ) - ) + response = ( + self.client.v2 + / "hostproviders" + / self.get_non_existent_pk(model=HostProvider) + / CONFIGS + / self.provider_initial_config + ).get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_create_success(self): @@ -2337,15 +1653,7 @@ def test_create_success(self): "adcmMeta": {"/activatable_group": {"isActive": True}}, "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:provider-config-list", - kwargs={ - "hostprovider_pk": self.provider.pk, - }, - ), - data=data, - ) + response = self.client.v2[self.provider, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) response_data = response.json() @@ -2361,12 +1669,7 @@ def test_create_success(self): self.assertEqual(response_data["isCurrent"], True) def test_schema(self): - response = self.client.get( - path=reverse( - viewname="v2:hostprovider-config-schema", - kwargs={"pk": self.provider.pk}, - ) - ) + response = self.client.v2[self.provider, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) expected_data = json.loads( @@ -2396,40 +1699,26 @@ def test_schema(self): def test_provider_permissions_model_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object configuration"): - response = self.client.get( - path=reverse(viewname="v2:provider-config-list", kwargs={"hostprovider_pk": self.provider.pk}) - ) + response = self.client.v2[self.provider, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_provider_permissions_object_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.provider, role_name="Provider Administrator"): - response = self.client.get( - path=reverse(viewname="v2:provider-config-list", kwargs={"hostprovider_pk": self.provider.pk}) - ) + response = self.client.v2[self.provider, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_schema_provider_permissions_another_object_role_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.provider, role_name="Provider Action: provider_action"): with self.grant_permissions(to=self.test_user, on=self.host_1, role_name="Manage Maintenance mode"): - response = self.client.get( - path=reverse( - viewname="v2:hostprovider-config-schema", - kwargs={"pk": self.provider.pk}, - ) - ) + response = self.client.v2[self.provider, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_schema_provider_permissions_another_model_role_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="Create provider"): - response = self.client.get( - path=reverse( - viewname="v2:hostprovider-config-schema", - kwargs={"pk": self.provider.pk}, - ) - ) + response = self.client.v2[self.provider, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_schema_provider_permissions_another_model_and_object_role_denied(self): @@ -2438,46 +1727,28 @@ def test_schema_provider_permissions_another_model_and_object_role_denied(self): with self.grant_permissions( to=self.test_user, on=self.provider, role_name="Provider Action: provider_action" ): - response = self.client.get( - path=reverse( - viewname="v2:provider-config-detail", - kwargs={"hostprovider_pk": self.provider.pk, "pk": self.provider.config.pk}, - ) - ) + response = self.client.v2[self.provider, CONFIGS, self.provider.config.current].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_provider_another_object_role_create_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.provider, role_name="Provider Action: provider_action"): with self.grant_permissions(to=self.test_user, on=self.host_1, role_name="Manage Maintenance mode"): - response = self.client.post( - path=reverse(viewname="v2:provider-config-list", kwargs={"hostprovider_pk": self.provider.pk}), - data={}, - ) + response = self.client.v2[self.provider, CONFIGS].post(data={}) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_provider_another_object_role_retrieve_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.provider, role_name="Provider Action: provider_action"): with self.grant_permissions(to=self.test_user, on=self.host_1, role_name="Manage Maintenance mode"): - response = self.client.get( - path=reverse( - viewname="v2:provider-config-detail", - kwargs={"hostprovider_pk": self.provider.pk, "pk": self.provider.config.pk}, - ) - ) + response = self.client.v2[self.provider, CONFIGS, self.provider.config.current].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_provider_another_object_role_list_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.provider, role_name="Provider Action: provider_action"): with self.grant_permissions(to=self.test_user, on=self.host_1, role_name="Manage Maintenance mode"): - response = self.client.get( - path=reverse( - viewname="v2:provider-config-list", - kwargs={"hostprovider_pk": self.provider.pk}, - ) - ) + response = self.client.v2[self.provider, CONFIGS].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -2485,44 +1756,27 @@ def test_permissions_cluster_another_object_role_create_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="Map hosts"): with self.grant_permissions(to=self.test_user, on=self.host_1, role_name="Manage Maintenance mode"): - response = self.client.post( - path=reverse(viewname="v2:cluster-config-list", kwargs={"cluster_pk": self.cluster_1.pk}), data={} - ) + response = self.client.v2[self.cluster_1, CONFIGS].post(data={}) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_cluster_another_object_role_retrieve_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="Map hosts"): with self.grant_permissions(to=self.test_user, on=self.host_1, role_name="Manage Maintenance mode"): - response = self.client.get( - path=reverse( - viewname="v2:cluster-config-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.cluster_1.config.pk}, - ) - ) + response = self.client.v2[self.cluster_1, CONFIGS, self.cluster_1.config.current].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_cluster_another_object_role_list_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="Map hosts"): - response = self.client.get( - path=reverse( - viewname="v2:cluster-config-list", - kwargs={"cluster_pk": self.cluster_1.pk}, - ) - ) + response = self.client.v2[self.cluster_1, CONFIGS].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_model_role_list_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="Create provider"): - response = self.client.get( - path=reverse( - viewname="v2:provider-config-list", - kwargs={"hostprovider_pk": self.provider.pk}, - ) - ) + response = self.client.v2[self.provider, CONFIGS].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -2541,12 +1795,7 @@ def setUp(self) -> None: self.test_user = self.create_user(**self.test_user_credentials) def test_list_success(self): - response = self.client.get( - path=reverse( - viewname="v2:hostprovider-group-config-config-list", - kwargs={"hostprovider_pk": self.provider.pk, "group_config_pk": self.group_config.pk}, - ) - ) + response = self.client.v2[self.group_config, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) @@ -2556,16 +1805,7 @@ def test_list_success(self): ) def test_retrieve_success(self): - response = self.client.get( - path=reverse( - viewname="v2:hostprovider-group-config-config-detail", - kwargs={ - "hostprovider_pk": self.provider.pk, - "group_config_pk": self.group_config.pk, - "pk": self.group_config_config.pk, - }, - ) - ) + response = self.client.v2[self.group_config, CONFIGS, self.group_config_config].get() self.assertEqual(response.status_code, HTTP_200_OK) expected_data = { @@ -2600,28 +1840,17 @@ def test_retrieve_success(self): self.assertDictEqual(actual_data, expected_data) def test_retrieve_wrong_pk_fail(self): - response = self.client.get( - path=reverse( - viewname="v2:hostprovider-group-config-config-detail", - kwargs={ - "hostprovider_pk": self.provider.pk, - "group_config_pk": self.group_config.pk, - "pk": self.get_non_existent_pk(model=ConfigLog), - }, - ) - ) + response = self.client.v2[self.group_config, CONFIGS, self.get_non_existent_pk(model=ConfigLog)].get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_retrieve_wrong_provider_pk_fail(self): - response = self.client.get( - path=reverse( - viewname="v2:provider-config-detail", - kwargs={ - "hostprovider_pk": self.get_non_existent_pk(model=HostProvider), - "pk": self.group_config.pk, - }, - ) - ) + response = ( + self.client.v2 + / "hostproviders" + / self.get_non_existent_pk(model=HostProvider) + / CONFIGS + / self.provider.config.current + ).get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_create_success(self): @@ -2644,16 +1873,7 @@ def test_create_success(self): }, "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:hostprovider-group-config-config-list", - kwargs={ - "hostprovider_pk": self.provider.pk, - "group_config_pk": self.group_config.pk, - }, - ), - data=data, - ) + response = self.client.v2[self.group_config, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) response_data = response.json() @@ -2697,28 +1917,10 @@ def test_create_no_permissions_fail(self): ): self.client.login(username=user_with_view_rights.username, password=user_password) - response = self.client.get( - path=reverse( - viewname="v2:hostprovider-group-config-config-detail", - kwargs={ - "hostprovider_pk": self.provider.pk, - "group_config_pk": self.group_config.pk, - "pk": self.group_config_config.pk, - }, - ) - ) + response = self.client.v2[self.group_config, CONFIGS, self.group_config_config].get() self.assertEqual(response.status_code, HTTP_200_OK) - response = self.client.post( - path=reverse( - viewname="v2:hostprovider-group-config-config-list", - kwargs={ - "hostprovider_pk": self.provider.pk, - "group_config_pk": self.group_config.pk, - }, - ), - data=data, - ) + response = self.client.v2[self.group_config, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.assertSetEqual(initial_configlog_ids, set(ConfigLog.objects.values_list("id", flat=True))) @@ -2759,16 +1961,7 @@ def test_cancel_sync(self): "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:hostprovider-group-config-config-list", - kwargs={ - "hostprovider_pk": self.provider.pk, - "group_config_pk": self.group_config.pk, - }, - ), - data=data, - ) + response = self.client.v2[self.group_config, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -2815,15 +2008,7 @@ def test_primary_config_update(self): "description": "new config", } - response = self.client.post( - path=reverse( - viewname="v2:provider-config-list", - kwargs={ - "hostprovider_pk": self.provider.pk, - }, - ), - data=data, - ) + response = self.client.v2[self.provider, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -2849,12 +2034,7 @@ def test_primary_config_update(self): self.assertFalse(config_log.attr["activatable_group"]["active"]) def test_schema(self): - response = self.client.get( - path=reverse( - viewname="v2:hostprovider-group-config-config-schema", - kwargs={"hostprovider_pk": self.provider.pk, "pk": self.group_config.pk}, - ) - ) + response = self.client.v2[self.group_config, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) expected_data = json.loads( @@ -2886,55 +2066,30 @@ def test_schema(self): def test_provider_permissions_model_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object configuration"): - response = self.client.get( - path=reverse( - viewname="v2:hostprovider-group-config-config-schema", - kwargs={"hostprovider_pk": self.provider.pk, "pk": self.group_config.pk}, - ) - ) + response = self.client.v2[self.group_config, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_schema_permissions_object_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.provider, role_name="Provider Administrator"): - response = self.client.get( - path=reverse( - viewname="v2:hostprovider-group-config-config-schema", - kwargs={"hostprovider_pk": self.provider.pk, "pk": self.group_config.pk}, - ) - ) + response = self.client.v2[self.group_config, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_schema_permissions_list_fail(self): self.client.login(**self.test_user_credentials) - response = self.client.get( - path=reverse( - viewname="v2:hostprovider-group-config-config-schema", - kwargs={"hostprovider_pk": self.provider.pk, "pk": self.group_config.pk}, - ) - ) + response = self.client.v2[self.group_config, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_schema_permissions_model_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object configuration"): - response = self.client.get( - path=reverse( - viewname="v2:hostprovider-group-config-config-list", - kwargs={"hostprovider_pk": self.provider.pk, "group_config_pk": self.group_config.pk}, - ) - ) + response = self.client.v2[self.group_config, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_permissions_object_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.provider, role_name="Provider Administrator"): - response = self.client.get( - path=reverse( - viewname="v2:hostprovider-group-config-config-list", - kwargs={"hostprovider_pk": self.provider.pk, "group_config_pk": self.group_config.pk}, - ) - ) + response = self.client.v2[self.group_config, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) @@ -2952,7 +2107,7 @@ def setUp(self) -> None: self.test_user = self.create_user(**self.test_user_credentials) def test_list_success(self): - response = self.client.get(path=reverse(viewname="v2:host-config-list", kwargs={"host_pk": self.host.pk})) + response = self.client.v2[self.host, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) @@ -2962,9 +2117,7 @@ def test_list_success(self): ) def test_retrieve_success(self): - response = self.client.get( - path=reverse(viewname="v2:host-config-detail", kwargs={"host_pk": self.host.pk, "pk": self.host_config.pk}) - ) + response = self.client.v2[self.host, CONFIGS, self.host_config].get() self.assertEqual(response.status_code, HTTP_200_OK) data = { @@ -2997,10 +2150,7 @@ def test_create_success(self): "adcmMeta": {"/activatable_group": {"isActive": True}}, "description": "new config", } - response = self.client.post( - path=reverse(viewname="v2:host-config-list", kwargs={"host_pk": self.host.pk}), - data=data, - ) + response = self.client.v2[self.host, CONFIGS].post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) response_data = response.json() @@ -3009,23 +2159,16 @@ def test_create_success(self): self.assertEqual(response_data["description"], data["description"]) self.assertEqual(response_data["isCurrent"], True) - response = self.client.get(path=reverse(viewname="v2:host-config-list", kwargs={"host_pk": self.host.pk})) + response = self.client.v2[self.host, CONFIGS].get() self.assertEqual(response.json()["count"], 2) def test_list_wrong_pk_fail(self): - response = self.client.get( - path=reverse(viewname="v2:host-config-list", kwargs={"host_pk": self.get_non_existent_pk(Host)}) - ) + response = (self.client.v2 / "hosts" / self.get_non_existent_pk(Host) / CONFIGS).get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_schema(self): - response = self.client.get( - path=reverse( - viewname="v2:host-config-schema", - kwargs={"pk": self.host.pk}, - ) - ) + response = self.client.v2[self.host, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) expected_data = json.loads( @@ -3038,80 +2181,48 @@ def test_schema(self): def test_schema_permissions_object_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.host, role_name="View host configurations"): - response = self.client.get( - path=reverse( - viewname="v2:host-config-schema", - kwargs={"pk": self.host.pk}, - ) - ) + response = self.client.v2[self.host, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_schema_permissions_model_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object configuration"): - response = self.client.get( - path=reverse( - viewname="v2:host-config-schema", - kwargs={"pk": self.host.pk}, - ) - ) + response = self.client.v2[self.host, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_schema_permissions_another_object_role_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.host, role_name="Host Action: host_action"): with self.grant_permissions(to=self.test_user, on=self.host_2, role_name="Manage Maintenance mode"): - response = self.client.get( - path=reverse( - viewname="v2:host-config-schema", - kwargs={"pk": self.host.pk}, - ) - ) + response = self.client.v2[self.host, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_another_object_role_create_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.host, role_name="Host Action: host_action"): with self.grant_permissions(to=self.test_user, on=self.host_2, role_name="Manage Maintenance mode"): - response = self.client.post( - path=reverse(viewname="v2:host-config-list", kwargs={"host_pk": self.host.pk}), data={} - ) + response = self.client.v2[self.host, CONFIGS].post(data={}) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_another_object_role_retrieve_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.host, role_name="Host Action: host_action"): with self.grant_permissions(to=self.test_user, on=self.host_2, role_name="Manage Maintenance mode"): - response = self.client.get( - path=reverse( - viewname="v2:host-config-detail", - kwargs={"host_pk": self.host.pk, "pk": self.host.config.pk}, - ) - ) + response = self.client.v2[self.host, CONFIGS, self.host.config.current].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_another_object_role_list_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.host, role_name="Host Action: host_action"): with self.grant_permissions(to=self.test_user, on=self.host_2, role_name="Manage Maintenance mode"): - response = self.client.get( - path=reverse( - viewname="v2:host-config-list", - kwargs={"host_pk": self.host.pk}, - ) - ) + response = self.client.v2[self.host, CONFIGS].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_schema_permissions_another_model_role_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object host-components"): - response = self.client.get( - path=reverse( - viewname="v2:host-config-schema", - kwargs={"pk": self.host.pk}, - ) - ) + response = self.client.v2[self.host, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_schema_permissions_another_model_and_object_role_denied(self): @@ -3119,24 +2230,19 @@ def test_schema_permissions_another_model_and_object_role_denied(self): with self.grant_permissions(to=self.test_user, on=[], role_name="View any object host-components"): with self.grant_permissions(to=self.test_user, on=self.host, role_name="Host Action: host_action"): - response = self.client.get( - path=reverse( - viewname="v2:host-config-detail", - kwargs={"host_pk": self.host.pk, "pk": self.host.config.pk}, - ) - ) + response = self.client.v2[self.host, CONFIGS, self.host.config.current].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_permissions_model_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object configuration"): - response = self.client.get(path=reverse(viewname="v2:host-config-list", kwargs={"host_pk": self.host.pk})) + response = self.client.v2[self.host, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) def test_permissions_object_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.provider, role_name="Provider Administrator"): - response = self.client.get(path=reverse(viewname="v2:host-config-list", kwargs={"host_pk": self.host.pk})) + response = self.client.v2[self.host, CONFIGS].get() self.assertEqual(response.status_code, HTTP_200_OK) @@ -3147,7 +2253,7 @@ def setUp(self) -> None: self.adcm_current_config = ConfigLog.objects.get(id=self.adcm.config.current) def test_list_success(self): - response = self.client.get(path=reverse(viewname="v2:adcm-config-list")) + response = (self.client.v2 / "adcm" / CONFIGS).get() self.assertEqual(response.status_code, HTTP_200_OK) data = response.json() @@ -3158,9 +2264,7 @@ def test_list_success(self): self.assertTrue(data["results"][0]["isCurrent"]) def test_retrieve_success(self): - response = self.client.get( - path=reverse(viewname="v2:adcm-config-detail", kwargs={"pk": self.adcm_current_config.pk}) - ) + response = (self.client.v2 / "adcm" / CONFIGS / self.adcm_current_config).get() self.assertEqual(response.status_code, HTTP_200_OK) data = response.json() @@ -3221,7 +2325,7 @@ def test_create_success(self): "description": "new ADCM config", } - response = self.client.post(path=reverse(viewname="v2:adcm-config-list"), data=data) + response = (self.client.v2 / "adcm" / CONFIGS).post(data=data) self.assertEqual(response.status_code, HTTP_201_CREATED) self.assertEqual(ConfigLog.objects.filter(obj_ref=self.adcm.config).count(), 2) @@ -3229,7 +2333,7 @@ def test_create_success(self): self.assertEqual(response.json()["description"], "new ADCM config") def test_schema(self): - response = self.client.get(path=reverse(viewname="v2:adcm-config-schema")) + response = (self.client.v2 / "adcm" / CONFIG_SCHEMA).get() self.assertEqual(response.status_code, HTTP_200_OK) expected_data = json.loads( @@ -3301,11 +2405,7 @@ def setUp(self) -> None: ).get() def test_schema(self): - response = self.client.get( - path=reverse( - viewname="v2:service-config-schema", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.service.pk} - ) - ) + response = self.client.v2[self.service, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertDictEqual( response.json(), @@ -3509,9 +2609,7 @@ def test_upgrade(self): }, ) - response = self.client.post( - path=reverse(viewname="v2:upgrade-run", kwargs={"cluster_pk": self.cluster.pk, "pk": self.upgrade.pk}) - ) + response = self.client.v2[self.cluster, "upgrades", self.upgrade, "run"].post(data={}) self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) From 3135f2a2ba69a694129ee4cccdc48b4e74e030df Mon Sep 17 00:00:00 2001 From: astarovo Date: Mon, 20 May 2024 07:25:51 +0300 Subject: [PATCH 129/208] ADCM-5429: Rework and add unittests for `adcm_check` --- python/ansible/plugins/action/adcm_check.py | 73 +--- python/ansible_plugin/executors/check.py | 171 +++++++++ .../ansible_plugin/tests/test_adcm_check.py | 335 ++++++++++++++++++ python/ansible_plugin/utils.py | 41 --- .../cm/services/job/run/_target_factories.py | 11 +- 5 files changed, 511 insertions(+), 120 deletions(-) create mode 100644 python/ansible_plugin/executors/check.py create mode 100644 python/ansible_plugin/tests/test_adcm_check.py diff --git a/python/ansible/plugins/action/adcm_check.py b/python/ansible/plugins/action/adcm_check.py index 1e72483263..b476366923 100644 --- a/python/ansible/plugins/action/adcm_check.py +++ b/python/ansible/plugins/action/adcm_check.py @@ -93,78 +93,13 @@ import sys -from ansible.plugins.action import ActionBase - sys.path.append("/adcm/python") import adcm.init_django # noqa: F401, isort:skip -from ansible_plugin.utils import create_checklog_object -from cm.errors import AdcmEx -from cm.logger import logger - - -class ActionModule(ActionBase): - TRANSFERS_FILES = False - _VALID_ARGS = frozenset( - ( - "title", - "result", - "msg", - "fail_msg", - "success_msg", - "group_title", - "group_success_msg", - "group_fail_msg", - ) - ) - - def run(self, tmp=None, task_vars=None): - super().run(tmp, task_vars) - job_id = None - if task_vars is not None and "job" in task_vars or "id" in task_vars["job"]: - job_id = task_vars["job"]["id"] - - old_optional_condition = "msg" in self._task.args - new_optional_condition = "fail_msg" in self._task.args and "success_msg" in self._task.args - optional_condition = old_optional_condition or new_optional_condition - required_condition = "title" in self._task.args and "result" in self._task.args and optional_condition - - if not required_condition: - return { - "failed": True, - "msg": "title, result and msg, fail_msg or success" "_msg are mandatory args of adcm_check", - } - - title = self._task.args["title"] - result = self._task.args["result"] - msg = self._task.args.get("msg", "") - fail_msg = self._task.args.get("fail_msg", "") - success_msg = self._task.args.get("success_msg", "") - - group_title = self._task.args.get("group_title", "") - group_fail_msg = self._task.args.get("group_fail_msg", "") - group_success_msg = self._task.args.get("group_success_msg", "") - - msg = (success_msg if success_msg else msg) if result else fail_msg if fail_msg else msg - - group = {"title": group_title, "success_msg": group_success_msg, "fail_msg": group_fail_msg} - - check = { - "title": title.replace("\x00", ""), - "result": result, - "message": msg.replace("\x00", ""), - } - - logger.debug( - "ansible adcm_check: %s, %s", - ", ".join([f"{k}: {v}" for k, v in group.items() if v]), - ", ".join([f"{k}: {v}" for k, v in check.items() if v]), - ) +from ansible_plugin.base import ADCMAnsiblePlugin +from ansible_plugin.executors.check import ADCMCheckPluginExecutor - try: - create_checklog_object(job_id=job_id, group_data=group, check_data=check) - except AdcmEx as e: - return {"failed": True, "msg": e.code + ":" + e.msg} - return {"failed": False, "changed": False} +class ActionModule(ADCMAnsiblePlugin): + executor_class = ADCMCheckPluginExecutor diff --git a/python/ansible_plugin/executors/check.py b/python/ansible_plugin/executors/check.py new file mode 100644 index 0000000000..cd201ad2c1 --- /dev/null +++ b/python/ansible_plugin/executors/check.py @@ -0,0 +1,171 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Collection, TypedDict +import json + +from cm.errors import AdcmEx +from cm.logger import logger +from cm.models import CheckLog, GroupCheckLog, JobLog, LogStorage +from core.types import CoreObjectDescriptor +from django.db import IntegrityError +from django.db.transaction import atomic +from pydantic import BaseModel, model_validator +from typing_extensions import Self + +from ansible_plugin.base import ( + ADCMAnsiblePluginExecutor, + ArgumentsConfig, + CallArguments, + CallResult, + PluginExecutorConfig, + RuntimeEnvironment, +) +from ansible_plugin.errors import ( + PluginRuntimeError, +) +from ansible_plugin.utils import assign_view_logstorage_permissions_by_job, get_checklogs_data_by_job_id + + +class CheckArguments(BaseModel): + title: str + result: bool + msg: str | None = None + fail_msg: str | None = None + success_msg: str | None = None + group_title: str | None = None + group_success_msg: str | None = None + group_fail_msg: str | None = None + + @model_validator(mode="after") + def check_msg_is_specified_if_no_fail_success_msg(self) -> Self: + if self.success_msg is None and self.fail_msg is None and self.msg is None: + message = "'msg' must be specified if 'success_msg' and 'fail_msg' are not specified" + raise ValueError(message) + + return self + + @model_validator(mode="after") + def check_success_msg_is_specified_if_no_msg(self) -> Self: + if self.msg is None and self.success_msg is None: + message = "'success_msg' must be specified if 'msg' are not specified" + raise ValueError(message) + + return self + + @model_validator(mode="after") + def check_fail_msg_is_specified_if_no_msg(self) -> Self: + if self.msg is None and self.success_msg is None: + message = "'fail_msg' must be specified if 'msg' are not specified" + raise ValueError(message) + + return self + + @model_validator(mode="after") + def check_group_msg_if_group_is_specified(self) -> Self: + if ( + self.group_title is not None + and self.group_success_msg is None + and self.group_title is not None + and self.group_fail_msg is None + ): + message = "either 'group_fail_msg' or 'group_success_msg' must be specified if 'group_titile' is specified" + raise ValueError(message) + + return self + + +class JSONLogReturnValue(TypedDict): + check: dict + + +class ADCMCheckPluginExecutor(ADCMAnsiblePluginExecutor[CheckArguments, JSONLogReturnValue]): + _config = PluginExecutorConfig( + arguments=ArgumentsConfig(represent_as=CheckArguments), + ) + + def __call__( + self, targets: Collection[CoreObjectDescriptor], arguments: CallArguments, runtime: RuntimeEnvironment + ) -> CallResult[None]: + _ = targets, runtime + + if arguments.success_msg and arguments.result: + msg = arguments.success_msg + elif not arguments.success_msg and arguments.result: + msg = arguments.msg + elif arguments.fail_msg: + msg = arguments.fail_msg + else: + msg = arguments.msg + + group_data = { + "title": arguments.group_title, + "success_msg": arguments.group_success_msg, + "fail_msg": arguments.group_fail_msg, + } + + check_data = { + "title": arguments.title.replace("\x00", ""), + "result": arguments.result, + "message": msg.replace("\x00", ""), + } + + logger.debug( + "ansible adcm_check: %s, %s", + ", ".join([f"{k}: {v}" for k, v in group_data.items() if v]), + ", ".join([f"{k}: {v}" for k, v in check_data.items() if v]), + ) + + check = {} + + try: + with atomic(): + job = JobLog.objects.get(id=runtime.vars.job.id) + group_title = group_data.pop("title") + + if group_title: + group, _ = GroupCheckLog.objects.get_or_create(job=job, title=group_title) + else: + group = None + + check_data.update({"job": job, "group": group}) + CheckLog.objects.create(**check_data) + + if group is not None: + group_data.update({"group": group}) + logs = CheckLog.objects.filter(group=group).values("result") + result = all(log["result"] for log in logs) + + msg = group_data["success_msg"] if result else group_data["fail_msg"] + + group.message = msg + group.result = result + group.save() + + check = get_checklogs_data_by_job_id(runtime.vars.job.id) + + log_storage, _ = LogStorage.objects.get_or_create( + job=job, name="ansible", type="check", format="json", body=json.dumps(check) + ) + + assign_view_logstorage_permissions_by_job(log_storage) + except AdcmEx as e: + error_message = f"Failed to create checklog: {check_data}, group: {group_data}, error: {e}" + return CallResult(value={}, changed=False, error=PluginRuntimeError(message=error_message)) + except IntegrityError as e: + return CallResult( + value={}, + changed=False, + error=PluginRuntimeError(message=f"Failed to perform check due to IntegrityError: {e}"), + ) + + return CallResult(value=JSONLogReturnValue(check=check), changed=True, error=None) diff --git a/python/ansible_plugin/tests/test_adcm_check.py b/python/ansible_plugin/tests/test_adcm_check.py new file mode 100644 index 0000000000..d423d5397d --- /dev/null +++ b/python/ansible_plugin/tests/test_adcm_check.py @@ -0,0 +1,335 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from cm.models import ADCM, CheckLog, GroupCheckLog, LogStorage, ServiceComponent +from cm.services.job.run.repo import JobRepoImpl + +from ansible_plugin.errors import PluginValidationError +from ansible_plugin.executors.check import ADCMCheckPluginExecutor +from ansible_plugin.tests.base import BaseTestEffectsOfADCMAnsiblePlugins + +EXECUTOR_MODULE = "ansible_plugin.executors.check" + + +class TestCheckPluginExecutor(BaseTestEffectsOfADCMAnsiblePlugins): + def setUp(self) -> None: + super().setUp() + + self.adcm = ADCM.objects.first() + self.service_1 = self.add_services_to_cluster(["service_1"], cluster=self.cluster).first() + self.component_1 = ServiceComponent.objects.filter(service=self.service_1).first() + + self.add_host_to_cluster(self.cluster, self.host_1) + + def test_adcm_check_success(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMCheckPluginExecutor, + call_arguments=""" + title: title + result: true + msg: test_message + """, + call_context=job, + ) + result = executor.execute() + + self.assertIsNone(result.error) + self.assertDictEqual( + result.value, {"check": [{"message": "test_message", "result": True, "title": "title", "type": "check"}]} + ) + self.assertTrue(result.changed) + + def test_adcm_check_no_title_fail(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMCheckPluginExecutor, + call_arguments=""" + result: true + msg: test_message + """, + call_context=job, + ) + result = executor.execute() + + self.assertIsInstance(result.error, PluginValidationError) + self.assertIn("Arguments doesn't match expected schema", result.error.message) + self.assertDictEqual(result.value, {}) + self.assertFalse(result.changed) + + def test_adcm_check_no_result_fail(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMCheckPluginExecutor, + call_arguments=""" + title: title + msg: test_message + """, + call_context=job, + ) + result = executor.execute() + + self.assertIsInstance(result.error, PluginValidationError) + self.assertIn("Arguments doesn't match expected schema", result.error.message) + self.assertDictEqual(result.value, {}) + self.assertFalse(result.changed) + + def test_adcm_check_no_msg_fail(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMCheckPluginExecutor, + call_arguments=""" + title: title + result: False + """, + call_context=job, + ) + result = executor.execute() + + self.assertIsInstance(result.error, PluginValidationError) + self.assertIn("Arguments doesn't match expected schema", result.error.message) + self.assertDictEqual(result.value, {}) + self.assertFalse(result.changed) + + def test_adcm_check_no_msg_but_there_success_msg_success(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMCheckPluginExecutor, + call_arguments=""" + title: title + result: True + success_msg: success + """, + call_context=job, + ) + result = executor.execute() + + self.assertIsNone(result.error) + self.assertDictEqual( + result.value, {"check": [{"message": "success", "result": True, "title": "title", "type": "check"}]} + ) + self.assertTrue(result.changed) + + def test_adcm_check_no_msg_but_there_fail_msg_fail(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMCheckPluginExecutor, + call_arguments=""" + title: title + result: False + fail_msg: fail + """, + call_context=job, + ) + result = executor.execute() + + self.assertIsInstance(result.error, PluginValidationError) + self.assertIn("Arguments doesn't match expected schema", result.error.message) + self.assertDictEqual(result.value, {}) + self.assertFalse(result.changed) + + def test_adcm_check_no_msg_but_there_success_msg_and_fail_msg_success(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMCheckPluginExecutor, + call_arguments=""" + title: title + result: False + success_msg: success + fail_msg: fail + """, + call_context=job, + ) + result = executor.execute() + + self.assertIsNone(result.error) + self.assertDictEqual( + result.value, {"check": [{"message": "fail", "result": False, "title": "title", "type": "check"}]} + ) + self.assertTrue(result.changed) + + def test_adcm_check_no_msg_and_there_success_msg_and_fail_msg_success(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMCheckPluginExecutor, + call_arguments=""" + title: title + result: False + success_msg: success + fail_msg: fail + """, + call_context=job, + ) + result = executor.execute() + + self.assertIsNone(result.error) + self.assertDictEqual( + result.value, {"check": [{"message": "fail", "result": False, "title": "title", "type": "check"}]} + ) + self.assertTrue(result.changed) + + def test_adcm_check_group_title_and_group_success_msg_success(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMCheckPluginExecutor, + call_arguments=""" + title: title + result: true + msg: test_message + group_title: group + group_success_msg: success group + """, + call_context=job, + ) + result = executor.execute() + + self.assertIsNone(result.error) + self.assertDictEqual( + result.value, + { + "check": [ + { + "content": [{"message": "test_message", "result": True, "title": "title", "type": "check"}], + "message": "success group", + "result": True, + "title": "group", + "type": "group", + } + ] + }, + ) + self.assertTrue(result.changed) + + def test_adcm_check_group_title_and_group_fail_msg_success(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMCheckPluginExecutor, + call_arguments=""" + title: title + result: true + msg: test_message + group_title: group + group_fail_msg: fail group + """, + call_context=job, + ) + result = executor.execute() + + self.assertIsNone(result.error) + self.assertDictEqual( + result.value, + { + "check": [ + { + "content": [{"message": "test_message", "result": True, "title": "title", "type": "check"}], + "message": None, + "result": True, + "title": "group", + "type": "group", + } + ] + }, + ) + self.assertTrue(result.changed) + + def test_adcm_check_group_title_no_group_msg_fail(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMCheckPluginExecutor, + call_arguments=""" + title: title + result: true + msg: test_message + group_title: group + """, + call_context=job, + ) + result = executor.execute() + + self.assertIsInstance(result.error, PluginValidationError) + self.assertIn("Arguments doesn't match expected schema", result.error.message) + self.assertDictEqual(result.value, {}) + self.assertFalse(result.changed) + + def test_adcm_check_double_call_val_success_fail(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMCheckPluginExecutor, + call_arguments=""" + title: title + result: true + msg: test_message + group_title: group + group_success_msg: success group + """, + call_context=job, + ) + executor.execute() + result = executor.execute() + + self.assertIn("Failed to perform check due to IntegrityError", result.error.message) + self.assertDictEqual(result.value, {}) + self.assertFalse(result.changed) + + self.assertEqual(GroupCheckLog.objects.all().count(), 1) + self.assertEqual(CheckLog.objects.all().count(), 1) + self.assertEqual(LogStorage.objects.all().count(), 3) + + def test_adcm_check_double_call_fail(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMCheckPluginExecutor, + call_arguments=""" + title: title + result: true + msg: test_message + group_title: group + """, + call_context=job, + ) + executor.execute() + result = executor.execute() + + self.assertIsInstance(result.error, PluginValidationError) + self.assertIn("Arguments doesn't match expected schema", result.error.message) + self.assertDictEqual(result.value, {}) + self.assertFalse(result.changed) + + self.assertEqual(GroupCheckLog.objects.all().count(), 0) + self.assertEqual(CheckLog.objects.all().count(), 0) + self.assertEqual(LogStorage.objects.all().count(), 2) diff --git a/python/ansible_plugin/utils.py b/python/ansible_plugin/utils.py index 7fd96f262c..919e574d94 100644 --- a/python/ansible_plugin/utils.py +++ b/python/ansible_plugin/utils.py @@ -45,7 +45,6 @@ Host, HostProvider, JobLog, - JobStatus, LogStorage, Prototype, ServiceComponent, @@ -343,17 +342,6 @@ def unset_host_multi_state(host_id, multi_state, missing_ok): return _unset_object_multi_state(obj, multi_state, missing_ok) -def log_group_check(group: GroupCheckLog, fail_msg: str, success_msg: str): - logs = CheckLog.objects.filter(group=group).values("result") - result = all(log["result"] for log in logs) - - msg = success_msg if result else fail_msg - - group.message = msg - group.result = result - group.save() - - def assign_view_logstorage_permissions_by_job(log_storage: LogStorage) -> None: task_role = Role.objects.filter(name=f"View role for task {log_storage.job.task_id}", built_in=True).first() view_logstorage_permission, _ = Permission.objects.get_or_create( @@ -371,35 +359,6 @@ def create_custom_log(job_id: int, name: str, log_format: str, body: str) -> Log return log -def create_checklog_object(job_id: int, group_data: dict, check_data: dict) -> CheckLog: - file_descriptor = job_lock(job_id) - job = JobLog.obj.get(id=job_id) - if job.status != JobStatus.RUNNING: - raise AdcmEx("JOB_NOT_FOUND", f'job #{job.pk} has status "{job.status}", not "running"') - - group_title = group_data.pop("title") - - if group_title: - group, _ = GroupCheckLog.objects.get_or_create(job=job, title=group_title) - else: - group = None - - check_data.update({"job": job, "group": group}) - check_log = CheckLog.objects.create(**check_data) - - if group is not None: - group_data.update({"group": group}) - log_group_check(**group_data) - - log_storage, _ = LogStorage.objects.get_or_create(job=job, name="ansible", type="check", format="json") - - assign_view_logstorage_permissions_by_job(log_storage) - - file_descriptor.close() - - return check_log - - def get_checklogs_data_by_job_id(job_id: int) -> list[dict[str, Any]]: data = [] group_subs = defaultdict(list) diff --git a/python/cm/services/job/run/_target_factories.py b/python/cm/services/job/run/_target_factories.py index f300f2f88c..880edd461e 100644 --- a/python/cm/services/job/run/_target_factories.py +++ b/python/cm/services/job/run/_target_factories.py @@ -44,7 +44,6 @@ PythonProcessExecutor, ) from cm.services.job.types import ( - ADCMActionType, ClusterActionType, ComponentActionType, HostActionType, @@ -299,14 +298,7 @@ def prepare_ansible_job_config(task: Task, job: Job, configuration: ExternalSett def _get_owner_specific_data( task: Task, -) -> ( - ClusterActionType - | ServiceActionType - | ComponentActionType - | HostProviderActionType - | HostActionType - | ADCMActionType -): +) -> ClusterActionType | ServiceActionType | ComponentActionType | HostProviderActionType | HostActionType: owner = task.owner if not owner: message = "Can't get owner task data for task without owner" @@ -346,7 +338,6 @@ def _get_owner_specific_data( component_type_id=owner.prototype_id, ) case _: - # ADCM will go in here for now, because ansible config is undefined for it message = f"Can't get task data for task with owner {owner.type}" raise NotImplementedError(message) From 519ccfc68214d35b0c84a8651b82418c49435436 Mon Sep 17 00:00:00 2001 From: Igor Kuzmin Date: Mon, 27 May 2024 13:59:09 +0000 Subject: [PATCH 130/208] bugfix/ADCM-5624 fix expand/collapse all nodes behaviour Task: https://tracker.yandex.ru/ADCM-5624 --- .../uikit/CollapseTree2/CollapseNode.tsx | 33 ++++++++++++++----- .../CollapseTree2/CollapseTree.stories.tsx | 7 +--- .../ConfigurationEditor.stories.constants.ts | 8 +++-- .../ConfigurationEditor.stories.tsx | 19 ++++++++++- .../ConfigurationTree.constants.ts | 2 ++ .../ConfigurationTree/ConfigurationTree.tsx | 28 +++++++++++----- 6 files changed, 70 insertions(+), 27 deletions(-) diff --git a/adcm-web/app/src/components/uikit/CollapseTree2/CollapseNode.tsx b/adcm-web/app/src/components/uikit/CollapseTree2/CollapseNode.tsx index 8e2249afc8..e2fbc94722 100644 --- a/adcm-web/app/src/components/uikit/CollapseTree2/CollapseNode.tsx +++ b/adcm-web/app/src/components/uikit/CollapseTree2/CollapseNode.tsx @@ -1,23 +1,26 @@ -import React, { ReactNode, useEffect, useState, useMemo } from 'react'; +import React, { ReactNode, useEffect, useState, useMemo, useCallback } from 'react'; import Collapse from '@uikit/Collapse/Collapse'; import { Node } from './CollapseNode.types'; import s from './CollapseNode.module.scss'; import cn from 'classnames'; import { ConfigurationNode } from '@uikit/ConfigurationEditor/ConfigurationEditor.types'; -import { rootNodeKey } from '@uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.constants'; +import { + rootNodeKey, + toggleAllNodesEventName, +} from '@uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.constants'; interface CollapseNodeProps { node: Node; + treeRef?: React.RefObject; isInitiallyExpanded?: boolean; - areExpandedAll?: boolean; getNodeClassName: (node: Node) => string; renderNodeContent: (node: Node, isExpanded: boolean, onExpand: (isOpen: boolean) => void) => ReactNode; } const CollapseNode = ({ node, + treeRef, isInitiallyExpanded = false, - areExpandedAll, getNodeClassName, renderNodeContent, }: CollapseNodeProps) => { @@ -31,11 +34,23 @@ const CollapseNode = ({ return fieldAttributes?.isActive === false || node.key === rootNodeKey; }, [node]); + const handleToggleAllNodes = useCallback( + (e: CustomEvent) => { + if (!isIgnoreExpandAll) { + setIsExpanded(e.detail); + } + }, + [isIgnoreExpandAll], + ); + useEffect(() => { - if (!isIgnoreExpandAll && typeof areExpandedAll === 'boolean') { - setIsExpanded(areExpandedAll); - } - }, [areExpandedAll, isIgnoreExpandAll]); + const localTreeRef = treeRef?.current; + localTreeRef?.addEventListener(toggleAllNodesEventName, handleToggleAllNodes as EventListener); + + return () => { + localTreeRef?.removeEventListener(toggleAllNodesEventName, handleToggleAllNodes as EventListener); + }; + }, [treeRef, handleToggleAllNodes]); const toggleCollapseNode = (isOpen: boolean) => { if (hasChildren) { @@ -54,10 +69,10 @@ const CollapseNode = ({ {children.map((childNode) => ( ))} diff --git a/adcm-web/app/src/components/uikit/CollapseTree2/CollapseTree.stories.tsx b/adcm-web/app/src/components/uikit/CollapseTree2/CollapseTree.stories.tsx index ca2083a1e1..c0bafa1fca 100644 --- a/adcm-web/app/src/components/uikit/CollapseTree2/CollapseTree.stories.tsx +++ b/adcm-web/app/src/components/uikit/CollapseTree2/CollapseTree.stories.tsx @@ -79,12 +79,7 @@ const CollapseComponentWithHooks = () => { return ( <> - + ); }; diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.stories.constants.ts b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.stories.constants.ts index 8e6045359a..7caf7a806f 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.stories.constants.ts +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.stories.constants.ts @@ -25,8 +25,12 @@ export const clusterConfigurationSchema: ConfigurationSchema = { adcmMeta: { isAdvanced: false, isInvisible: false, - activation: null, - synchronization: null, + activation: { + isAllowChange: true, + }, + synchronization: { + isAllowChange: true, + }, isSecret: false, stringExtra: null, }, diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.stories.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.stories.tsx index f6ce0812a8..67e706a5f8 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.stories.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.stories.tsx @@ -95,7 +95,7 @@ const ConfigurationEditorStoryWithHooks = ({ initialConfigurationData, initialAt return ( <> - +
Show invisible: @@ -165,3 +165,20 @@ export const ConfigurationEditorReadonlyStory: Story = { /> ), }; + +const attributes: ConfigurationAttributes = { + ['/cluster_config/cluster']: { + isActive: true, + isSynchronized: false, + }, +}; + +export const ConfigurationEditorAttributesStory: Story = { + render: () => ( + + ), +}; diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.constants.ts b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.constants.ts index 92b3ee40c3..bc64b9cde2 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.constants.ts +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.constants.ts @@ -6,3 +6,5 @@ export const whiteSpaceStringStub = ''; export const rootNodeKey = '/'; export const rootNodeTitle = 'Configuration'; export const primitiveFieldTypes = new Set(['string', 'integer', 'number', 'boolean']); + +export const toggleAllNodesEventName = 'toggle-all-nodes'; diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.tsx index f00fb62209..a1ba5cce71 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useState } from 'react'; +import { memo, useEffect, useRef, useState } from 'react'; import CollapseNode from '@uikit/CollapseTree2/CollapseNode'; import FieldNodeContent from './NodeContent/FieldNodeContent'; import AddItemNodeContent from './NodeContent/AddItemNodeContent'; @@ -16,7 +16,7 @@ import { ConfigurationAttributes, ConfigurationData, ConfigurationSchema } from import { ChangeConfigurationNodeHandler, ChangeFieldAttributesHandler } from './ConfigurationTree.types'; import s from './ConfigurationTree.module.scss'; import cn from 'classnames'; -import { rootNodeKey } from './ConfigurationTree.constants'; +import { rootNodeKey, toggleAllNodesEventName } from './ConfigurationTree.constants'; export interface ConfigurationTreeProps { schema: ConfigurationSchema; @@ -61,6 +61,7 @@ const ConfigurationTree = memo( onFieldAttributesChange, onChangeIsValid, }: ConfigurationTreeProps) => { + const ref = useRef(null); const configNode: ConfigurationNode = buildConfigurationNodes(schema, configuration, attributes); const [selectedNode, setSelectedNode] = useState(null); @@ -74,6 +75,13 @@ const ConfigurationTree = memo( onChangeIsValid?.(isValid); }, [isValid, onChangeIsValid]); + useEffect(() => { + if (ref.current) { + const eventData = { detail: areExpandedAll }; + ref.current.dispatchEvent(new CustomEvent(toggleAllNodesEventName, eventData)); + } + }, [areExpandedAll]); + const handleClick = (node: ConfigurationNodeView, ref: React.RefObject) => { setSelectedNode(node); onEditField(node, ref); @@ -130,13 +138,15 @@ const ConfigurationTree = memo( }; return ( - +
+ +
); }, ); From 6e132a0b7e5889dbc8ec6dcffa6b590340d34d4c Mon Sep 17 00:00:00 2001 From: Skrynnik Daniil Date: Mon, 27 May 2024 17:43:34 +0300 Subject: [PATCH 131/208] ADCM-5568: remove `reverse` from test_concerns.py --- python/api_v2/tests/test_concerns.py | 54 ++++++++-------------------- 1 file changed, 15 insertions(+), 39 deletions(-) diff --git a/python/api_v2/tests/test_concerns.py b/python/api_v2/tests/test_concerns.py index f3c2196536..ecbde5e895 100644 --- a/python/api_v2/tests/test_concerns.py +++ b/python/api_v2/tests/test_concerns.py @@ -19,7 +19,6 @@ ) from cm.services.concern.messages import ConcernMessage from cm.tests.mocks.task_runner import RunTaskMock -from django.urls import reverse from rest_framework.response import Response from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED @@ -66,7 +65,7 @@ def test_required_service_concern(self): }, } - response: Response = self.client.get(path=reverse(viewname="v2:cluster-detail", kwargs={"pk": cluster.pk})) + response: Response = self.client.v2[cluster].get() self.assertEqual(response.status_code, HTTP_200_OK) data = response.json() @@ -84,7 +83,7 @@ def test_required_config_concern(self): }, } - response: Response = self.client.get(path=reverse(viewname="v2:cluster-detail", kwargs={"pk": cluster.pk})) + response: Response = self.client.v2[cluster].get() self.assertEqual(response.status_code, HTTP_200_OK) data = response.json() @@ -102,7 +101,7 @@ def test_required_import_concern(self): }, } - response: Response = self.client.get(path=reverse(viewname="v2:cluster-detail", kwargs={"pk": cluster.pk})) + response: Response = self.client.v2[cluster].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()["concerns"]), 1) @@ -118,7 +117,7 @@ def test_required_hc_concern(self): }, } - response: Response = self.client.get(path=reverse(viewname="v2:cluster-detail", kwargs={"pk": cluster.pk})) + response: Response = self.client.v2[cluster].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()["concerns"]), 1) @@ -131,13 +130,12 @@ def test_outdated_config_flag(self): "placeholder": {"source": {"name": cluster.name, "params": {"clusterId": cluster.pk}, "type": "cluster"}}, } - response: Response = self.client.post( - path=reverse(viewname="v2:cluster-config-list", kwargs={"cluster_pk": cluster.pk}), + response: Response = self.client.v2[cluster, "configs"].post( data={"config": {"string": "new_string"}, "adcmMeta": {}, "description": ""}, ) self.assertEqual(response.status_code, HTTP_201_CREATED) - response: Response = self.client.get(path=reverse(viewname="v2:cluster-detail", kwargs={"pk": cluster.pk})) + response: Response = self.client.v2[cluster].get() self.assertEqual(response.status_code, HTTP_200_OK) data = response.json() @@ -167,9 +165,7 @@ def test_service_requirements(self): }, } - response: Response = self.client.get( - path=reverse(viewname="v2:service-detail", kwargs={"cluster_pk": cluster.pk, "pk": service.pk}) - ) + response: Response = self.client.v2[service].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()["concerns"]), 1) @@ -179,14 +175,7 @@ def test_job_concern(self): action = Action.objects.filter(prototype=self.cluster_1.prototype).first() with RunTaskMock(): - response = self.client.post( - path=reverse( - viewname="v2:cluster-action-run", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "pk": action.pk, - }, - ), + response = self.client.v2[self.cluster_1, "actions", action, "run"].post( data={"configuration": None, "isVerbose": True, "hostComponentMap": []}, ) @@ -204,9 +193,7 @@ def test_job_concern(self): }, } - response: Response = self.client.get( - path=reverse(viewname="v2:cluster-detail", kwargs={"pk": self.cluster_1.pk}) - ) + response: Response = self.client.v2[self.cluster_1].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()["concerns"]), 1) @@ -227,38 +214,29 @@ def test_import_concern_resolved_after_saving_import(self): import_cluster = self.add_cluster(bundle=self.required_import_bundle, name="required_import_cluster") export_cluster = self.cluster_1 - response: Response = self.client.get( - path=reverse(viewname="v2:cluster-detail", kwargs={"pk": import_cluster.pk}) - ) + response: Response = self.client.v2[import_cluster].get() self.assertEqual(len(response.json()["concerns"]), 1) self.assertEqual(import_cluster.concerns.count(), 1) - self.client.post( - path=reverse(viewname="v2:cluster-import-list", kwargs={"cluster_pk": import_cluster.pk}), + self.client.v2[import_cluster, "imports"].post( data=[{"source": {"id": export_cluster.pk, "type": ObjectType.CLUSTER}}], ) - response: Response = self.client.get( - path=reverse(viewname="v2:cluster-detail", kwargs={"pk": import_cluster.pk}) - ) + response: Response = self.client.v2[import_cluster].get() self.assertEqual(len(response.json()["concerns"]), 0) self.assertEqual(import_cluster.concerns.count(), 0) def test_non_required_import_do_not_raises_concern(self): self.assertGreater(PrototypeImport.objects.filter(prototype=self.cluster_2.prototype).count(), 0) - response: Response = self.client.get( - path=reverse(viewname="v2:cluster-detail", kwargs={"pk": self.cluster_2.pk}) - ) + response: Response = self.client.v2[self.cluster_2].get() self.assertEqual(len(response.json()["concerns"]), 0) self.assertEqual(self.cluster_2.concerns.count(), 0) def test_concern_owner_cluster(self): import_cluster = self.add_cluster(bundle=self.required_import_bundle, name="required_import_cluster") - response: Response = self.client.get( - path=reverse(viewname="v2:cluster-detail", kwargs={"pk": import_cluster.pk}) - ) + response: Response = self.client.v2[import_cluster].get() self.assertEqual(len(response.json()["concerns"]), 1) self.assertEqual(response.json()["concerns"][0]["owner"]["id"], import_cluster.pk) self.assertEqual(response.json()["concerns"][0]["owner"]["type"], "cluster") @@ -266,9 +244,7 @@ def test_concern_owner_cluster(self): def test_concern_owner_service(self): cluster = self.add_cluster(bundle=self.service_requirements_bundle, name="service_requirements_cluster") service = self.add_services_to_cluster(service_names=["service_1"], cluster=cluster).get() - response: Response = self.client.get( - path=reverse(viewname="v2:service-detail", kwargs={"cluster_pk": cluster.pk, "pk": service.pk}) - ) + response: Response = self.client.v2[service].get() self.assertEqual(len(response.json()["concerns"]), 1) self.assertEqual(response.json()["concerns"][0]["owner"]["id"], service.pk) From 399352b45e467ae6e7d32bd0dad5cb070e1569b2 Mon Sep 17 00:00:00 2001 From: Kirill Fedorenko Date: Mon, 27 May 2024 19:10:49 +0000 Subject: [PATCH 132/208] ADCM-5628 [UI] Add data test locators to Import rows https://tracker.yandex.ru/ADCM-5628 --- .../ClusterImportCard/ClusterImportCard.tsx | 10 ++++++++-- .../ClusterImportServices/ClusterImportsService.tsx | 1 + .../ClusterImportsCluster/ClusterImportsCluster.tsx | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImportCard/ClusterImportCard.tsx b/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImportCard/ClusterImportCard.tsx index 8bcb5de219..cb659f13f0 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImportCard/ClusterImportCard.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterImport/ClusterImportCard/ClusterImportCard.tsx @@ -19,6 +19,7 @@ export interface ClusterImportCardProps { clusterImport: AdcmClusterImport; selectedSingleBind: ClusterImportsSetGroup; selectedImports: SelectedImportsGroup; + dataTest: string; onCheckHandler: (selectedImport: SelectedImportHandlerData[]) => void; } @@ -35,7 +36,11 @@ export const ClusterImportLoading = () => { }; export const ClusterImportEmptyCard = () => { - return
No data
; + return ( +
+ No data +
+ ); }; const ClusterImportCard = ({ @@ -43,6 +48,7 @@ const ClusterImportCard = ({ onCheckHandler, selectedSingleBind, selectedImports, + dataTest, }: ClusterImportCardProps) => { // Some services can be "isMultiBind = false", and already selected in another cluster, such services we count here as selected const isAllServicesSelected = clusterImport.importServices?.every( @@ -108,7 +114,7 @@ const ClusterImportCard = ({ return ( <> -
+
{ clusterImports.map((item) => ( { clusterImports.map((item) => ( Date: Mon, 27 May 2024 19:11:11 +0000 Subject: [PATCH 133/208] bugfix/ADCM-5559 do not highlight hashed secrets validation errors Task: https://tracker.yandex.ru/ADCM-5559 --- adcm-web/app/.eslintrc.json | 1 + .../ConfigurationEditor.stories.constants.ts | 6 +- .../ConfigurationEditor.utils.ts | 20 ++-- .../ConfigurationTree.constants.ts | 1 + .../ConfigurationTree/ConfigurationTree.tsx | 19 ++-- .../ConfigurationTree.utils.test.ts | 42 ++++++-- .../ConfigurationTree.utils.ts | 98 +++++++++++++++++-- .../NodeContent/FieldNodeContent.tsx | 19 ++-- .../FieldNodeErrors.module.scss | 5 + .../FieldNodeErrors/FieldNodeErrors.tsx | 31 ++++++ .../NodeContent/NodeWithChildrenContent.tsx | 16 +-- adcm-web/app/src/models/adcm/configuration.ts | 14 ++- .../app/src/utils/jsonSchemaUtils.test.ts | 74 +++----------- adcm-web/app/src/utils/jsonSchemaUtils.ts | 53 +--------- 14 files changed, 240 insertions(+), 159 deletions(-) create mode 100644 adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeErrors/FieldNodeErrors.module.scss create mode 100644 adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeErrors/FieldNodeErrors.tsx diff --git a/adcm-web/app/.eslintrc.json b/adcm-web/app/.eslintrc.json index 043bfd561a..8874909e06 100644 --- a/adcm-web/app/.eslintrc.json +++ b/adcm-web/app/.eslintrc.json @@ -187,6 +187,7 @@ "yaml", "user’s", "vite", + "whitespace", "ws", "wss", "xsrf" diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.stories.constants.ts b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.stories.constants.ts index 7caf7a806f..68c780b466 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.stories.constants.ts +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.stories.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable spellcheck/spell-checker */ -import { ConfigurationSchema } from '@models/adcm'; +import type { ConfigurationSchema } from '@models/adcm'; export const clusterConfigurationSchema: ConfigurationSchema = { $schema: 'https://json-schema.org/draft/2020-12/schema', @@ -98,7 +98,7 @@ export const clusterConfigurationSchema: ConfigurationSchema = { isInvisible: false, activation: null, synchronization: null, - isSecret: false, + isSecret: true, stringExtra: { isMultiline: false, }, @@ -219,7 +219,7 @@ export const initialClusterConfiguration = { cluster: [ { cluster_name: 'Lorem ipsum cluster', - cluster_password: '123', + cluster_password: '$ANSIBLE_VAULT;1.1;AES256\n34326665616563333065323730386465316132646533343764663738', shard: [ { internal_replica: 1, replicas: [{ host: 'host111' }], weight: 11 }, { internal_replica: 2, replicas: [{ host: 'host111' }], weight: 110 }, diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.utils.ts b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.utils.ts index aa50859cd1..0ead650f8a 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.utils.ts +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.utils.ts @@ -1,12 +1,12 @@ -import { ConfigurationData, SchemaDefinition } from '@models/adcm'; -import { JSONObject, JSONPrimitive, JSONValue } from '@models/json'; -import { ConfigurationNodePath } from './ConfigurationEditor.types'; +import type { ConfigurationData, SchemaDefinition } from '@models/adcm'; +import type { JSONObject, JSONPrimitive, JSONValue } from '@models/json'; +import type { ConfigurationNodePath } from './ConfigurationEditor.types'; import { generateFromSchema } from '@utils/jsonSchemaUtils'; import { isObject } from '@utils/objectUtils'; export const editField = (configuration: ConfigurationData, path: ConfigurationNodePath, value: JSONValue) => { if (path.length) { - const newConfiguration = JSON.parse(JSON.stringify(configuration)); + const newConfiguration = cloneConfiguration(configuration); const fieldName = path.pop()!; @@ -22,7 +22,7 @@ export const editField = (configuration: ConfigurationData, path: ConfigurationN }; export const addField = (configuration: ConfigurationData, path: ConfigurationNodePath, value: JSONPrimitive) => { - const newConfiguration = JSON.parse(JSON.stringify(configuration)); + const newConfiguration = cloneConfiguration(configuration); const fieldName = path.pop()!; @@ -41,7 +41,7 @@ export const addField = (configuration: ConfigurationData, path: ConfigurationNo }; export const deleteField = (configuration: ConfigurationData, path: ConfigurationNodePath) => { - const newConfiguration = JSON.parse(JSON.stringify(configuration)); + const newConfiguration = cloneConfiguration(configuration); const fieldName = path.pop()!; @@ -60,7 +60,7 @@ export const addArrayItem = ( path: ConfigurationNodePath, schema: SchemaDefinition, ) => { - const newConfiguration = JSON.parse(JSON.stringify(configuration)); + const newConfiguration = cloneConfiguration(configuration); let node = newConfiguration; for (const part of path) { @@ -79,7 +79,7 @@ export const addArrayItem = ( }; export const deleteArrayItem = (configuration: ConfigurationData, path: ConfigurationNodePath) => { - const newConfiguration = JSON.parse(JSON.stringify(configuration)); + const newConfiguration = cloneConfiguration(configuration); const fieldName = path.pop()!; @@ -111,3 +111,7 @@ export const removeEmpty = (value: unknown): unknown => { return value; }; + +const cloneConfiguration = (configuration: ConfigurationData) => { + return JSON.parse(JSON.stringify(configuration)); +}; diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.constants.ts b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.constants.ts index bc64b9cde2..fca7b815f9 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.constants.ts +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.constants.ts @@ -6,5 +6,6 @@ export const whiteSpaceStringStub = ''; export const rootNodeKey = '/'; export const rootNodeTitle = 'Configuration'; export const primitiveFieldTypes = new Set(['string', 'integer', 'number', 'boolean']); +export const secretFieldValuePrefixToIgnore = '$ANSIBLE_VAULT'; export const toggleAllNodesEventName = 'toggle-all-nodes'; diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.tsx index a1ba5cce71..a175a64e25 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.tsx @@ -3,7 +3,7 @@ import CollapseNode from '@uikit/CollapseTree2/CollapseNode'; import FieldNodeContent from './NodeContent/FieldNodeContent'; import AddItemNodeContent from './NodeContent/AddItemNodeContent'; import NodeWithChildrenContent from './NodeContent/NodeWithChildrenContent'; -import { +import type { ConfigurationNode, ConfigurationNodeView, ConfigurationArray, @@ -12,8 +12,8 @@ import { ConfigurationObject, } from '../ConfigurationEditor.types'; import { buildConfigurationNodes, buildConfigurationTree, validate } from './ConfigurationTree.utils'; -import { ConfigurationAttributes, ConfigurationData, ConfigurationSchema } from '@models/adcm'; -import { ChangeConfigurationNodeHandler, ChangeFieldAttributesHandler } from './ConfigurationTree.types'; +import type { ConfigurationAttributes, ConfigurationData, ConfigurationSchema, FieldErrors } from '@models/adcm'; +import type { ChangeConfigurationNodeHandler, ChangeFieldAttributesHandler } from './ConfigurationTree.types'; import s from './ConfigurationTree.module.scss'; import cn from 'classnames'; import { rootNodeKey, toggleAllNodesEventName } from './ConfigurationTree.constants'; @@ -67,9 +67,9 @@ const ConfigurationTree = memo( const viewConfigTree = buildConfigurationTree(configNode, filter); - const { isValid, errorsPaths } = validate(schema, configuration, attributes); + const { isValid, configurationErrors } = validate(schema, configuration, attributes); // todo: remove commented for debugging process - // !isValid && console.error(errorsPaths); + // !isValid && console.error(configurationErrors); useEffect(() => { onChangeIsValid?.(isValid); @@ -88,7 +88,7 @@ const ConfigurationTree = memo( }; const handleGetNodeClassName = (node: ConfigurationNodeView) => { - const hasError = errorsPaths[node.key] !== undefined; + const hasError = configurationErrors[node.key] !== undefined; const isSelected = node.key === selectedNode?.key; return getNodeClassName(node, hasError, isSelected); }; @@ -98,13 +98,14 @@ const ConfigurationTree = memo( isExpanded: boolean, onExpand: (isOpen: boolean) => void, ) => { - const error = typeof errorsPaths[node.key] === 'string' ? (errorsPaths[node.key] as string) : undefined; + const errors = + typeof configurationErrors[node.key] === 'object' ? (configurationErrors[node.key] as FieldErrors) : undefined; switch (node.data.type) { case 'field': { return ( { test('structure', () => { @@ -338,9 +344,9 @@ describe('validate', () => { }, }; - const { isValid, errorsPaths } = validate(clusterConfigurationSchema, configuration, attributes); + const { isValid, configurationErrors } = validate(clusterConfigurationSchema, configuration, attributes); expect(isValid).toBe(false); - expect(Object.keys(errorsPaths).length).not.toBe(0); + expect(Object.keys(configurationErrors).length).not.toBe(0); }); test('Do not validate inactive groups', () => { @@ -359,9 +365,33 @@ describe('validate', () => { }, }; - const { isValid, errorsPaths } = validate(validateInactiveGroupSchema, configuration, attributes); + const { isValid, configurationErrors } = validate(validateInactiveGroupSchema, configuration, attributes); expect(isValid).toBe(false); - expect(errorsPaths).toStrictEqual({ '/': true, '/structure_2': true, '/structure_2/someField1': 'must be string' }); + expect(Object.keys(configurationErrors).length === 2); + expect(configurationErrors['/']).toBe(true); + expect(configurationErrors['/structure_2']).toBe(true); + expect(configurationErrors['/structure_2/someField1']).not.toBe(true); + expect(typeof configurationErrors['/structure_2/someField1']).toBe('object'); + + const fieldErrors = configurationErrors['/structure_2/someField1']; + expect((fieldErrors as FieldErrors).messages).not.toStrictEqual({ required: 'must be string' }); + }); + + test('fillParentPathParts', () => { + const errors: ConfigurationErrors = { + '/config/cluster/clusterName': true, + }; + + fillParentPathParts(errors); + + const expected: ConfigurationErrors = { + '/': true, + '/config': true, + '/config/cluster': true, + '/config/cluster/clusterName': true, + }; + + expect(errors).toStrictEqual(expected); }); }); diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.utils.ts b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.utils.ts index aa515e2691..8b7843d9a3 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.utils.ts +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.utils.ts @@ -2,10 +2,12 @@ import { ConfigurationData, ConfigurationSchema, SchemaDefinition, + ConfigurationErrors, SingleSchemaDefinition, MultipleSchemaDefinitions, ConfigurationAttributes, FieldAttributes, + FieldErrors, } from '@models/adcm'; import { JSONValue, JSONObject, JSONPrimitive } from '@models/json'; import { @@ -17,30 +19,108 @@ import { ConfigurationNodeView, } from '../ConfigurationEditor.types'; import { validate as validateJsonSchema } from '@utils/jsonSchemaUtils'; -import { primitiveFieldTypes, rootNodeKey, rootNodeTitle } from './ConfigurationTree.constants'; +import { + primitiveFieldTypes, + rootNodeKey, + rootNodeTitle, + secretFieldValuePrefixToIgnore, +} from './ConfigurationTree.constants'; export const validate = (schema: SchemaDefinition, configuration: JSONObject, attributes: ConfigurationAttributes) => { - const { errorsPaths } = validateJsonSchema(schema, configuration); + const errors = validateJsonSchema(schema, configuration); + + const configurationErrors = getConfigurationErrors(errors); + filterConfigurationErrors(configurationErrors, attributes); + fillParentPathParts(configurationErrors); + + const isValid = Object.keys(configurationErrors).length === 0; + + return { isValid, configurationErrors }; +}; + +export const getConfigurationErrors = (errors: ReturnType) => { + const result: ConfigurationErrors = {}; + + if (!errors || errors.length === 0) { + return result; + } + + // group error by fieldPath + for (const error of errors) { + let instancePath = error.instancePath; + let errorMessage = error.message || ''; + + // extend error from structure to field + if (error.keyword === 'required') { + instancePath += `/${error.params.missingProperty}`; + errorMessage = 'required'; + } + + if (!result[instancePath]) { + result[instancePath] = { + schema: error.parentSchema as SchemaDefinition, + value: error.data, + messages: {}, + }; + } - // TODO: Optimize (get rid nested loop) + const fieldErrors = result[instancePath] as FieldErrors; + fieldErrors.messages[error.keyword] = errorMessage; + } + + return result; +}; + +export const filterConfigurationErrors = (errors: ConfigurationErrors, attributes: ConfigurationAttributes) => { // ignore errors for not active groups for (const [path, value] of Object.entries(attributes)) { if (value.isActive === false) { - for (const [errorPath] of Object.entries(errorsPaths)) { + for (const [errorPath] of Object.entries(errors)) { if (errorPath === path || errorPath.startsWith(`${path}/`)) { - delete errorsPaths[errorPath]; + delete errors[errorPath]; } } } } - if (Object.keys(errorsPaths).length === 1 && errorsPaths['/'] === true) { - delete errorsPaths['/']; + for (const [errorPath, error] of Object.entries(errors)) { + const fieldErrors = error as FieldErrors; + const { fieldSchema } = determineFieldSchema(fieldErrors.schema); + + if (fieldSchema.type === 'string' && fieldSchema.adcmMeta.isSecret) { + const fieldValue = fieldErrors.value as string; + const isIgnoredKeyword = + fieldErrors.messages['pattern'] || fieldErrors.messages['minLength'] || fieldErrors.messages['maxLength']; + + // ignore hashed secrets from backend + if (isIgnoredKeyword && fieldValue.startsWith(secretFieldValuePrefixToIgnore)) { + delete errors[errorPath]; + } + } } +}; - const isValid = Object.keys(errorsPaths).length === 0; +export const fillParentPathParts = (errors: ConfigurationErrors) => { + // root always has children with errors + if (Object.keys(errors).length > 0) { + errors['/'] = true; + } - return { isValid, errorsPaths }; + // errorPath - is full path to field + // like /configuration/cluster/clusterName + for (const errorPath of Object.keys(errors)) { + const parts = errorPath.split('/'); + let path = ''; + + // skip first part and last: + // - first part is empty string + // - last part represents full path and it already exists in errors + for (let i = 1; i < parts.length - 1; i++) { + const part = parts[i]; + path = `${path}/${part}`; + errors[path] = true; + } + } }; export const getTitle = (keyName: string, fieldSchema: SingleSchemaDefinition) => diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeContent.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeContent.tsx index 47396ef309..322684d8c3 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeContent.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeContent.tsx @@ -1,19 +1,20 @@ import { useCallback, useRef, useMemo, useState } from 'react'; -import { IconButton, Tooltip } from '@uikit'; -import { ConfigurationField, ConfigurationNodeView } from '../../ConfigurationEditor.types'; +import { IconButton, Tooltip, MarkerIcon } from '@uikit'; +import type { ConfigurationField, ConfigurationNodeView } from '../../ConfigurationEditor.types'; import { emptyStringStub, nullStub, secretStub, whiteSpaceStringStub } from '../ConfigurationTree.constants'; import s from '../ConfigurationTree.module.scss'; import cn from 'classnames'; import ActivationAttribute from './ActivationAttribute/ActivationAttribute'; import SynchronizedAttribute from './SyncronizedAttribute/SynchronizedAttribute'; -import { ChangeConfigurationNodeHandler, ChangeFieldAttributesHandler } from '../ConfigurationTree.types'; -import MarkerIcon from '@uikit/MarkerIcon/MarkerIcon'; +import FieldNodeErrors from './FieldNodeErrors/FieldNodeErrors'; +import type { ChangeConfigurationNodeHandler, ChangeFieldAttributesHandler } from '../ConfigurationTree.types'; import { isPrimitiveValueSet } from '@models/json'; +import type { FieldErrors } from '@models/adcm'; import { isWhiteSpaceOnly } from '@utils/validationsUtils.ts'; interface FieldNodeContentProps { node: ConfigurationNodeView; - error?: string; + errors?: FieldErrors; onClick: ChangeConfigurationNodeHandler; onClear: ChangeConfigurationNodeHandler; onDelete: ChangeConfigurationNodeHandler; @@ -22,7 +23,7 @@ interface FieldNodeContentProps { const FieldNodeContent = ({ node, - error, + errors, onClick, onClear, onDelete, @@ -70,7 +71,7 @@ const FieldNodeContent = ({ }; const className = cn(s.nodeContent, { - 'is-failed': error !== undefined, + 'is-failed': errors !== undefined, }); const value: string | number | boolean = useMemo(() => { @@ -122,8 +123,8 @@ const FieldNodeContent = ({ {fieldNodeData.isDeletable && ( )} - {error && ( - + {errors && ( + }> )} diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeErrors/FieldNodeErrors.module.scss b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeErrors/FieldNodeErrors.module.scss new file mode 100644 index 0000000000..8eb830f70d --- /dev/null +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeErrors/FieldNodeErrors.module.scss @@ -0,0 +1,5 @@ +.fieldNodeErrors { + display: flex; + flex-direction: column; + gap: 8px; +} \ No newline at end of file diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeErrors/FieldNodeErrors.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeErrors/FieldNodeErrors.tsx new file mode 100644 index 0000000000..7b4240af2f --- /dev/null +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeErrors/FieldNodeErrors.tsx @@ -0,0 +1,31 @@ +import type { FieldErrors } from '@models/adcm'; +import s from './FieldNodeErrors.module.scss'; + +export interface FieldNodeErrorsProps { + fieldErrors: FieldErrors; +} + +const FieldNodeErrors = ({ fieldErrors }: FieldNodeErrorsProps) => { + const hasOneOfKeywordError = Boolean(fieldErrors.messages['oneOf']); + + return ( +
+ {Object.entries(fieldErrors.messages).map(([keyword, error]) => { + if (keyword === 'oneOf' || keyword === 'type') { + return null; + } + + return {error}; + })} + + {hasOneOfKeywordError && ( + <> + OR + must be unset + + )} +
+ ); +}; + +export default FieldNodeErrors; diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/NodeWithChildrenContent.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/NodeWithChildrenContent.tsx index be25e3b958..879ebc12bf 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/NodeWithChildrenContent.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/NodeWithChildrenContent.tsx @@ -1,17 +1,19 @@ import { useCallback, useRef, useState } from 'react'; import { IconButton, MarkerIcon, Tooltip } from '@uikit'; import { isValueSet } from '@models/json'; -import { ConfigurationArray, ConfigurationObject, ConfigurationNodeView } from '../../ConfigurationEditor.types'; -import { ChangeConfigurationNodeHandler, ChangeFieldAttributesHandler } from '../ConfigurationTree.types'; +import type { ConfigurationArray, ConfigurationObject, ConfigurationNodeView } from '../../ConfigurationEditor.types'; +import type { ChangeConfigurationNodeHandler, ChangeFieldAttributesHandler } from '../ConfigurationTree.types'; import s from '../ConfigurationTree.module.scss'; import cn from 'classnames'; import SynchronizedAttribute from './SyncronizedAttribute/SynchronizedAttribute'; import ActivationAttribute from './ActivationAttribute/ActivationAttribute'; +import FieldNodeErrors from './FieldNodeErrors/FieldNodeErrors'; import { nullStub } from '../ConfigurationTree.constants'; +import type { FieldErrors } from '@models/adcm'; interface NodeWithChildrenContentProps { node: ConfigurationNodeView; - error?: string; + errors?: FieldErrors; isExpanded: boolean; onClear: ChangeConfigurationNodeHandler; onDelete: ChangeConfigurationNodeHandler; @@ -22,7 +24,7 @@ interface NodeWithChildrenContentProps { const NodeWithChildrenContent = ({ node, isExpanded, - error, + errors, onClear, onDelete, onExpand, @@ -74,7 +76,7 @@ const NodeWithChildrenContent = ({ const className = cn(s.nodeContent, { 'is-open': isExpanded, - 'is-failed': error !== undefined, + 'is-failed': errors !== undefined, }); const hasChildren = Boolean(node.children?.length); @@ -94,8 +96,8 @@ const NodeWithChildrenContent = ({ )} {isDeletable && } - {error && ( - + {errors && ( + }> )} diff --git a/adcm-web/app/src/models/adcm/configuration.ts b/adcm-web/app/src/models/adcm/configuration.ts index 8ccfd3d0d4..21b4363320 100644 --- a/adcm-web/app/src/models/adcm/configuration.ts +++ b/adcm-web/app/src/models/adcm/configuration.ts @@ -51,7 +51,7 @@ export type FieldAttributes = { isSynchronized?: boolean; }; -export type ConfigurationAttributes = Record; // key - path, value: attributes +export type ConfigurationAttributes = Record; // key - path, value: attributes export interface AdcmConfigShortView { id: number; @@ -74,3 +74,15 @@ export interface AdcmConfiguration { export interface AdcmFullConfigurationInfo extends AdcmConfigShortView { configuration: AdcmConfiguration; } + +export type FieldPath = string; +export type ErrorKeyword = string; +export type ErrorMessage = string; +export type FieldErrors = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any; + schema: SchemaDefinition; + messages: Record; +}; + +export type ConfigurationErrors = Record; diff --git a/adcm-web/app/src/utils/jsonSchemaUtils.test.ts b/adcm-web/app/src/utils/jsonSchemaUtils.test.ts index 5170443bd5..493387b00c 100644 --- a/adcm-web/app/src/utils/jsonSchemaUtils.test.ts +++ b/adcm-web/app/src/utils/jsonSchemaUtils.test.ts @@ -68,8 +68,8 @@ describe('validate', () => { }, }; - const result = validate(schema, object); - expect(result.isValid).toBe(true); + const errors = validate(schema, object); + expect(errors).toBe(null); }); test('validate incorrect data', () => { @@ -84,18 +84,14 @@ describe('validate', () => { }, }; - const result = validate(schema, object); - expect(result.isValid).toBe(false); - expect(result.errorsPaths).toStrictEqual({ - '/': true, - '/clusterConfiguration': true, - '/clusterConfiguration/cluster_config': true, - '/clusterConfiguration/cluster_config/cluster': true, - '/clusterConfiguration/cluster_config/cluster/shard': true, - '/clusterConfiguration/cluster_config/cluster/shard/0': true, - '/clusterConfiguration/cluster_config/cluster/shard/0/internal_replica': 'must be >= 12', - '/clusterConfiguration/cluster_config/cluster/shard/0/weight': 'must be <= 10', - }); + const errors = validate(schema, object); + + expect(errors).not.toBe(null); + expect(errors?.length).toBe(2); + expect(errors![0].instancePath).toBe('/clusterConfiguration/cluster_config/cluster/shard/0/internal_replica'); + expect(errors![0].message).toBe('must be >= 12'); + expect(errors![1].instancePath).toBe('/clusterConfiguration/cluster_config/cluster/shard/0/weight'); + expect(errors![1].message).toBe('must be <= 10'); }); test('validate multiple types', () => { @@ -137,52 +133,14 @@ describe('validate', () => { some_field: null, }; - const result1 = validate(schema, object1); - expect(result1.isValid).toBe(true); - - const result2 = validate(schema, object2); - expect(result2.isValid).toBe(true); - - const result3 = validate(schema, object3); - expect(result3.isValid).toBe(false); - }); - - test('test required fields', () => { - const schema: Schema = { - $schema: 'https://json-schema.org/draft/2020-12/schema', - type: 'object', - required: ['structure'], - properties: { - structure: { - type: 'object', - required: ['field1', 'field2'], - properties: { - field1: { type: 'string' }, - field2: { type: 'string' }, - }, - }, - }, - }; - - const object1 = { - structure: { - field1: 'value1', - }, - }; - - const object2 = { - structure: { - field1: 'value1', - field2: 'value2', - }, - }; + const errors1 = validate(schema, object1); + expect(errors1).toBe(null); - const result1 = validate(schema, object1); - expect(result1.isValid).toBe(false); - expect(result1.errorsPaths).toStrictEqual({ '/': true, '/structure': true, '/structure/field2': 'required' }); + const errors2 = validate(schema, object2); + expect(errors2).toBe(null); - const result2 = validate(schema, object2); - expect(result2.isValid).toBe(true); + const errors3 = validate(schema, object3); + expect(errors3).not.toBe(null); }); }); diff --git a/adcm-web/app/src/utils/jsonSchemaUtils.ts b/adcm-web/app/src/utils/jsonSchemaUtils.ts index c5b2a3f969..f4b27e089c 100644 --- a/adcm-web/app/src/utils/jsonSchemaUtils.ts +++ b/adcm-web/app/src/utils/jsonSchemaUtils.ts @@ -1,9 +1,10 @@ /* eslint-disable spellcheck/spell-checker */ -import Ajv2020, { Schema, ErrorObject } from 'ajv/dist/2020'; +import Ajv2020, { Schema } from 'ajv/dist/2020'; const ajv = new Ajv2020({ strictSchema: true, allErrors: true, + verbose: true, }); ajv.addVocabulary(['adcmMeta']); @@ -20,55 +21,9 @@ ajvWithDefaults.addFormat('yaml', true); export const validate = (schema: Schema, data: T) => { const validate = ajv.compile(schema, true); - const isValid = validate(data); + validate(data); - return { - isValid, - errors: validate.errors, - errorsPaths: getAllErrorInstancePaths(validate.errors), - evaluated: validate.evaluated, - schema: validate.schema, - schemaEnv: validate.schemaEnv, - source: validate.source, - }; -}; - -const getAllErrorInstancePaths = (errors: ErrorObject[] | undefined | null) => { - const result: Record = {}; // key - path, value - message or true (true means that child node has error) - if (!errors || errors.length === 0) { - return result; - } - - // root always has children with errors - result['/'] = true; - - for (const error of errors) { - let instancePath = error.instancePath; - let errorMessage = error.message; - - if (result[instancePath]) { - continue; - } - - // extend error from structure to field - if (error.keyword === 'required') { - instancePath += `/${error.params.missingProperty}`; - errorMessage = 'required'; - } - - const parts = instancePath.split('/'); - let path = ''; - for (const part of parts) { - if (part) { - path = `${path}/${part}`; - result[path] = true; - } - } - - result[instancePath] = errorMessage ?? ''; - } - - return result; + return validate.errors; }; export const generateFromSchema = (schema: Schema): T | null => { From 8dfe7fb63ca33a7bc6965008fdc42917788cdd97 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Tue, 28 May 2024 04:21:39 +0000 Subject: [PATCH 134/208] ADCM-5605 Implement data collectors for statistics collection command --- python/adcm/tests/base.py | 9 -- .../tests/test_targets_extraction.py | 2 +- python/cm/collect_statistics/collectors.py | 108 +++++++++++-- .../commands/collect_statistics_new.py | 33 +++- python/cm/tests/test_cluster.py | 6 +- python/cm/tests/test_collect_statistics.py | 142 ++++++++++++++++++ python/cm/tests/test_hc.py | 4 +- .../test_policy/test_service_admin_role.py | 10 +- 8 files changed, 278 insertions(+), 36 deletions(-) create mode 100644 python/cm/tests/test_collect_statistics.py diff --git a/python/adcm/tests/base.py b/python/adcm/tests/base.py index 0b5fc494b8..f071414380 100644 --- a/python/adcm/tests/base.py +++ b/python/adcm/tests/base.py @@ -421,15 +421,6 @@ def create_host_in_cluster(self, provider_pk: int, name: str, cluster_pk: int) - return host - def add_host_to_cluster(self, cluster_pk: int, host_pk: int) -> None: - response: Response = self.client.post( - path=reverse(viewname="v1:host", kwargs={"cluster_id": cluster_pk}), - data={"host_id": host_pk}, - content_type=APPLICATION_JSON, - ) - - self.assertEqual(response.status_code, HTTP_201_CREATED) - @staticmethod def get_hostcomponent_data(service_pk: int, host_pk: int) -> list[dict[str, int]]: hostcomponent_data = [] diff --git a/python/ansible_plugin/tests/test_targets_extraction.py b/python/ansible_plugin/tests/test_targets_extraction.py index f1bf84df36..7c2500cfaf 100644 --- a/python/ansible_plugin/tests/test_targets_extraction.py +++ b/python/ansible_plugin/tests/test_targets_extraction.py @@ -197,7 +197,7 @@ def test_component_host_action_context_success(self): parent_cluster = self.cluster_1 component = ServiceComponent.objects.filter(cluster=parent_cluster).first() - self.add_host_to_cluster(cluster_pk=parent_cluster.pk, host_pk=host.pk) + self.add_host_to_cluster(cluster=parent_cluster, host=host) self.set_hostcomponent(cluster=parent_cluster, entries=[(host, component)]) self.check_target_detection( diff --git a/python/cm/collect_statistics/collectors.py b/python/cm/collect_statistics/collectors.py index 7d07f0b351..dd9155a55f 100644 --- a/python/cm/collect_statistics/collectors.py +++ b/python/cm/collect_statistics/collectors.py @@ -10,43 +10,50 @@ # See the License for the specific language governing permissions and # limitations under the License. +from collections import defaultdict +from hashlib import md5 +from typing import Literal + +from django.db.models import Count, F from pydantic import BaseModel +from rbac.models import Policy, Role, User +from typing_extensions import TypedDict -from cm.collect_statistics.types import Collector +from cm.models import Bundle, Cluster, HostComponent, HostProvider -class BundleData(BaseModel): +class BundleData(TypedDict): name: str version: str edition: str date: str -class HostComponentData(BaseModel): +class HostComponentData(TypedDict): host_name: str component_name: str service_name: str -class ClusterData(BaseModel): +class ClusterData(TypedDict): name: str host_count: int bundle: dict host_component_map: list[dict] -class HostProviderData(BaseModel): +class HostProviderData(TypedDict): name: str host_count: int bundle: dict -class UserData(BaseModel): +class UserData(TypedDict): email: str date_joined: str -class RoleData(BaseModel): +class RoleData(TypedDict): name: str built_in: bool @@ -55,15 +62,92 @@ class ADCMEntities(BaseModel): clusters: list[ClusterData] bundles: list[BundleData] providers: list[HostProviderData] + + +class RBACEntities(BaseModel): users: list[UserData] roles: list[RoleData] -class CommunityBundleCollector(Collector): - def __call__(self) -> ADCMEntities: - pass +class RBACCollector: + def __init__(self, date_format: str): + self._date_format = date_format + + def __call__(self) -> RBACEntities: + return RBACEntities( + users=[ + UserData(email=email, date_joined=date_joined.strftime(self._date_format)) + for email, date_joined in User.objects.values_list("email", "date_joined") + ], + roles=[ + RoleData(**role) + for role in Role.objects.filter( + pk__in=Policy.objects.filter(role__isnull=False).values_list("role_id", flat=True).distinct() + ).values("name", "built_in") + ], + ) + + +class BundleCollector: + EDITION: str + def __init__(self, date_format: str): + self._date_format = date_format -class EnterpriseBundleCollector(Collector): def __call__(self) -> ADCMEntities: - pass + bundles: dict[int, BundleData] = { + entry.pop("id"): BundleData(date=entry.pop("date").strftime(self._date_format), **entry) + for entry in Bundle.objects.filter(edition=self.EDITION).values("id", *BundleData.__annotations__.keys()) + } + + hostproviders_data = [ + HostProviderData(name=entry["name"], host_count=entry["host_count"], bundle=bundles[entry["bundle_id"]]) + for entry in HostProvider.objects.filter(prototype__bundle_id__in=bundles.keys()) + .values("name", bundle_id=F("prototype__bundle_id")) + .annotate(host_count=Count("host")) + ] + + cluster_general_info: dict[int, dict[Literal["name", "bundle_id", "host_count"], int | str]] = { + entry.pop("id"): entry + for entry in Cluster.objects.filter(prototype__bundle_id__in=bundles.keys()) + .values("id", "name", bundle_id=F("prototype__bundle_id")) + .annotate(host_count=Count("host")) + } + + hostcomponent_data = defaultdict(list) + for entry in HostComponent.objects.filter(cluster_id__in=cluster_general_info.keys()).values( + "cluster_id", + host_name=F("host__fqdn"), + component_name=F("component__prototype__name"), + service_name=F("service__prototype__name"), + ): + hostcomponent_data[entry.pop("cluster_id")].append( + HostComponentData( + host_name=md5(entry.pop("host_name").encode(encoding="utf-8")).hexdigest(), # noqa: S324 + **entry, + ) + ) + + clusters_data = [ + ClusterData( + name=data["name"], + host_count=data["host_count"], + bundle=bundles[data["bundle_id"]], + host_component_map=hostcomponent_data.get(cluster_id, []), + ) + for cluster_id, data in cluster_general_info.items() + ] + + return ADCMEntities( + clusters=clusters_data, + bundles=bundles.values(), + providers=hostproviders_data, + ) + + +class CommunityBundleCollector(BundleCollector): + EDITION = "community" + + +class EnterpriseBundleCollector(BundleCollector): + EDITION = "enterprise" diff --git a/python/cm/management/commands/collect_statistics_new.py b/python/cm/management/commands/collect_statistics_new.py index 63adb6b5c1..6f9cbd6c1a 100644 --- a/python/cm/management/commands/collect_statistics_new.py +++ b/python/cm/management/commands/collect_statistics_new.py @@ -10,11 +10,17 @@ # See the License for the specific language governing permissions and # limitations under the License. +import socket from django.conf import settings from django.core.management import BaseCommand -from cm.collect_statistics.collectors import CommunityBundleCollector, EnterpriseBundleCollector +from cm.collect_statistics.collectors import ( + ADCMEntities, + CommunityBundleCollector, + EnterpriseBundleCollector, + RBACCollector, +) from cm.collect_statistics.encoders import TarFileEncoder from cm.collect_statistics.senders import StatisticSender from cm.collect_statistics.storages import JSONFile, TarFileWithJSONFileStorage, TarFileWithTarFileStorage @@ -22,7 +28,11 @@ def is_internal() -> bool: - return True # TODO: implement logic + try: + with socket.create_connection(("adsw.io", 80), timeout=1): + return True + except TimeoutError: + return False class Command(BaseCommand): @@ -34,6 +44,8 @@ def add_arguments(self, parser): parser.add_argument("--encode", action="store_true", help="encode data") def handle(self, *_, full: bool, send: bool, encode: bool, **__): + date_format = "%Y-%m-%d %H:%M:%S" + statistics_data = { "adcm": { "uuid": str(ADCM.objects.values_list("uuid", flat=True).get()), @@ -42,22 +54,31 @@ def handle(self, *_, full: bool, send: bool, encode: bool, **__): }, "format_version": "0.2", } + rbac_entries_data: dict = RBACCollector(date_format=date_format)().model_dump() - community_bundle_data = CommunityBundleCollector()() + community_bundle_data: ADCMEntities = CommunityBundleCollector(date_format=date_format)() community_storage = TarFileWithJSONFileStorage() - community_storage.add(JSONFile(filename="community.json", data={**statistics_data, **community_bundle_data})) + community_storage.add( + JSONFile( + filename="community.json", + data={**statistics_data, **rbac_entries_data, **community_bundle_data.model_dump()}, + ) + ) community_archive = community_storage.gather() final_storage = TarFileWithTarFileStorage() final_storage.add(community_archive) if full: - enterprise_bundle_data = EnterpriseBundleCollector()() + enterprise_bundle_data: ADCMEntities = EnterpriseBundleCollector(date_format=date_format)() enterprise_storage = TarFileWithJSONFileStorage() enterprise_storage.add( - JSONFile(filename="enterprise.json", data={**statistics_data, **enterprise_bundle_data}) + JSONFile( + filename="enterprise.json", + data={**statistics_data, **rbac_entries_data, **enterprise_bundle_data.model_dump()}, + ) ) final_storage.add(enterprise_storage.gather()) diff --git a/python/cm/tests/test_cluster.py b/python/cm/tests/test_cluster.py index 64a962c883..bfba307ddd 100644 --- a/python/cm/tests/test_cluster.py +++ b/python/cm/tests/test_cluster.py @@ -12,7 +12,7 @@ import string -from adcm.tests.base import APPLICATION_JSON, BaseTestCase +from adcm.tests.base import APPLICATION_JSON, BaseTestCase, BusinessLogicMixin from core.cluster.types import ClusterTopology, ComponentTopology, ServiceTopology from core.types import ShortObjectInfo from django.urls import reverse @@ -23,7 +23,7 @@ from cm.tests.utils import gen_component, gen_host, gen_service, generate_hierarchy -class TestCluster(BaseTestCase): +class TestCluster(BaseTestCase, BusinessLogicMixin): def setUp(self) -> None: super().setUp() @@ -161,7 +161,7 @@ def test_retrieve_cluster_topology_success(self) -> None: component_11 = hierarchy["component"] host_1 = hierarchy["host"] host_2 = gen_host(provider=hierarchy["provider"]) - self.add_host_to_cluster(cluster_pk=cluster.pk, host_pk=host_2.pk) + self.add_host_to_cluster(cluster=cluster, host=host_2) gen_host(provider=hierarchy["provider"]) service_2 = gen_service(cluster=cluster) component_21 = gen_component(service=service_2) diff --git a/python/cm/tests/test_collect_statistics.py b/python/cm/tests/test_collect_statistics.py new file mode 100644 index 0000000000..4a7c4e2a20 --- /dev/null +++ b/python/cm/tests/test_collect_statistics.py @@ -0,0 +1,142 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from hashlib import md5 +from operator import itemgetter +from pathlib import Path + +from adcm.tests.base import BaseTestCase, BusinessLogicMixin +from django.utils import timezone + +from cm.collect_statistics.collectors import CommunityBundleCollector +from cm.models import Bundle + + +class TestBundle(BaseTestCase, BusinessLogicMixin): + def setUp(self) -> None: + super().setUp() + + self.bundles_dir = Path(__file__).parent / "bundles" + self.maxDiff = None + + def test_collect_community_bundle_collector(self) -> None: + # prepare data + bundle_cluster_reg = self.add_bundle(self.bundles_dir / "cluster_1") + bundle_cluster_full = self.add_bundle(self.bundles_dir / "cluster_full_config") + bundle_prov_reg = self.add_bundle(self.bundles_dir / "provider") + bundle_prov_full = self.add_bundle(self.bundles_dir / "provider_full_config") + + cluster_reg_1 = self.add_cluster(bundle=bundle_cluster_reg, name="Regular 1") + cluster_full = self.add_cluster(bundle=bundle_cluster_full, name="Full 1") + cluster_reg_2 = self.add_cluster(bundle=bundle_cluster_reg, name="Regular 2") + + provider_full_1 = self.add_provider(bundle=bundle_prov_full, name="Prov Full 1") + provider_full_2 = self.add_provider(bundle=bundle_prov_full, name="Prov Full 2") + provider_reg_1 = self.add_provider(bundle=bundle_prov_reg, name="Prov Reg 1") + + host_1 = self.add_host(provider=provider_full_1, fqdn="host-1", cluster=cluster_reg_1) + host_2 = self.add_host(provider=provider_full_1, fqdn="host-2", cluster=cluster_reg_2) + self.add_host(provider=provider_reg_1, fqdn="host-3", cluster=cluster_reg_1) + + self.add_services_to_cluster(["service_one_component"], cluster=cluster_reg_1) + service_2 = self.add_services_to_cluster(["service_two_components"], cluster=cluster_reg_1).get() + service_3 = self.add_services_to_cluster(["service_two_components"], cluster=cluster_reg_2).get() + + component_1, component_2 = service_2.servicecomponent_set.order_by("id").all() + component_3 = service_3.servicecomponent_set.order_by("id").first() + self.set_hostcomponent(cluster=cluster_reg_1, entries=((host_1, component_1), (host_1, component_2))) + + self.set_hostcomponent(cluster=cluster_reg_2, entries=((host_2, component_3),)) + + # prepare expected + order_hc_by = itemgetter("component_name") + by_name = itemgetter("name") + collect = CommunityBundleCollector(date_format="%Y") + current_year = str(timezone.now().year) + host_1_name_hash = md5(host_1.fqdn.encode("utf-8")).hexdigest() # noqa: S324 + host_2_name_hash = md5(host_2.fqdn.encode("utf-8")).hexdigest() # noqa: S324 + expected_bundles = [ + {"name": bundle.name, "version": bundle.version, "edition": "community", "date": current_year} + for bundle in ( + bundle_cluster_reg, + bundle_cluster_full, + bundle_prov_reg, + bundle_prov_full, + Bundle.objects.get(name="ADCM"), + ) + ] + expected = { + "bundles": sorted(expected_bundles, key=by_name), + "providers": sorted( + [ + {"name": provider_full_1.name, "bundle": expected_bundles[3], "host_count": 2}, + {"name": provider_full_2.name, "bundle": expected_bundles[3], "host_count": 0}, + {"name": provider_reg_1.name, "bundle": expected_bundles[2], "host_count": 1}, + ], + key=by_name, + ), + "clusters": sorted( + [ + { + "name": cluster_full.name, + "host_count": 0, + "bundle": expected_bundles[1], + "host_component_map": [], + }, + { + "name": cluster_reg_1.name, + "host_count": 2, + "bundle": expected_bundles[0], + "host_component_map": sorted( + [ + { + "host_name": host_1_name_hash, + "component_name": component_1.name, + "service_name": service_2.name, + }, + { + "host_name": host_1_name_hash, + "component_name": component_2.name, + "service_name": service_2.name, + }, + ], + key=order_hc_by, + ), + }, + { + "name": cluster_reg_2.name, + "host_count": 1, + "bundle": expected_bundles[0], + "host_component_map": [ + { + "host_name": host_2_name_hash, + "component_name": component_3.name, + "service_name": service_3.name, + }, + ], + }, + ], + key=by_name, + ), + } + + # check + actual = collect().model_dump() + + # order for reproducible comparison + for root_key in actual: + actual[root_key] = sorted(actual[root_key], key=by_name) + + for entry in actual["clusters"]: + entry["host_component_map"] = sorted(entry["host_component_map"], key=order_hc_by) + + self.assertDictEqual(actual, expected) diff --git a/python/cm/tests/test_hc.py b/python/cm/tests/test_hc.py index bb79370644..cf50a6f876 100644 --- a/python/cm/tests/test_hc.py +++ b/python/cm/tests/test_hc.py @@ -189,8 +189,8 @@ def test_run_same_hc_bug_adcm_4929(self) -> None: host_1 = self.add_host(provider=hostprovider, fqdn="host-1") host_2 = self.add_host(provider=hostprovider, fqdn="host-2") - self.add_host_to_cluster(cluster.pk, host_1.pk) - self.add_host_to_cluster(cluster.pk, host_2.pk) + self.add_host_to_cluster(cluster, host_1) + self.add_host_to_cluster(cluster, host_2) component_1_1 = ServiceComponent.objects.get(service=service_1, prototype__name="component_1") component_2_1 = ServiceComponent.objects.get(service=service_2, prototype__name="component_1") diff --git a/python/rbac/tests/test_policy/test_service_admin_role.py b/python/rbac/tests/test_policy/test_service_admin_role.py index ab1d43627f..4fc1bdd909 100644 --- a/python/rbac/tests/test_policy/test_service_admin_role.py +++ b/python/rbac/tests/test_policy/test_service_admin_role.py @@ -10,9 +10,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -from adcm.tests.base import APPLICATION_JSON, BaseTestCase +from adcm.tests.base import APPLICATION_JSON, BaseTestCase, BusinessLogicMixin from cm.models import ( + Cluster, ClusterObject, + Host, ObjectConfig, ObjectType, Prototype, @@ -25,7 +27,7 @@ from rbac.models import Group -class PolicyWithServiceAdminRoleTestCase(BaseTestCase): +class PolicyWithServiceAdminRoleTestCase(BaseTestCase, BusinessLogicMixin): def setUp(self) -> None: super().setUp() @@ -41,7 +43,9 @@ def setUp(self) -> None: self.cluster_pk = self.get_cluster_pk() self.host_pk = self.get_host_pk() self.service = self.get_service() - self.add_host_to_cluster(cluster_pk=self.cluster_pk, host_pk=self.host_pk) + self.add_host_to_cluster( + cluster=Cluster.objects.get(id=self.cluster_pk), host=Host.objects.get(id=self.host_pk) + ) self.create_policy(role_name="Service Administrator", obj=self.service, group_pk=self.new_user_group.pk) self.another_user_log_in(username=new_user.username, password=self.new_user_password) From 59acb5a3e607359e5f51ea73e063c4b8f5a7c46e Mon Sep 17 00:00:00 2001 From: Egor Araslanov Date: Tue, 28 May 2024 10:05:08 +0500 Subject: [PATCH 135/208] ADCM-5584 Remove `reverse` from `test_upgrade` --- python/adcm/tests/client.py | 2 +- python/api_v2/tests/test_upgrade.py | 194 +++++++--------------------- 2 files changed, 51 insertions(+), 145 deletions(-) diff --git a/python/adcm/tests/client.py b/python/adcm/tests/client.py index 26c3cfbbf7..9c2a1830c9 100644 --- a/python/adcm/tests/client.py +++ b/python/adcm/tests/client.py @@ -66,7 +66,7 @@ def path(self) -> str: def get(self, *, query: dict | None = None) -> Response: return self._client.get(path=self.path, data=query) - def post(self, *, data: dict | list[dict], format_: str | None = None) -> Response: + def post(self, *, data: dict | list[dict] | None = None, format_: str | None = None) -> Response: return self._client.post(path=self.path, data=data, format=format_) def patch(self, *, data: dict) -> Response: diff --git a/python/api_v2/tests/test_upgrade.py b/python/api_v2/tests/test_upgrade.py index c6ba5e805a..11fda8d993 100644 --- a/python/api_v2/tests/test_upgrade.py +++ b/python/api_v2/tests/test_upgrade.py @@ -22,12 +22,11 @@ Upgrade, ) from cm.tests.mocks.task_runner import RunTaskMock -from django.urls import reverse from init_db import init from rbac.upgrade.role import init_roles from rest_framework.response import Response from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND -from rest_framework.test import APIClient, APITestCase +from rest_framework.test import APITestCase from api_v2.tests.base import BaseAPITestCase @@ -74,21 +73,17 @@ def setUp(self) -> None: ) self.create_user() - self.unauthorized_client = APIClient() + self.unauthorized_client = self.client_class() self.unauthorized_client.login(username="test_user_username", password="test_user_password") def test_cluster_list_upgrades_success(self): - response: Response = self.client.get( - path=reverse(viewname="v2:upgrade-list", kwargs={"cluster_pk": self.cluster_1.pk}), - ) + response: Response = self.client.v2[self.cluster_1, "upgrades"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()), 6) def test_upgrade_visibility_from_edition_any_success(self): - response: Response = self.client.get( - path=reverse(viewname="v2:upgrade-list", kwargs={"cluster_pk": self.cluster_2.pk}), - ) + response: Response = self.client.v2[self.cluster_2, "upgrades"].get() self.assertEqual(response.status_code, HTTP_200_OK) @@ -96,11 +91,7 @@ def test_upgrade_visibility_from_edition_any_success(self): self.assertListEqual(response_upgrades, ["Upgrade 99.0"]) def test_cluster_upgrade_retrieve_success(self): - response: Response = self.client.get( - path=reverse( - viewname="v2:upgrade-detail", kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.cluster_upgrade.pk} - ), - ) + response: Response = self.client.v2[self.cluster_1, "upgrades", self.cluster_upgrade].get() self.assertEqual(response.status_code, HTTP_200_OK) upgrade_data = response.json() @@ -149,12 +140,7 @@ def test_cluster_upgrade_retrieve_success(self): ) def test_cluster_upgrade_retrieve_complex_success(self): - response: Response = self.client.get( - path=reverse( - viewname="v2:upgrade-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.upgrade_cluster_via_action_complex.pk}, - ), - ) + response: Response = self.client.v2[self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex].get() self.assertEqual(response.status_code, HTTP_200_OK) upgrade_data = response.json() @@ -180,12 +166,9 @@ def test_cluster_upgrade_run_success(self): Prototype.objects.update(license="accepted") with RunTaskMock() as run_task: - response: Response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.upgrade_cluster_via_action_simple.pk}, - ), - ) + response: Response = self.client.v2[ + self.cluster_1, "upgrades", self.upgrade_cluster_via_action_simple, "run" + ].post() self.assertEqual(response.status_code, HTTP_200_OK) data = response.json() @@ -210,11 +193,9 @@ def test_cluster_upgrade_run_complex_success(self): HostComponent.objects.create(cluster=self.cluster_1, service=self.service_1, component=component_2, host=host) with RunTaskMock() as run_task: - response: Response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.upgrade_cluster_via_action_complex.pk}, - ), + response: Response = self.client.v2[ + self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex, "run" + ].post( data={ "hostComponentMap": [{"hostId": host.pk, "componentId": component_1.pk}], "configuration": { @@ -252,11 +233,9 @@ def test_adcm_5246_cluster_upgrade_other_constraints_run_success(self): HostComponent.objects.create(cluster=self.cluster_1, service=self.service_1, component=component_1, host=host_2) with RunTaskMock() as run_task: - response: Response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.upgrade_cluster_via_action_complex.pk}, - ), + response: Response = self.client.v2[ + self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex, "run" + ].post( data={ "hostComponentMap": [ {"hostId": host_1.pk, "componentId": component_1.pk}, @@ -288,11 +267,9 @@ def test_adcm_4856_cluster_upgrade_run_complex_no_component_fail(self): self.add_host_to_cluster(cluster=self.cluster_1, host=host) with RunTaskMock() as run_task: - response: Response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.upgrade_cluster_via_action_complex.pk}, - ), + response: Response = self.client.v2[ + self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex, "run" + ].post( data={ "hostComponentMap": [{"hostId": host.pk, "componentId": 1000}], "configuration": { @@ -311,11 +288,9 @@ def test_adcm_4856_cluster_upgrade_run_complex_no_host_fail(self): component_1 = ServiceComponent.objects.get(service=self.service_1, prototype__name="component_1") with RunTaskMock() as run_task: - response: Response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.upgrade_cluster_via_action_complex.pk}, - ), + response: Response = self.client.v2[ + self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex, "run" + ].post( data={ "hostComponentMap": [{"hostId": 1000, "componentId": component_1.pk}], "configuration": { @@ -340,11 +315,9 @@ def test_adcm_4856_cluster_upgrade_run_complex_duplicated_hc_success(self): component_1 = ServiceComponent.objects.get(service=self.service_1, prototype__name="component_1") with RunTaskMock() as run_task: - response: Response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.upgrade_cluster_via_action_complex.pk}, - ), + response: Response = self.client.v2[ + self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex, "run" + ].post( data={ "hostComponentMap": [ {"hostId": host.pk, "componentId": component_1.pk}, @@ -380,11 +353,9 @@ def test_adcm_4856_cluster_upgrade_run_complex_several_entries_hc_success(self): component_1 = ServiceComponent.objects.get(service=self.service_1, prototype__name="component_1") with RunTaskMock() as run_task: - response: Response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.upgrade_cluster_via_action_complex.pk}, - ), + response: Response = self.client.v2[ + self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex, "run" + ].post( data={ "hostComponentMap": [ {"hostId": host_1.pk, "componentId": component_1.pk}, @@ -409,20 +380,13 @@ def test_adcm_4856_cluster_upgrade_run_complex_several_entries_hc_success(self): ) def test_provider_list_upgrades_success(self): - response: Response = self.client.get( - path=reverse(viewname="v2:upgrade-list", kwargs={"hostprovider_pk": self.provider.pk}), - ) + response: Response = self.client.v2[self.provider, "upgrades"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()), 3) def test_provider_upgrade_retrieve_success(self): - response: Response = self.client.get( - path=reverse( - viewname="v2:upgrade-detail", - kwargs={"hostprovider_pk": self.provider.pk, "pk": self.provider_upgrade.pk}, - ), - ) + response: Response = self.client.v2[self.provider, "upgrades", self.provider_upgrade].get() self.assertEqual(response.status_code, HTTP_200_OK) upgrade_data = response.json() self.assertTrue( @@ -437,12 +401,7 @@ def test_provider_upgrade_retrieve_success(self): self.assertFalse(upgrade_data["isAllowToTerminate"]) def test_provider_upgrade_retrieve_complex_success(self): - response: Response = self.client.get( - path=reverse( - viewname="v2:upgrade-detail", - kwargs={"hostprovider_pk": self.provider.pk, "pk": self.upgrade_host_via_action_complex.pk}, - ), - ) + response: Response = self.client.v2[self.provider, "upgrades", self.upgrade_host_via_action_complex].get() self.assertEqual(response.status_code, HTTP_200_OK) upgrade_data = response.json() @@ -460,12 +419,7 @@ def test_provider_upgrade_retrieve_complex_success(self): def test_provider_upgrade_run_success(self): with RunTaskMock() as run_task: - response: Response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={"hostprovider_pk": self.provider.pk, "pk": self.upgrade_host_via_action_simple.pk}, - ), - ) + response = self.client.v2[self.provider, "upgrades", self.upgrade_host_via_action_simple, "run"].post() self.assertEqual(response.status_code, HTTP_200_OK) data = response.json() @@ -479,104 +433,61 @@ def test_provider_upgrade_run_success(self): self.assertEqual(self.provider.prototype.version, self.upgrade_host_via_action_simple.action.prototype.version) def test_provider_upgrade_run_violate_constraint_fail(self): - response: Response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={"hostprovider_pk": self.provider.pk, "pk": self.cluster_upgrade.pk}, - ), - ) + response: Response = self.client.v2[self.provider, "upgrades", self.cluster_upgrade, "run"].post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_cluster_upgrade_run_violate_constraint_fail(self): - response: Response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.provider_upgrade.pk}, - ), - ) + response: Response = self.client.v2[self.cluster_1, "upgrades", self.provider_upgrade, "run"].post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_provider_upgrade_run_not_found_fail(self): - response: Response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={"hostprovider_pk": self.provider.pk, "pk": self.provider_upgrade.pk + 10}, - ), - ) + response = self.client.v2[self.provider, "upgrades", self.get_non_existent_pk(Upgrade), "run"].post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_cluster_upgrade_run_not_found_fail(self): - response: Response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.cluster_upgrade.pk + 10}, - ), - ) + response = self.client.v2[self.cluster_1, "upgrades", self.get_non_existent_pk(Upgrade), "run"].post() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_cluster_upgrade_retrieve_not_found_fail(self): - response: Response = self.client.get( - path=reverse( - viewname="v2:upgrade-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.cluster_upgrade.pk + 10}, - ), - ) + response: Response = self.client.v2[self.cluster_1, "upgrades", self.get_non_existent_pk(Upgrade)].get() + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_hostprovider_upgrade_retrieve_not_found_fail(self): - response: Response = self.client.get( - path=reverse( - viewname="v2:upgrade-detail", - kwargs={"hostprovider_pk": self.provider.pk, "pk": self.cluster_upgrade.pk + 100}, - ), - ) + response: Response = self.client.v2[self.provider, "upgrades", self.get_non_existent_pk(Upgrade)].get() + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_cluster_upgrade_hostcomponent_validation_fail(self): + endpoint = self.client.v2[self.cluster_1, "upgrades", self.upgrade_cluster_via_action_complex, "run"] for hc_data in ([{"hostId": 1}], [{"componentId": 4}], [{}]): with self.subTest(f"Pass host_component_map as {hc_data}"): - response: Response = self.client.post( - path=reverse( - viewname="v2:upgrade-run", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.upgrade_cluster_via_action_complex.pk}, - ), - data={"hostComponentMap": hc_data}, - ) + response: Response = endpoint.post(data={"hostComponentMap": hc_data}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) def test_cluster_list_unauthorized_fail(self) -> None: - response: Response = self.unauthorized_client.get( - path=reverse(viewname="v2:upgrade-list", kwargs={"cluster_pk": self.cluster_1.pk}), - ) + response: Response = self.unauthorized_client.v2[self.cluster_1, "upgrades"].get() + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_cluster_retrieve_unauthorized_fail(self): - response: Response = self.unauthorized_client.get( - path=reverse( - viewname="v2:upgrade-detail", - kwargs={"cluster_pk": self.cluster_1.pk, "pk": self.cluster_upgrade.pk}, - ), - ) + response: Response = self.unauthorized_client.v2[self.cluster_1, "upgrades", self.cluster_upgrade].get() + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_hostprovider_list_unauthorized_fail(self) -> None: - response: Response = self.unauthorized_client.get( - path=reverse(viewname="v2:upgrade-list", kwargs={"hostprovider_pk": self.provider.pk}), - ) + response: Response = self.unauthorized_client.v2[self.provider, "upgrades"].get() + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_hostprovider_retrieve_unauthorized_fail(self): - response: Response = self.unauthorized_client.get( - path=reverse( - viewname="v2:upgrade-detail", - kwargs={"hostprovider_pk": self.cluster_1.pk, "pk": self.provider_upgrade.pk}, - ), - ) + response: Response = self.unauthorized_client.v2[self.provider, "upgrades", self.provider_upgrade].get() + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_adcm_4703_retrieve_upgrade_with_variant_without_cluster_config_500(self) -> None: @@ -595,12 +506,7 @@ def test_adcm_4703_retrieve_upgrade_with_variant_without_cluster_config_500(self cluster=cluster, host=self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="second_host") ) - response = self.client.get( - path=reverse( - viewname="v2:upgrade-detail", - kwargs={"cluster_pk": cluster.pk, "pk": upgrade.pk}, - ), - ) + response = self.client.v2[cluster, "upgrades", upgrade].get() self.assertEqual(response.status_code, HTTP_200_OK) schema = response.json()["configuration"]["configSchema"] From 2af3ca06680bd37981323dfe33612aeb75b04c8f Mon Sep 17 00:00:00 2001 From: Pavel Nesterovkiy Date: Tue, 28 May 2024 10:20:44 +0000 Subject: [PATCH 136/208] feature/ADCM-5620 changed vite-plugin-svg-sprite to vite-plugin-svg-spriteR https://tracker.yandex.ru/ADCM-5620 --- adcm-web/app/package.json | 2 +- .../app/src/components/uikit/Icon/Icon.tsx | 10 +- adcm-web/app/vite.config.ts | 5 +- adcm-web/app/yarn.lock | 9074 ++++++++--------- 4 files changed, 4243 insertions(+), 4848 deletions(-) diff --git a/adcm-web/app/package.json b/adcm-web/app/package.json index 3c97b764ad..36d8fa45c6 100644 --- a/adcm-web/app/package.json +++ b/adcm-web/app/package.json @@ -89,7 +89,7 @@ "vite": "4.4.1", "vite-plugin-eslint": "1.8.1", "vite-plugin-react-remove-attributes": "1.0.3", - "vite-plugin-svg-sprite": "0.3.2", + "vite-plugin-svg-spriter": "1.0.0", "vite-plugin-svgr": "3.2.0", "vite-tsconfig-paths": "4.2.0" }, diff --git a/adcm-web/app/src/components/uikit/Icon/Icon.tsx b/adcm-web/app/src/components/uikit/Icon/Icon.tsx index 9f78b5bb10..b4bd50722f 100644 --- a/adcm-web/app/src/components/uikit/Icon/Icon.tsx +++ b/adcm-web/app/src/components/uikit/Icon/Icon.tsx @@ -1,11 +1,7 @@ import React from 'react'; import cn from 'classnames'; -import { allowIconsNames, IconsNames } from './sprite'; -import { Size } from '@uikit/types/size.types'; - -allowIconsNames.forEach(async (name) => { - await import(`./icons/${name}.svg`); -}); +import type { IconsNames } from './sprite'; +import type { Size } from '@uikit/types/size.types'; const iconSizesConfig: { [key in Size]: number } = { small: 12, @@ -24,7 +20,7 @@ const Icon = React.forwardRef(({ name, size = 'medium' return ( - + ); }); diff --git a/adcm-web/app/vite.config.ts b/adcm-web/app/vite.config.ts index 9f3c34976f..070323704c 100644 --- a/adcm-web/app/vite.config.ts +++ b/adcm-web/app/vite.config.ts @@ -1,7 +1,7 @@ import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; import tsconfigPaths from 'vite-tsconfig-paths'; -import createSvgSpritePlugin from 'vite-plugin-svg-sprite'; +import createSvgSpritePlugin from 'vite-plugin-svg-spriter'; import eslintPlugin from 'vite-plugin-eslint'; import svgr from 'vite-plugin-svgr'; //import VitePluginReactRemoveAttributes from 'vite-plugin-react-remove-attributes'; @@ -31,8 +31,7 @@ export default defineConfig(({ command, mode }) => { plugins: [ tsconfigPaths(), createSvgSpritePlugin({ - include: '**/icons/*.svg', - symbolId: 'icon-[name]', + svgFolder: './src/components/uikit/Icon/icons', }), svgr({ exclude: [/virtual:/, /node_modules/], diff --git a/adcm-web/app/yarn.lock b/adcm-web/app/yarn.lock index 35913f9b14..db1ccd280c 100644 --- a/adcm-web/app/yarn.lock +++ b/adcm-web/app/yarn.lock @@ -2,20 +2,20 @@ # Manual changes might be lost - proceed with caution! __metadata: - version: 6 - cacheKey: 8 + version: 8 + cacheKey: 10 "@aashutoshrathi/word-wrap@npm:^1.2.3": version: 1.2.6 resolution: "@aashutoshrathi/word-wrap@npm:1.2.6" - checksum: ada901b9e7c680d190f1d012c84217ce0063d8f5c5a7725bb91ec3c5ed99bb7572680eb2d2938a531ccbaec39a95422fcd8a6b4a13110c7d98dd75402f66a0cd + checksum: 6eebd12a5cd03cee38fcb915ef9f4ea557df6a06f642dfc7fe8eb4839eb5c9ca55a382f3604d52c14200b0c214c12af5e1f23d2a6d8e23ef2d016b105a9d6c0a languageName: node linkType: hard "@adobe/css-tools@npm:^4.3.2": version: 4.3.3 resolution: "@adobe/css-tools@npm:4.3.3" - checksum: d21f3786b84911fee59c995a146644a85c98692979097b26484ffa9e442fb1a92ccd68ce984e3e7cf8d5933c3560fbc0ad3e3cd1de50b9a723d1c012e793bbcb + checksum: 0e77057efb4e18182560855503066b75edca98671be327d3f8a7ae89ec3da6821e693114b55225909fca00d7e7ed8422f3d79d71fe95dd4d5df1f2026a9fda02 languageName: node linkType: hard @@ -23,9 +23,9 @@ __metadata: version: 2.2.1 resolution: "@ampproject/remapping@npm:2.2.1" dependencies: - "@jridgewell/gen-mapping": ^0.3.0 - "@jridgewell/trace-mapping": ^0.3.9 - checksum: 03c04fd526acc64a1f4df22651186f3e5ef0a9d6d6530ce4482ec9841269cf7a11dbb8af79237c282d721c5312024ff17529cd72cc4768c11e999b58e2302079 + "@jridgewell/gen-mapping": "npm:^0.3.0" + "@jridgewell/trace-mapping": "npm:^0.3.9" + checksum: e15fecbf3b54c988c8b4fdea8ef514ab482537e8a080b2978cc4b47ccca7140577ca7b65ad3322dcce65bc73ee6e5b90cbfe0bbd8c766dad04d5c62ec9634c42 languageName: node linkType: hard @@ -33,10 +33,10 @@ __metadata: version: 1.4.126 resolution: "@aw-web-design/x-default-browser@npm:1.4.126" dependencies: - default-browser-id: 3.0.0 + default-browser-id: "npm:3.0.0" bin: x-default-browser: bin/x-default-browser.js - checksum: f63b68a0ff41c8fe478b1b4822e169cac0d26c61b123c0400d5e16a8a5987732b85795aff16d6b21936f9c955f0d32bffbfc166890d3446f74a72a7a2c9633ea + checksum: f7111a6f00953f32d344a05c9a1bc1f22124dfc2696b2b7906ca856a9f845a282f272f603c997ebbb8a2d6b865664f46fda3bec1c480f040e21b815ff8ed3607 languageName: node linkType: hard @@ -44,16 +44,16 @@ __metadata: version: 7.23.5 resolution: "@babel/code-frame@npm:7.23.5" dependencies: - "@babel/highlight": ^7.23.4 - chalk: ^2.4.2 - checksum: d90981fdf56a2824a9b14d19a4c0e8db93633fd488c772624b4e83e0ceac6039a27cd298a247c3214faa952bf803ba23696172ae7e7235f3b97f43ba278c569a + "@babel/highlight": "npm:^7.23.4" + chalk: "npm:^2.4.2" + checksum: 44e58529c9d93083288dc9e649c553c5ba997475a7b0758cc3ddc4d77b8a7d985dbe78cc39c9bbc61f26d50af6da1ddf0a3427eae8cc222a9370619b671ed8f5 languageName: node linkType: hard "@babel/compat-data@npm:^7.22.6, @babel/compat-data@npm:^7.23.3, @babel/compat-data@npm:^7.23.5": version: 7.23.5 resolution: "@babel/compat-data@npm:7.23.5" - checksum: 06ce244cda5763295a0ea924728c09bae57d35713b675175227278896946f922a63edf803c322f855a3878323d48d0255a2a3023409d2a123483c8a69ebb4744 + checksum: 088f14f646ecbddd5ef89f120a60a1b3389a50a9705d44603dca77662707d0175a5e0e0da3943c3298f1907a4ab871468656fbbf74bb7842cd8b0686b2c19736 languageName: node linkType: hard @@ -61,22 +61,22 @@ __metadata: version: 7.23.9 resolution: "@babel/core@npm:7.23.9" dependencies: - "@ampproject/remapping": ^2.2.0 - "@babel/code-frame": ^7.23.5 - "@babel/generator": ^7.23.6 - "@babel/helper-compilation-targets": ^7.23.6 - "@babel/helper-module-transforms": ^7.23.3 - "@babel/helpers": ^7.23.9 - "@babel/parser": ^7.23.9 - "@babel/template": ^7.23.9 - "@babel/traverse": ^7.23.9 - "@babel/types": ^7.23.9 - convert-source-map: ^2.0.0 - debug: ^4.1.0 - gensync: ^1.0.0-beta.2 - json5: ^2.2.3 - semver: ^6.3.1 - checksum: 634a511f74db52a5f5a283c1121f25e2227b006c095b84a02a40a9213842489cd82dc7d61cdc74e10b5bcd9bb0a4e28bab47635b54c7e2256d47ab57356e2a76 + "@ampproject/remapping": "npm:^2.2.0" + "@babel/code-frame": "npm:^7.23.5" + "@babel/generator": "npm:^7.23.6" + "@babel/helper-compilation-targets": "npm:^7.23.6" + "@babel/helper-module-transforms": "npm:^7.23.3" + "@babel/helpers": "npm:^7.23.9" + "@babel/parser": "npm:^7.23.9" + "@babel/template": "npm:^7.23.9" + "@babel/traverse": "npm:^7.23.9" + "@babel/types": "npm:^7.23.9" + convert-source-map: "npm:^2.0.0" + debug: "npm:^4.1.0" + gensync: "npm:^1.0.0-beta.2" + json5: "npm:^2.2.3" + semver: "npm:^6.3.1" + checksum: 268cdbb86bef1b8ea5b1300f2f325e56a1740a5051360cb228ffeaa0f80282b6674f3a2b4d6466adb0691183759b88d4c37b4a4f77232c84a49ed771c84cdc27 languageName: node linkType: hard @@ -84,11 +84,11 @@ __metadata: version: 7.23.6 resolution: "@babel/generator@npm:7.23.6" dependencies: - "@babel/types": ^7.23.6 - "@jridgewell/gen-mapping": ^0.3.2 - "@jridgewell/trace-mapping": ^0.3.17 - jsesc: ^2.5.1 - checksum: 1a1a1c4eac210f174cd108d479464d053930a812798e09fee069377de39a893422df5b5b146199ead7239ae6d3a04697b45fc9ac6e38e0f6b76374390f91fc6c + "@babel/types": "npm:^7.23.6" + "@jridgewell/gen-mapping": "npm:^0.3.2" + "@jridgewell/trace-mapping": "npm:^0.3.17" + jsesc: "npm:^2.5.1" + checksum: 864090d5122c0aa3074471fd7b79d8a880c1468480cbd28925020a3dcc7eb6e98bedcdb38983df299c12b44b166e30915b8085a7bc126e68fa7e2aadc7bd1ac5 languageName: node linkType: hard @@ -96,7 +96,7 @@ __metadata: version: 7.22.5 resolution: "@babel/helper-annotate-as-pure@npm:7.22.5" dependencies: - "@babel/types": ^7.22.5 + "@babel/types": "npm:^7.22.5" checksum: 53da330f1835c46f26b7bf4da31f7a496dee9fd8696cca12366b94ba19d97421ce519a74a837f687749318f94d1a37f8d1abcbf35e8ed22c32d16373b2f6198d languageName: node linkType: hard @@ -105,7 +105,7 @@ __metadata: version: 7.22.15 resolution: "@babel/helper-builder-binary-assignment-operator-visitor@npm:7.22.15" dependencies: - "@babel/types": ^7.22.15 + "@babel/types": "npm:^7.22.15" checksum: 639c697a1c729f9fafa2dd4c9af2e18568190299b5907bd4c2d0bc818fcbd1e83ffeecc2af24327a7faa7ac4c34edd9d7940510a5e66296c19bad17001cf5c7a languageName: node linkType: hard @@ -114,12 +114,12 @@ __metadata: version: 7.23.6 resolution: "@babel/helper-compilation-targets@npm:7.23.6" dependencies: - "@babel/compat-data": ^7.23.5 - "@babel/helper-validator-option": ^7.23.5 - browserslist: ^4.22.2 - lru-cache: ^5.1.1 - semver: ^6.3.1 - checksum: c630b98d4527ac8fe2c58d9a06e785dfb2b73ec71b7c4f2ddf90f814b5f75b547f3c015f110a010fd31f76e3864daaf09f3adcd2f6acdbfb18a8de3a48717590 + "@babel/compat-data": "npm:^7.23.5" + "@babel/helper-validator-option": "npm:^7.23.5" + browserslist: "npm:^4.22.2" + lru-cache: "npm:^5.1.1" + semver: "npm:^6.3.1" + checksum: 05595cd73087ddcd81b82d2f3297aac0c0422858dfdded43d304786cf680ec33e846e2317e6992d2c964ee61d93945cbf1fa8ec80b55aee5bfb159227fb02cb9 languageName: node linkType: hard @@ -127,18 +127,18 @@ __metadata: version: 7.23.10 resolution: "@babel/helper-create-class-features-plugin@npm:7.23.10" dependencies: - "@babel/helper-annotate-as-pure": ^7.22.5 - "@babel/helper-environment-visitor": ^7.22.20 - "@babel/helper-function-name": ^7.23.0 - "@babel/helper-member-expression-to-functions": ^7.23.0 - "@babel/helper-optimise-call-expression": ^7.22.5 - "@babel/helper-replace-supers": ^7.22.20 - "@babel/helper-skip-transparent-expression-wrappers": ^7.22.5 - "@babel/helper-split-export-declaration": ^7.22.6 - semver: ^6.3.1 + "@babel/helper-annotate-as-pure": "npm:^7.22.5" + "@babel/helper-environment-visitor": "npm:^7.22.20" + "@babel/helper-function-name": "npm:^7.23.0" + "@babel/helper-member-expression-to-functions": "npm:^7.23.0" + "@babel/helper-optimise-call-expression": "npm:^7.22.5" + "@babel/helper-replace-supers": "npm:^7.22.20" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.22.5" + "@babel/helper-split-export-declaration": "npm:^7.22.6" + semver: "npm:^6.3.1" peerDependencies: "@babel/core": ^7.0.0 - checksum: ff0730c21f0e73b9e314701bca6568bb5885dff2aa3c32b1e2e3d18ed2818f56851b9ffdbe2e8008c9bb94b265a1443883ae4c1ca5dde278ce71ac4218006d68 + checksum: 8b9f02526eeb03ef1d2bc89e3554377ae966b33a74078ab1f88168dfa725dc206ea5ecf4cf417c3651d8a6b3c70204f6939a9aa0401be3d0d32ddbf6024ea3c7 languageName: node linkType: hard @@ -146,12 +146,12 @@ __metadata: version: 7.22.15 resolution: "@babel/helper-create-regexp-features-plugin@npm:7.22.15" dependencies: - "@babel/helper-annotate-as-pure": ^7.22.5 - regexpu-core: ^5.3.1 - semver: ^6.3.1 + "@babel/helper-annotate-as-pure": "npm:^7.22.5" + regexpu-core: "npm:^5.3.1" + semver: "npm:^6.3.1" peerDependencies: "@babel/core": ^7.0.0 - checksum: 0243b8d4854f1dc8861b1029a46d3f6393ad72f366a5a08e36a4648aa682044f06da4c6e87a456260e1e1b33c999f898ba591a0760842c1387bcc93fbf2151a6 + checksum: 886b675e82f1327b4f7a2c69a68eefdb5dbb0b9d4762c2d4f42a694960a9ccf61e1a3bcad601efd92c110033eb1a944fcd1e5cac188aa6b2e2076b541e210e20 languageName: node linkType: hard @@ -159,14 +159,14 @@ __metadata: version: 0.5.0 resolution: "@babel/helper-define-polyfill-provider@npm:0.5.0" dependencies: - "@babel/helper-compilation-targets": ^7.22.6 - "@babel/helper-plugin-utils": ^7.22.5 - debug: ^4.1.1 - lodash.debounce: ^4.0.8 - resolve: ^1.14.2 + "@babel/helper-compilation-targets": "npm:^7.22.6" + "@babel/helper-plugin-utils": "npm:^7.22.5" + debug: "npm:^4.1.1" + lodash.debounce: "npm:^4.0.8" + resolve: "npm:^1.14.2" peerDependencies: "@babel/core": ^7.4.0 || ^8.0.0-0 <8.0.0 - checksum: d24626b819d3875cb65189d761004e9230f2b3fb60542525c4785616f4b2366741369235a864b744f54beb26d625ae4b0af0c9bb3306b61bf4fccb61e0620020 + checksum: f849e816ec4b182a3e8fa8e09ff016f88bb95259cd6b2190b815c48f83c3d3b68e973a8ec72acc5086bfe93705cbd46ec089c06476421d858597780e42235a03 languageName: node linkType: hard @@ -181,9 +181,9 @@ __metadata: version: 7.23.0 resolution: "@babel/helper-function-name@npm:7.23.0" dependencies: - "@babel/template": ^7.22.15 - "@babel/types": ^7.23.0 - checksum: e44542257b2d4634a1f979244eb2a4ad8e6d75eb6761b4cfceb56b562f7db150d134bc538c8e6adca3783e3bc31be949071527aa8e3aab7867d1ad2d84a26e10 + "@babel/template": "npm:^7.22.15" + "@babel/types": "npm:^7.23.0" + checksum: 7b2ae024cd7a09f19817daf99e0153b3bf2bc4ab344e197e8d13623d5e36117ed0b110914bc248faa64e8ccd3e97971ec7b41cc6fd6163a2b980220c58dcdf6d languageName: node linkType: hard @@ -191,7 +191,7 @@ __metadata: version: 7.22.5 resolution: "@babel/helper-hoist-variables@npm:7.22.5" dependencies: - "@babel/types": ^7.22.5 + "@babel/types": "npm:^7.22.5" checksum: 394ca191b4ac908a76e7c50ab52102669efe3a1c277033e49467913c7ed6f7c64d7eacbeabf3bed39ea1f41731e22993f763b1edce0f74ff8563fd1f380d92cc languageName: node linkType: hard @@ -200,8 +200,8 @@ __metadata: version: 7.23.0 resolution: "@babel/helper-member-expression-to-functions@npm:7.23.0" dependencies: - "@babel/types": ^7.23.0 - checksum: 494659361370c979ada711ca685e2efe9460683c36db1b283b446122596602c901e291e09f2f980ecedfe6e0f2bd5386cb59768285446530df10c14df1024e75 + "@babel/types": "npm:^7.23.0" + checksum: 325feb6e200478c8cd6e10433fabe993a7d3315cc1a2a457e45514a5f95a73dff4c69bea04cc2daea0ffe72d8ed85d504b3f00b2e0767b7d4f5ae25fec9b35b2 languageName: node linkType: hard @@ -209,8 +209,8 @@ __metadata: version: 7.22.15 resolution: "@babel/helper-module-imports@npm:7.22.15" dependencies: - "@babel/types": ^7.22.15 - checksum: ecd7e457df0a46f889228f943ef9b4a47d485d82e030676767e6a2fdcbdaa63594d8124d4b55fd160b41c201025aec01fc27580352b1c87a37c9c6f33d116702 + "@babel/types": "npm:^7.22.15" + checksum: 5ecf9345a73b80c28677cfbe674b9f567bb0d079e37dcba9055e36cb337db24ae71992a58e1affa9d14a60d3c69907d30fe1f80aea105184501750a58d15c81c languageName: node linkType: hard @@ -218,14 +218,14 @@ __metadata: version: 7.23.3 resolution: "@babel/helper-module-transforms@npm:7.23.3" dependencies: - "@babel/helper-environment-visitor": ^7.22.20 - "@babel/helper-module-imports": ^7.22.15 - "@babel/helper-simple-access": ^7.22.5 - "@babel/helper-split-export-declaration": ^7.22.6 - "@babel/helper-validator-identifier": ^7.22.20 + "@babel/helper-environment-visitor": "npm:^7.22.20" + "@babel/helper-module-imports": "npm:^7.22.15" + "@babel/helper-simple-access": "npm:^7.22.5" + "@babel/helper-split-export-declaration": "npm:^7.22.6" + "@babel/helper-validator-identifier": "npm:^7.22.20" peerDependencies: "@babel/core": ^7.0.0 - checksum: 5d0895cfba0e16ae16f3aa92fee108517023ad89a855289c4eb1d46f7aef4519adf8e6f971e1d55ac20c5461610e17213f1144097a8f932e768a9132e2278d71 + checksum: 583fa580f8e50e6f45c4f46aa76a8e49c2528deb84e25f634d66461b9a0e2420e13979b0a607b67aef67eaf8db8668eb9edc038b4514b16e3879fe09e8fd294b languageName: node linkType: hard @@ -233,7 +233,7 @@ __metadata: version: 7.22.5 resolution: "@babel/helper-optimise-call-expression@npm:7.22.5" dependencies: - "@babel/types": ^7.22.5 + "@babel/types": "npm:^7.22.5" checksum: c70ef6cc6b6ed32eeeec4482127e8be5451d0e5282d5495d5d569d39eb04d7f1d66ec99b327f45d1d5842a9ad8c22d48567e93fc502003a47de78d122e355f7c languageName: node linkType: hard @@ -241,7 +241,7 @@ __metadata: "@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.18.6, @babel/helper-plugin-utils@npm:^7.22.5, @babel/helper-plugin-utils@npm:^7.8.0, @babel/helper-plugin-utils@npm:^7.8.3": version: 7.22.5 resolution: "@babel/helper-plugin-utils@npm:7.22.5" - checksum: c0fc7227076b6041acd2f0e818145d2e8c41968cc52fb5ca70eed48e21b8fe6dd88a0a91cbddf4951e33647336eb5ae184747ca706817ca3bef5e9e905151ff5 + checksum: ab220db218089a2aadd0582f5833fd17fa300245999f5f8784b10f5a75267c4e808592284a29438a0da365e702f05acb369f99e1c915c02f9f9210ec60eab8ea languageName: node linkType: hard @@ -249,9 +249,9 @@ __metadata: version: 7.22.20 resolution: "@babel/helper-remap-async-to-generator@npm:7.22.20" dependencies: - "@babel/helper-annotate-as-pure": ^7.22.5 - "@babel/helper-environment-visitor": ^7.22.20 - "@babel/helper-wrap-function": ^7.22.20 + "@babel/helper-annotate-as-pure": "npm:^7.22.5" + "@babel/helper-environment-visitor": "npm:^7.22.20" + "@babel/helper-wrap-function": "npm:^7.22.20" peerDependencies: "@babel/core": ^7.0.0 checksum: 2fe6300a6f1b58211dffa0aed1b45d4958506d096543663dba83bd9251fe8d670fa909143a65b45e72acb49e7e20fbdb73eae315d9ddaced467948c3329986e7 @@ -262,12 +262,12 @@ __metadata: version: 7.22.20 resolution: "@babel/helper-replace-supers@npm:7.22.20" dependencies: - "@babel/helper-environment-visitor": ^7.22.20 - "@babel/helper-member-expression-to-functions": ^7.22.15 - "@babel/helper-optimise-call-expression": ^7.22.5 + "@babel/helper-environment-visitor": "npm:^7.22.20" + "@babel/helper-member-expression-to-functions": "npm:^7.22.15" + "@babel/helper-optimise-call-expression": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0 - checksum: a0008332e24daedea2e9498733e3c39b389d6d4512637e000f96f62b797e702ee24a407ccbcd7a236a551590a38f31282829a8ef35c50a3c0457d88218cae639 + checksum: 617666f57b0f94a2f430ee66b67c8f6fa94d4c22400f622947580d8f3638ea34b71280af59599ed4afbb54ae6e2bdd4f9083fe0e341184a4bb0bd26ef58d3017 languageName: node linkType: hard @@ -275,8 +275,8 @@ __metadata: version: 7.22.5 resolution: "@babel/helper-simple-access@npm:7.22.5" dependencies: - "@babel/types": ^7.22.5 - checksum: fe9686714caf7d70aedb46c3cce090f8b915b206e09225f1e4dbc416786c2fdbbee40b38b23c268b7ccef749dd2db35f255338fb4f2444429874d900dede5ad2 + "@babel/types": "npm:^7.22.5" + checksum: 7d5430eecf880937c27d1aed14245003bd1c7383ae07d652b3932f450f60bfcf8f2c1270c593ab063add185108d26198c69d1aca0e6fb7c6fdada4bcf72ab5b7 languageName: node linkType: hard @@ -284,7 +284,7 @@ __metadata: version: 7.22.5 resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.22.5" dependencies: - "@babel/types": ^7.22.5 + "@babel/types": "npm:^7.22.5" checksum: 1012ef2295eb12dc073f2b9edf3425661e9b8432a3387e62a8bc27c42963f1f216ab3124228015c748770b2257b4f1fda882ca8fa34c0bf485e929ae5bc45244 languageName: node linkType: hard @@ -293,7 +293,7 @@ __metadata: version: 7.22.6 resolution: "@babel/helper-split-export-declaration@npm:7.22.6" dependencies: - "@babel/types": ^7.22.5 + "@babel/types": "npm:^7.22.5" checksum: e141cace583b19d9195f9c2b8e17a3ae913b7ee9b8120246d0f9ca349ca6f03cb2c001fd5ec57488c544347c0bb584afec66c936511e447fd20a360e591ac921 languageName: node linkType: hard @@ -301,14 +301,14 @@ __metadata: "@babel/helper-string-parser@npm:^7.23.4": version: 7.23.4 resolution: "@babel/helper-string-parser@npm:7.23.4" - checksum: c0641144cf1a7e7dc93f3d5f16d5327465b6cf5d036b48be61ecba41e1eece161b48f46b7f960951b67f8c3533ce506b16dece576baef4d8b3b49f8c65410f90 + checksum: c352082474a2ee1d2b812bd116a56b2e8b38065df9678a32a535f151ec6f58e54633cc778778374f10544b930703cca6ddf998803888a636afa27e2658068a9c languageName: node linkType: hard "@babel/helper-validator-identifier@npm:^7.22.20": version: 7.22.20 resolution: "@babel/helper-validator-identifier@npm:7.22.20" - checksum: 136412784d9428266bcdd4d91c32bcf9ff0e8d25534a9d94b044f77fe76bc50f941a90319b05aafd1ec04f7d127cd57a179a3716009ff7f3412ef835ada95bdc + checksum: df882d2675101df2d507b95b195ca2f86a3ef28cb711c84f37e79ca23178e13b9f0d8b522774211f51e40168bf5142be4c1c9776a150cddb61a0d5bf3e95750b languageName: node linkType: hard @@ -323,10 +323,10 @@ __metadata: version: 7.22.20 resolution: "@babel/helper-wrap-function@npm:7.22.20" dependencies: - "@babel/helper-function-name": ^7.22.5 - "@babel/template": ^7.22.15 - "@babel/types": ^7.22.19 - checksum: 221ed9b5572612aeb571e4ce6a256f2dee85b3c9536f1dd5e611b0255e5f59a3d0ec392d8d46d4152149156a8109f92f20379b1d6d36abb613176e0e33f05fca + "@babel/helper-function-name": "npm:^7.22.5" + "@babel/template": "npm:^7.22.15" + "@babel/types": "npm:^7.22.19" + checksum: b22e4666dec3d401bdf8ebd01d448bb3733617dae5aa6fbd1b684a22a35653cca832edd876529fd139577713b44fb89b4f5e52b7315ab218620f78b8a8ae23de languageName: node linkType: hard @@ -334,10 +334,10 @@ __metadata: version: 7.23.9 resolution: "@babel/helpers@npm:7.23.9" dependencies: - "@babel/template": ^7.23.9 - "@babel/traverse": ^7.23.9 - "@babel/types": ^7.23.9 - checksum: 2678231192c0471dbc2fc403fb19456cc46b1afefcfebf6bc0f48b2e938fdb0fef2e0fe90c8c8ae1f021dae5012b700372e4b5d15867f1d7764616532e4a6324 + "@babel/template": "npm:^7.23.9" + "@babel/traverse": "npm:^7.23.9" + "@babel/types": "npm:^7.23.9" + checksum: dd56daac8bbd7ed174bb00fd185926fd449e591d9a00edaceb7ac6edbdd7a8db57e2cb365b4fafda382201752789ced2f7ae010f667eab0f198a4571cda4d2c5 languageName: node linkType: hard @@ -345,10 +345,10 @@ __metadata: version: 7.23.4 resolution: "@babel/highlight@npm:7.23.4" dependencies: - "@babel/helper-validator-identifier": ^7.22.20 - chalk: ^2.4.2 - js-tokens: ^4.0.0 - checksum: 643acecdc235f87d925979a979b539a5d7d1f31ae7db8d89047269082694122d11aa85351304c9c978ceeb6d250591ccadb06c366f358ccee08bb9c122476b89 + "@babel/helper-validator-identifier": "npm:^7.22.20" + chalk: "npm:^2.4.2" + js-tokens: "npm:^4.0.0" + checksum: 62fef9b5bcea7131df4626d009029b1ae85332042f4648a4ce6e740c3fd23112603c740c45575caec62f260c96b11054d3be5987f4981a5479793579c3aac71f languageName: node linkType: hard @@ -357,7 +357,7 @@ __metadata: resolution: "@babel/parser@npm:7.23.9" bin: parser: ./bin/babel-parser.js - checksum: e7cd4960ac8671774e13803349da88d512f9292d7baa952173260d3e8f15620a28a3701f14f709d769209022f9e7b79965256b8be204fc550cfe783cdcabe7c7 + checksum: 727a7a807100f6a26df859e2f009c4ddbd0d3363287b45daa50bd082ccd0d431d0c4d0e610a91f806e04a1918726cd0f5a0592c9b902a815337feed12e1cafd9 languageName: node linkType: hard @@ -365,7 +365,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0 checksum: ddbaf2c396b7780f15e80ee01d6dd790db076985f3dfeb6527d1a8d4cacf370e49250396a3aa005b2c40233cac214a106232f83703d5e8491848bde273938232 @@ -376,9 +376,9 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/helper-skip-transparent-expression-wrappers": ^7.22.5 - "@babel/plugin-transform-optional-chaining": ^7.23.3 + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.22.5" + "@babel/plugin-transform-optional-chaining": "npm:^7.23.3" peerDependencies: "@babel/core": ^7.13.0 checksum: 434b9d710ae856fa1a456678cc304fbc93915af86d581ee316e077af746a709a741ea39d7e1d4f5b98861b629cc7e87f002d3138f5e836775632466d4c74aef2 @@ -389,11 +389,11 @@ __metadata: version: 7.23.7 resolution: "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@npm:7.23.7" dependencies: - "@babel/helper-environment-visitor": ^7.22.20 - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-environment-visitor": "npm:^7.22.20" + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0 - checksum: f88e400b548202a6f8c5dfd25bc4949a13ea1ccb64a170d7dea4deaa655a0fcb001d3fd61c35e1ad9c09a3d5f0d43f783400425471fe6d660ccaf33dabea9aba + checksum: 3b0c9554cd0048e6e7341d7b92f29d400dbc6a5a4fc2f86dbed881d32e02ece9b55bc520387bae2eac22a5ab38a0b205c29b52b181294d99b4dd75e27309b548 languageName: node linkType: hard @@ -402,7 +402,7 @@ __metadata: resolution: "@babel/plugin-proposal-private-property-in-object@npm:7.21.0-placeholder-for-preset-env.2" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: d97745d098b835d55033ff3a7fb2b895b9c5295b08a5759e4f20df325aa385a3e0bc9bd5ad8f2ec554a44d4e6525acfc257b8c5848a1345cb40f26a30e277e91 + checksum: fab70f399aa869275690ec6c7cedb4ef361d4e8b6f55c3d7b04bfee61d52fb93c87cec2c65d73cddbaca89fb8ef5ec0921fce675c9169d9d51f18305ab34e78a languageName: node linkType: hard @@ -410,7 +410,7 @@ __metadata: version: 7.8.4 resolution: "@babel/plugin-syntax-async-generators@npm:7.8.4" dependencies: - "@babel/helper-plugin-utils": ^7.8.0 + "@babel/helper-plugin-utils": "npm:^7.8.0" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 7ed1c1d9b9e5b64ef028ea5e755c0be2d4e5e4e3d6cf7df757b9a8c4cfa4193d268176d0f1f7fbecdda6fe722885c7fda681f480f3741d8a2d26854736f05367 @@ -421,7 +421,7 @@ __metadata: version: 7.8.3 resolution: "@babel/plugin-syntax-bigint@npm:7.8.3" dependencies: - "@babel/helper-plugin-utils": ^7.8.0 + "@babel/helper-plugin-utils": "npm:^7.8.0" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 3a10849d83e47aec50f367a9e56a6b22d662ddce643334b087f9828f4c3dd73bdc5909aaeabe123fed78515767f9ca43498a0e621c438d1cd2802d7fae3c9648 @@ -432,7 +432,7 @@ __metadata: version: 7.12.13 resolution: "@babel/plugin-syntax-class-properties@npm:7.12.13" dependencies: - "@babel/helper-plugin-utils": ^7.12.13 + "@babel/helper-plugin-utils": "npm:^7.12.13" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 24f34b196d6342f28d4bad303612d7ff566ab0a013ce89e775d98d6f832969462e7235f3e7eaf17678a533d4be0ba45d3ae34ab4e5a9dcbda5d98d49e5efa2fc @@ -443,7 +443,7 @@ __metadata: version: 7.14.5 resolution: "@babel/plugin-syntax-class-static-block@npm:7.14.5" dependencies: - "@babel/helper-plugin-utils": ^7.14.5 + "@babel/helper-plugin-utils": "npm:^7.14.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 3e80814b5b6d4fe17826093918680a351c2d34398a914ce6e55d8083d72a9bdde4fbaf6a2dcea0e23a03de26dc2917ae3efd603d27099e2b98380345703bf948 @@ -454,7 +454,7 @@ __metadata: version: 7.8.3 resolution: "@babel/plugin-syntax-dynamic-import@npm:7.8.3" dependencies: - "@babel/helper-plugin-utils": ^7.8.0 + "@babel/helper-plugin-utils": "npm:^7.8.0" peerDependencies: "@babel/core": ^7.0.0-0 checksum: ce307af83cf433d4ec42932329fad25fa73138ab39c7436882ea28742e1c0066626d224e0ad2988724c82644e41601cef607b36194f695cb78a1fcdc959637bd @@ -465,7 +465,7 @@ __metadata: version: 7.8.3 resolution: "@babel/plugin-syntax-export-namespace-from@npm:7.8.3" dependencies: - "@babel/helper-plugin-utils": ^7.8.3 + "@babel/helper-plugin-utils": "npm:^7.8.3" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 85740478be5b0de185228e7814451d74ab8ce0a26fcca7613955262a26e99e8e15e9da58f60c754b84515d4c679b590dbd3f2148f0f58025f4ae706f1c5a5d4a @@ -476,7 +476,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-syntax-flow@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: c6e6f355d6ace5f4a9e7bb19f1fed2398aeb9b62c4c671a189d81b124f9f5bb77c4225b6e85e19339268c60a021c1e49104e450375de5e6bb70612190d9678af @@ -487,7 +487,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-syntax-import-assertions@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 883e6b35b2da205138caab832d54505271a3fee3fc1e8dc0894502434fc2b5d517cbe93bbfbfef8068a0fb6ec48ebc9eef3f605200a489065ba43d8cddc1c9a7 @@ -498,7 +498,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-syntax-import-attributes@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 9aed7661ffb920ca75df9f494757466ca92744e43072e0848d87fa4aa61a3f2ee5a22198ac1959856c036434b5614a8f46f1fb70298835dbe28220cdd1d4c11e @@ -509,7 +509,7 @@ __metadata: version: 7.10.4 resolution: "@babel/plugin-syntax-import-meta@npm:7.10.4" dependencies: - "@babel/helper-plugin-utils": ^7.10.4 + "@babel/helper-plugin-utils": "npm:^7.10.4" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 166ac1125d10b9c0c430e4156249a13858c0366d38844883d75d27389621ebe651115cb2ceb6dc011534d5055719fa1727b59f39e1ab3ca97820eef3dcab5b9b @@ -520,7 +520,7 @@ __metadata: version: 7.8.3 resolution: "@babel/plugin-syntax-json-strings@npm:7.8.3" dependencies: - "@babel/helper-plugin-utils": ^7.8.0 + "@babel/helper-plugin-utils": "npm:^7.8.0" peerDependencies: "@babel/core": ^7.0.0-0 checksum: bf5aea1f3188c9a507e16efe030efb996853ca3cadd6512c51db7233cc58f3ac89ff8c6bdfb01d30843b161cfe7d321e1bf28da82f7ab8d7e6bc5464666f354a @@ -531,7 +531,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-syntax-jsx@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 89037694314a74e7f0e7a9c8d3793af5bf6b23d80950c29b360db1c66859d67f60711ea437e70ad6b5b4b29affe17eababda841b6c01107c2b638e0493bafb4e @@ -542,7 +542,7 @@ __metadata: version: 7.10.4 resolution: "@babel/plugin-syntax-logical-assignment-operators@npm:7.10.4" dependencies: - "@babel/helper-plugin-utils": ^7.10.4 + "@babel/helper-plugin-utils": "npm:^7.10.4" peerDependencies: "@babel/core": ^7.0.0-0 checksum: aff33577037e34e515911255cdbb1fd39efee33658aa00b8a5fd3a4b903585112d037cce1cc9e4632f0487dc554486106b79ccd5ea63a2e00df4363f6d4ff886 @@ -553,7 +553,7 @@ __metadata: version: 7.8.3 resolution: "@babel/plugin-syntax-nullish-coalescing-operator@npm:7.8.3" dependencies: - "@babel/helper-plugin-utils": ^7.8.0 + "@babel/helper-plugin-utils": "npm:^7.8.0" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 87aca4918916020d1fedba54c0e232de408df2644a425d153be368313fdde40d96088feed6c4e5ab72aac89be5d07fef2ddf329a15109c5eb65df006bf2580d1 @@ -564,7 +564,7 @@ __metadata: version: 7.10.4 resolution: "@babel/plugin-syntax-numeric-separator@npm:7.10.4" dependencies: - "@babel/helper-plugin-utils": ^7.10.4 + "@babel/helper-plugin-utils": "npm:^7.10.4" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 01ec5547bd0497f76cc903ff4d6b02abc8c05f301c88d2622b6d834e33a5651aa7c7a3d80d8d57656a4588f7276eba357f6b7e006482f5b564b7a6488de493a1 @@ -575,7 +575,7 @@ __metadata: version: 7.8.3 resolution: "@babel/plugin-syntax-object-rest-spread@npm:7.8.3" dependencies: - "@babel/helper-plugin-utils": ^7.8.0 + "@babel/helper-plugin-utils": "npm:^7.8.0" peerDependencies: "@babel/core": ^7.0.0-0 checksum: fddcf581a57f77e80eb6b981b10658421bc321ba5f0a5b754118c6a92a5448f12a0c336f77b8abf734841e102e5126d69110a306eadb03ca3e1547cab31f5cbf @@ -586,7 +586,7 @@ __metadata: version: 7.8.3 resolution: "@babel/plugin-syntax-optional-catch-binding@npm:7.8.3" dependencies: - "@babel/helper-plugin-utils": ^7.8.0 + "@babel/helper-plugin-utils": "npm:^7.8.0" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 910d90e72bc90ea1ce698e89c1027fed8845212d5ab588e35ef91f13b93143845f94e2539d831dc8d8ededc14ec02f04f7bd6a8179edd43a326c784e7ed7f0b9 @@ -597,7 +597,7 @@ __metadata: version: 7.8.3 resolution: "@babel/plugin-syntax-optional-chaining@npm:7.8.3" dependencies: - "@babel/helper-plugin-utils": ^7.8.0 + "@babel/helper-plugin-utils": "npm:^7.8.0" peerDependencies: "@babel/core": ^7.0.0-0 checksum: eef94d53a1453361553c1f98b68d17782861a04a392840341bc91780838dd4e695209c783631cf0de14c635758beafb6a3a65399846ffa4386bff90639347f30 @@ -608,7 +608,7 @@ __metadata: version: 7.14.5 resolution: "@babel/plugin-syntax-private-property-in-object@npm:7.14.5" dependencies: - "@babel/helper-plugin-utils": ^7.14.5 + "@babel/helper-plugin-utils": "npm:^7.14.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: b317174783e6e96029b743ccff2a67d63d38756876e7e5d0ba53a322e38d9ca452c13354a57de1ad476b4c066dbae699e0ca157441da611117a47af88985ecda @@ -619,7 +619,7 @@ __metadata: version: 7.14.5 resolution: "@babel/plugin-syntax-top-level-await@npm:7.14.5" dependencies: - "@babel/helper-plugin-utils": ^7.14.5 + "@babel/helper-plugin-utils": "npm:^7.14.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: bbd1a56b095be7820029b209677b194db9b1d26691fe999856462e66b25b281f031f3dfd91b1619e9dcf95bebe336211833b854d0fb8780d618e35667c2d0d7e @@ -630,7 +630,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-syntax-typescript@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: abfad3a19290d258b028e285a1f34c9b8a0cbe46ef79eafed4ed7ffce11b5d0720b5e536c82f91cbd8442cde35a3dd8e861fa70366d87ff06fdc0d4756e30876 @@ -641,8 +641,8 @@ __metadata: version: 7.18.6 resolution: "@babel/plugin-syntax-unicode-sets-regex@npm:7.18.6" dependencies: - "@babel/helper-create-regexp-features-plugin": ^7.18.6 - "@babel/helper-plugin-utils": ^7.18.6 + "@babel/helper-create-regexp-features-plugin": "npm:^7.18.6" + "@babel/helper-plugin-utils": "npm:^7.18.6" peerDependencies: "@babel/core": ^7.0.0 checksum: a651d700fe63ff0ddfd7186f4ebc24447ca734f114433139e3c027bc94a900d013cf1ef2e2db8430425ba542e39ae160c3b05f06b59fd4656273a3df97679e9c @@ -653,7 +653,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-arrow-functions@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 1e99118176e5366c2636064d09477016ab5272b2a92e78b8edb571d20bc3eaa881789a905b20042942c3c2d04efc530726cf703f937226db5ebc495f5d067e66 @@ -664,10 +664,10 @@ __metadata: version: 7.23.9 resolution: "@babel/plugin-transform-async-generator-functions@npm:7.23.9" dependencies: - "@babel/helper-environment-visitor": ^7.22.20 - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/helper-remap-async-to-generator": ^7.22.20 - "@babel/plugin-syntax-async-generators": ^7.8.4 + "@babel/helper-environment-visitor": "npm:^7.22.20" + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/helper-remap-async-to-generator": "npm:^7.22.20" + "@babel/plugin-syntax-async-generators": "npm:^7.8.4" peerDependencies: "@babel/core": ^7.0.0-0 checksum: d402494087a6b803803eb5ab46b837aab100a04c4c5148e38bfa943ea1bbfc1ecfb340f1ced68972564312d3580f550c125f452372e77607a558fbbaf98c31c0 @@ -678,9 +678,9 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-async-to-generator@npm:7.23.3" dependencies: - "@babel/helper-module-imports": ^7.22.15 - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/helper-remap-async-to-generator": ^7.22.20 + "@babel/helper-module-imports": "npm:^7.22.15" + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/helper-remap-async-to-generator": "npm:^7.22.20" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 2e9d9795d4b3b3d8090332104e37061c677f29a1ce65bcbda4099a32d243e5d9520270a44bbabf0fb1fb40d463bd937685b1a1042e646979086c546d55319c3c @@ -691,7 +691,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-block-scoped-functions@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: e63b16d94ee5f4d917e669da3db5ea53d1e7e79141a2ec873c1e644678cdafe98daa556d0d359963c827863d6b3665d23d4938a94a4c5053a1619c4ebd01d020 @@ -702,10 +702,10 @@ __metadata: version: 7.23.4 resolution: "@babel/plugin-transform-block-scoping@npm:7.23.4" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: fc4b2100dd9f2c47d694b4b35ae8153214ccb4e24ef545c259a9db17211b18b6a430f22799b56db8f6844deaeaa201af45a03331d0c80cc28b0c4e3c814570e4 + checksum: bbb965a3acdfb03559806d149efbd194ac9c983b260581a60efcb15eb9fbe20e3054667970800146d867446db1c1398f8e4ee87f4454233e49b8f8ce947bd99b languageName: node linkType: hard @@ -713,8 +713,8 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-class-properties@npm:7.23.3" dependencies: - "@babel/helper-create-class-features-plugin": ^7.22.15 - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-create-class-features-plugin": "npm:^7.22.15" + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 9c6f8366f667897541d360246de176dd29efc7a13d80a5b48361882f7173d9173be4646c3b7d9b003ccc0e01e25df122330308f33db921fa553aa17ad544b3fc @@ -725,9 +725,9 @@ __metadata: version: 7.23.4 resolution: "@babel/plugin-transform-class-static-block@npm:7.23.4" dependencies: - "@babel/helper-create-class-features-plugin": ^7.22.15 - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/plugin-syntax-class-static-block": ^7.14.5 + "@babel/helper-create-class-features-plugin": "npm:^7.22.15" + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/plugin-syntax-class-static-block": "npm:^7.14.5" peerDependencies: "@babel/core": ^7.12.0 checksum: c8bfaba19a674fc2eb54edad71e958647360474e3163e8226f1acd63e4e2dbec32a171a0af596c1dc5359aee402cc120fea7abd1fb0e0354b6527f0fc9e8aa1e @@ -738,17 +738,17 @@ __metadata: version: 7.23.8 resolution: "@babel/plugin-transform-classes@npm:7.23.8" dependencies: - "@babel/helper-annotate-as-pure": ^7.22.5 - "@babel/helper-compilation-targets": ^7.23.6 - "@babel/helper-environment-visitor": ^7.22.20 - "@babel/helper-function-name": ^7.23.0 - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/helper-replace-supers": ^7.22.20 - "@babel/helper-split-export-declaration": ^7.22.6 - globals: ^11.1.0 + "@babel/helper-annotate-as-pure": "npm:^7.22.5" + "@babel/helper-compilation-targets": "npm:^7.23.6" + "@babel/helper-environment-visitor": "npm:^7.22.20" + "@babel/helper-function-name": "npm:^7.23.0" + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/helper-replace-supers": "npm:^7.22.20" + "@babel/helper-split-export-declaration": "npm:^7.22.6" + globals: "npm:^11.1.0" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 7dee6cebe52131d2d16944f36e1fdb9d4b24f44d0e7e450f93a44435d001f17cc0789a4cb6b15ec67c8e484581b8a730b5c3ec374470f29ff0133086955b8c58 + checksum: 4bb4b19e7a39871c4414fb44fc5f2cc47c78f993b74c43238dfb99c9dac2d15cb99b43f8a3d42747580e1807d2b8f5e13ce7e95e593fd839bd176aa090bf9a23 languageName: node linkType: hard @@ -756,11 +756,11 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-computed-properties@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/template": ^7.22.15 + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/template": "npm:^7.22.15" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 80452661dc25a0956f89fe98cb562e8637a9556fb6c00d312c57653ce7df8798f58d138603c7e1aad96614ee9ccd10c47e50ab9ded6b6eded5adeb230d2a982e + checksum: e75593e02c5ea473c17839e3c9d597ce3697bf039b66afe9a4d06d086a87fb3d95850b4174476897afc351dc1b46a9ec3165ee6e8fbad3732c0d65f676f855ad languageName: node linkType: hard @@ -768,10 +768,10 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-destructuring@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 9e015099877272501162419bfe781689aec5c462cd2aec752ee22288f209eec65969ff11b8fdadca2eaddea71d705d3bba5b9c60752fcc1be67874fcec687105 + checksum: 5abd93718af5a61f8f6a97d2ccac9139499752dd5b2c533d7556fb02947ae01b2f51d4c4f5e64df569e8783d3743270018eb1fa979c43edec7dd1377acf107ed languageName: node linkType: hard @@ -779,8 +779,8 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-dotall-regex@npm:7.23.3" dependencies: - "@babel/helper-create-regexp-features-plugin": ^7.22.15 - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-create-regexp-features-plugin": "npm:^7.22.15" + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: a2dbbf7f1ea16a97948c37df925cb364337668c41a3948b8d91453f140507bd8a3429030c7ce66d09c299987b27746c19a2dd18b6f17dcb474854b14fd9159a3 @@ -791,7 +791,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-duplicate-keys@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: c2a21c34dc0839590cd945192cbc46fde541a27e140c48fe1808315934664cdbf18db64889e23c4eeb6bad9d3e049482efdca91d29de5734ffc887c4fbabaa16 @@ -802,8 +802,8 @@ __metadata: version: 7.23.4 resolution: "@babel/plugin-transform-dynamic-import@npm:7.23.4" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/plugin-syntax-dynamic-import": ^7.8.3 + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/plugin-syntax-dynamic-import": "npm:^7.8.3" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 57a722604c430d9f3dacff22001a5f31250e34785d4969527a2ae9160fa86858d0892c5b9ff7a06a04076f8c76c9e6862e0541aadca9c057849961343aab0845 @@ -814,8 +814,8 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-exponentiation-operator@npm:7.23.3" dependencies: - "@babel/helper-builder-binary-assignment-operator-visitor": ^7.22.15 - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-builder-binary-assignment-operator-visitor": "npm:^7.22.15" + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 00d05ab14ad0f299160fcf9d8f55a1cc1b740e012ab0b5ce30207d2365f091665115557af7d989cd6260d075a252d9e4283de5f2b247dfbbe0e42ae586e6bf66 @@ -826,8 +826,8 @@ __metadata: version: 7.23.4 resolution: "@babel/plugin-transform-export-namespace-from@npm:7.23.4" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/plugin-syntax-export-namespace-from": ^7.8.3 + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/plugin-syntax-export-namespace-from": "npm:^7.8.3" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 9f770a81bfd03b48d6ba155d452946fd56d6ffe5b7d871e9ec2a0b15e0f424273b632f3ed61838b90015b25bbda988896b7a46c7d964fbf8f6feb5820b309f93 @@ -838,11 +838,11 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-flow-strip-types@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/plugin-syntax-flow": ^7.23.3 + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/plugin-syntax-flow": "npm:^7.23.3" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: de38cc5cf948bc19405ea041292181527a36f59f08d787a590415fac36e9b0c7992f0d3e2fd3b9402089bafdaa1a893291a0edf15beebfd29bdedbbe582fee9b + checksum: 84af4b1f6d79f1a66a2440c5cfe3ba0e2bb9355402da477add13de1867088efb8d7b2be15d67ac955f1d2a745d4a561423bbb473fe6e4622b157989598ec323f languageName: node linkType: hard @@ -850,11 +850,11 @@ __metadata: version: 7.23.6 resolution: "@babel/plugin-transform-for-of@npm:7.23.6" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/helper-skip-transparent-expression-wrappers": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 228c060aa61f6aa89dc447170075f8214863b94f830624e74ade99c1a09316897c12d76e848460b0b506593e58dbc42739af6dc4cb0fe9b84dffe4a596050a36 + checksum: b84ef1f26a2db316237ae6d10fa7c22c70ac808ed0b8e095a8ecf9101551636cbb026bee9fb95a0a7944f3b8278ff9636a9088cb4a4ac5b84830a13829242735 languageName: node linkType: hard @@ -862,9 +862,9 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-function-name@npm:7.23.3" dependencies: - "@babel/helper-compilation-targets": ^7.22.15 - "@babel/helper-function-name": ^7.23.0 - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-compilation-targets": "npm:^7.22.15" + "@babel/helper-function-name": "npm:^7.23.0" + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 355c6dbe07c919575ad42b2f7e020f320866d72f8b79181a16f8e0cd424a2c761d979f03f47d583d9471b55dcd68a8a9d829b58e1eebcd572145b934b48975a6 @@ -875,8 +875,8 @@ __metadata: version: 7.23.4 resolution: "@babel/plugin-transform-json-strings@npm:7.23.4" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/plugin-syntax-json-strings": ^7.8.3 + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/plugin-syntax-json-strings": "npm:^7.8.3" peerDependencies: "@babel/core": ^7.0.0-0 checksum: f9019820233cf8955d8ba346df709a0683c120fe86a24ed1c9f003f2db51197b979efc88f010d558a12e1491210fc195a43cd1c7fee5e23b92da38f793a875de @@ -887,7 +887,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-literals@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 519a544cd58586b9001c4c9b18da25a62f17d23c48600ff7a685d75ca9eb18d2c5e8f5476f067f0a8f1fea2a31107eff950b9864833061e6076dcc4bdc3e71ed @@ -898,8 +898,8 @@ __metadata: version: 7.23.4 resolution: "@babel/plugin-transform-logical-assignment-operators@npm:7.23.4" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/plugin-syntax-logical-assignment-operators": ^7.10.4 + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/plugin-syntax-logical-assignment-operators": "npm:^7.10.4" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 2ae1dc9b4ff3bf61a990ff3accdecb2afe3a0ca649b3e74c010078d1cdf29ea490f50ac0a905306a2bcf9ac177889a39ac79bdcc3a0fdf220b3b75fac18d39b5 @@ -910,7 +910,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-member-expression-literals@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 95cec13c36d447c5aa6b8e4c778b897eeba66dcb675edef01e0d2afcec9e8cb9726baf4f81b4bbae7a782595aed72e6a0d44ffb773272c3ca180fada99bf92db @@ -921,11 +921,11 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-modules-amd@npm:7.23.3" dependencies: - "@babel/helper-module-transforms": ^7.23.3 - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-module-transforms": "npm:^7.23.3" + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: d163737b6a3d67ea579c9aa3b83d4df4b5c34d9dcdf25f415f027c0aa8cded7bac2750d2de5464081f67a042ad9e1c03930c2fab42acd79f9e57c00cf969ddff + checksum: 48c87dee2c7dae8ed40d16901f32c9e58be4ef87bf2c3985b51dd2e78e82081f3bad0a39ee5cf6e8909e13e954e2b4bedef0a8141922f281ed833ddb59ed9be2 languageName: node linkType: hard @@ -933,12 +933,12 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-modules-commonjs@npm:7.23.3" dependencies: - "@babel/helper-module-transforms": ^7.23.3 - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/helper-simple-access": ^7.22.5 + "@babel/helper-module-transforms": "npm:^7.23.3" + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/helper-simple-access": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 720a231ceade4ae4d2632478db4e7fecf21987d444942b72d523487ac8d715ca97de6c8f415c71e939595e1a4776403e7dc24ed68fe9125ad4acf57753c9bff7 + checksum: a3bc082d0dfe8327a29263a6d721cea608d440bc8141ba3ec6ba80ad73d84e4f9bbe903c27e9291c29878feec9b5dee2bd0563822f93dc951f5d7fc36bdfe85b languageName: node linkType: hard @@ -946,13 +946,13 @@ __metadata: version: 7.23.9 resolution: "@babel/plugin-transform-modules-systemjs@npm:7.23.9" dependencies: - "@babel/helper-hoist-variables": ^7.22.5 - "@babel/helper-module-transforms": ^7.23.3 - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/helper-validator-identifier": ^7.22.20 + "@babel/helper-hoist-variables": "npm:^7.22.5" + "@babel/helper-module-transforms": "npm:^7.23.3" + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/helper-validator-identifier": "npm:^7.22.20" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: cec6abeae6be66fd1a5940c482fe9ff94b689c71fcf4147e179119e4accd09d17d476e36528bc9cb4ab0ec6728fedf48b1c49d0551ea707fb192575d8eac9167 + checksum: 4bb800e5a9d0d668d7421ae3672fccff7d5f2a36621fd87414d7ece6d6f4d93627f9644cfecacae934bc65ffc131c8374242aaa400cca874dcab9b281a21aff0 languageName: node linkType: hard @@ -960,11 +960,11 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-modules-umd@npm:7.23.3" dependencies: - "@babel/helper-module-transforms": ^7.23.3 - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-module-transforms": "npm:^7.23.3" + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 586a7a2241e8b4e753a37af9466a9ffa8a67b4ba9aa756ad7500712c05d8fa9a8c1ed4f7bd25fae2a8265e6cf8fe781ec85a8ee885dd34cf50d8955ee65f12dc + checksum: e3f3af83562d687899555c7826b3faf0ab93ee7976898995b1d20cbe7f4451c55e05b0e17bfb3e549937cbe7573daf5400b752912a241b0a8a64d2457c7626e5 languageName: node linkType: hard @@ -972,8 +972,8 @@ __metadata: version: 7.22.5 resolution: "@babel/plugin-transform-named-capturing-groups-regex@npm:7.22.5" dependencies: - "@babel/helper-create-regexp-features-plugin": ^7.22.5 - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-create-regexp-features-plugin": "npm:^7.22.5" + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0 checksum: 3ee564ddee620c035b928fdc942c5d17e9c4b98329b76f9cefac65c111135d925eb94ed324064cd7556d4f5123beec79abea1d4b97d1c8a2a5c748887a2eb623 @@ -984,7 +984,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-new-target@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: e5053389316fce73ad5201b7777437164f333e24787fbcda4ae489cd2580dbbbdfb5694a7237bad91fabb46b591d771975d69beb1c740b82cb4761625379f00b @@ -995,8 +995,8 @@ __metadata: version: 7.23.4 resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.23.4" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/plugin-syntax-nullish-coalescing-operator": ^7.8.3 + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/plugin-syntax-nullish-coalescing-operator": "npm:^7.8.3" peerDependencies: "@babel/core": ^7.0.0-0 checksum: a27d73ea134d3d9560a6b2e26ab60012fba15f1db95865aa0153c18f5ec82cfef6a7b3d8df74e3c2fca81534fa5efeb6cacaf7b08bdb7d123e3dafdd079886a3 @@ -1007,8 +1007,8 @@ __metadata: version: 7.23.4 resolution: "@babel/plugin-transform-numeric-separator@npm:7.23.4" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/plugin-syntax-numeric-separator": ^7.10.4 + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/plugin-syntax-numeric-separator": "npm:^7.10.4" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 6ba0e5db3c620a3ec81f9e94507c821f483c15f196868df13fa454cbac719a5449baf73840f5b6eb7d77311b24a2cf8e45db53700d41727f693d46f7caf3eec3 @@ -1019,14 +1019,14 @@ __metadata: version: 7.23.4 resolution: "@babel/plugin-transform-object-rest-spread@npm:7.23.4" dependencies: - "@babel/compat-data": ^7.23.3 - "@babel/helper-compilation-targets": ^7.22.15 - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/plugin-syntax-object-rest-spread": ^7.8.3 - "@babel/plugin-transform-parameters": ^7.23.3 + "@babel/compat-data": "npm:^7.23.3" + "@babel/helper-compilation-targets": "npm:^7.22.15" + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/plugin-syntax-object-rest-spread": "npm:^7.8.3" + "@babel/plugin-transform-parameters": "npm:^7.23.3" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 73fec495e327ca3959c1c03d07a621be09df00036c69fff0455af9a008291677ee9d368eec48adacdc6feac703269a649747568b4af4c4e9f134aa71cc5b378d + checksum: 656f09c4ec629856e807d5b386559166ae417ff75943abce19656b2c6de5101dfd0aaf23f9074e854339370b4e09f57518d3202457046ee5b567ded531005479 languageName: node linkType: hard @@ -1034,8 +1034,8 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-object-super@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/helper-replace-supers": ^7.22.20 + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/helper-replace-supers": "npm:^7.22.20" peerDependencies: "@babel/core": ^7.0.0-0 checksum: e495497186f621fa79026e183b4f1fbb172fd9df812cbd2d7f02c05b08adbe58012b1a6eb6dd58d11a30343f6ec80d0f4074f9b501d70aa1c94df76d59164c53 @@ -1046,8 +1046,8 @@ __metadata: version: 7.23.4 resolution: "@babel/plugin-transform-optional-catch-binding@npm:7.23.4" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/plugin-syntax-optional-catch-binding": ^7.8.3 + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/plugin-syntax-optional-catch-binding": "npm:^7.8.3" peerDependencies: "@babel/core": ^7.0.0-0 checksum: d50b5ee142cdb088d8b5de1ccf7cea85b18b85d85b52f86618f6e45226372f01ad4cdb29abd4fd35ea99a71fefb37009e0107db7a787dcc21d4d402f97470faf @@ -1058,12 +1058,12 @@ __metadata: version: 7.23.4 resolution: "@babel/plugin-transform-optional-chaining@npm:7.23.4" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/helper-skip-transparent-expression-wrappers": ^7.22.5 - "@babel/plugin-syntax-optional-chaining": ^7.8.3 + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.22.5" + "@babel/plugin-syntax-optional-chaining": "npm:^7.8.3" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: e7a4c08038288057b7a08d68c4d55396ada9278095509ca51ed8dfb72a7f13f26bdd7c5185de21079fe0a9d60d22c227cb32e300d266c1bda40f70eee9f4bc1e + checksum: 0ef24e889d6151428953fc443af5f71f4dae73f373dc1b7f5dd3f6a61d511296eb77e9b870e8c2c02a933e3455ae24c1fa91738c826b72a4ff87e0337db527e8 languageName: node linkType: hard @@ -1071,10 +1071,10 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-parameters@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: a735b3e85316d17ec102e3d3d1b6993b429bdb3b494651c9d754e3b7d270462ee1f1a126ccd5e3d871af5e683727e9ef98c9d34d4a42204fffaabff91052ed16 + checksum: a8c36c3fc25f9daa46c4f6db47ea809c395dc4abc7f01c4b1391f6e5b0cd62b83b6016728b02a6a8ac21aca56207c9ec66daefc0336e9340976978de7e6e28df languageName: node linkType: hard @@ -1082,8 +1082,8 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-private-methods@npm:7.23.3" dependencies: - "@babel/helper-create-class-features-plugin": ^7.22.15 - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-create-class-features-plugin": "npm:^7.22.15" + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: cedc1285c49b5a6d9a3d0e5e413b756ac40b3ac2f8f68bdfc3ae268bc8d27b00abd8bb0861c72756ff5dd8bf1eb77211b7feb5baf4fdae2ebbaabe49b9adc1d0 @@ -1094,13 +1094,13 @@ __metadata: version: 7.23.4 resolution: "@babel/plugin-transform-private-property-in-object@npm:7.23.4" dependencies: - "@babel/helper-annotate-as-pure": ^7.22.5 - "@babel/helper-create-class-features-plugin": ^7.22.15 - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/plugin-syntax-private-property-in-object": ^7.14.5 + "@babel/helper-annotate-as-pure": "npm:^7.22.5" + "@babel/helper-create-class-features-plugin": "npm:^7.22.15" + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/plugin-syntax-private-property-in-object": "npm:^7.14.5" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: fb7adfe94ea97542f250a70de32bddbc3e0b802381c92be947fec83ebffda57e68533c4d0697152719a3496fdd3ebf3798d451c024cd4ac848fc15ac26b70aa7 + checksum: 02eef2ee98fa86ee5052ed9bf0742d6d22b510b5df2fcce0b0f5615d6001f7786c6b31505e7f1c2f446406d8fb33603a5316d957cfa5b8365cbf78ddcc24fa42 languageName: node linkType: hard @@ -1108,7 +1108,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-property-literals@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 16b048c8e87f25095f6d53634ab7912992f78e6997a6ff549edc3cf519db4fca01c7b4e0798530d7f6a05228ceee479251245cdd850a5531c6e6f404104d6cc9 @@ -1119,7 +1119,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-react-display-name@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 7f86964e8434d3ddbd3c81d2690c9b66dbf1cd8bd9512e2e24500e9fa8cf378bc52c0853270b3b82143aba5965aec04721df7abdb768f952b44f5c6e0b198779 @@ -1130,7 +1130,7 @@ __metadata: version: 7.22.5 resolution: "@babel/plugin-transform-react-jsx-development@npm:7.22.5" dependencies: - "@babel/plugin-transform-react-jsx": ^7.22.5 + "@babel/plugin-transform-react-jsx": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 36bc3ff0b96bb0ef4723070a50cfdf2e72cfd903a59eba448f9fe92fea47574d6f22efd99364413719e1f3fb3c51b6c9b2990b87af088f8486a84b2a5f9e4560 @@ -1141,7 +1141,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-react-jsx-self@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 882bf56bc932d015c2d83214133939ddcf342e5bcafa21f1a93b19f2e052145115e1e0351730897fd66e5f67cad7875b8a8d81ceb12b6e2a886ad0102cb4eb1f @@ -1152,7 +1152,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-react-jsx-source@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 92287fb797e522d99bdc77eaa573ce79ff0ad9f1cf4e7df374645e28e51dce0adad129f6f075430b129b5bac8dad843f65021970e12e992d6d6671f0d65bb1e0 @@ -1163,14 +1163,14 @@ __metadata: version: 7.23.4 resolution: "@babel/plugin-transform-react-jsx@npm:7.23.4" dependencies: - "@babel/helper-annotate-as-pure": ^7.22.5 - "@babel/helper-module-imports": ^7.22.15 - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/plugin-syntax-jsx": ^7.23.3 - "@babel/types": ^7.23.4 + "@babel/helper-annotate-as-pure": "npm:^7.22.5" + "@babel/helper-module-imports": "npm:^7.22.15" + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/plugin-syntax-jsx": "npm:^7.23.3" + "@babel/types": "npm:^7.23.4" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: d8b8c52e8e22e833bf77c8d1a53b0a57d1fd52ba9596a319d572de79446a8ed9d95521035bc1175c1589d1a6a34600d2e678fa81d81bac8fac121137097f1f0a + checksum: d83806701349addfb77b8347b4f0dc8e76fb1c9ac21bdef69f4002394fce2396d61facfc6e1a3de54cbabcdadf991a1f642e69edb5116ac14f95e33d9f7c221d languageName: node linkType: hard @@ -1178,8 +1178,8 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-react-pure-annotations@npm:7.23.3" dependencies: - "@babel/helper-annotate-as-pure": ^7.22.5 - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-annotate-as-pure": "npm:^7.22.5" + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 9ea3698b1d422561d93c0187ac1ed8f2367e4250b10e259785ead5aa643c265830fd0f4cf5087a5bedbc4007444c06da2f2006686613220acf0949895f453666 @@ -1190,8 +1190,8 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-regenerator@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 - regenerator-transform: ^0.15.2 + "@babel/helper-plugin-utils": "npm:^7.22.5" + regenerator-transform: "npm:^0.15.2" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 7fdacc7b40008883871b519c9e5cdea493f75495118ccc56ac104b874983569a24edd024f0f5894ba1875c54ee2b442f295d6241c3280e61c725d0dd3317c8e6 @@ -1202,7 +1202,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-reserved-words@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 298c4440ddc136784ff920127cea137168e068404e635dc946ddb5d7b2a27b66f1dd4c4acb01f7184478ff7d5c3e7177a127279479926519042948fb7fa0fa48 @@ -1213,7 +1213,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-shorthand-properties@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 5d677a03676f9fff969b0246c423d64d77502e90a832665dc872a5a5e05e5708161ce1effd56bb3c0f2c20a1112fca874be57c8a759d8b08152755519281f326 @@ -1224,11 +1224,11 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-spread@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/helper-skip-transparent-expression-wrappers": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 8fd5cac201e77a0b4825745f4e07a25f923842f282f006b3a79223c00f61075c8868d12eafec86b2642cd0b32077cdd32314e27bcb75ee5e6a68c0144140dcf2 + checksum: c6372d2f788fd71d85aba12fbe08ee509e053ed27457e6674a4f9cae41ff885e2eb88aafea8fadd0ccf990601fc69ec596fa00959e05af68a15461a8d97a548d languageName: node linkType: hard @@ -1236,7 +1236,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-sticky-regex@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 53e55eb2575b7abfdb4af7e503a2bf7ef5faf8bf6b92d2cd2de0700bdd19e934e5517b23e6dfed94ba50ae516b62f3f916773ef7d9bc81f01503f585051e2949 @@ -1247,7 +1247,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-template-literals@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: b16c5cb0b8796be0118e9c144d15bdc0d20a7f3f59009c6303a6e9a8b74c146eceb3f05186f5b97afcba7cfa87e34c1585a22186e3d5b22f2fd3d27d959d92b2 @@ -1258,7 +1258,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-typeof-symbol@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 0af7184379d43afac7614fc89b1bdecce4e174d52f4efaeee8ec1a4f2c764356c6dba3525c0685231f1cbf435b6dd4ee9e738d7417f3b10ce8bbe869c32f4384 @@ -1269,13 +1269,13 @@ __metadata: version: 7.23.6 resolution: "@babel/plugin-transform-typescript@npm:7.23.6" dependencies: - "@babel/helper-annotate-as-pure": ^7.22.5 - "@babel/helper-create-class-features-plugin": ^7.23.6 - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/plugin-syntax-typescript": ^7.23.3 + "@babel/helper-annotate-as-pure": "npm:^7.22.5" + "@babel/helper-create-class-features-plugin": "npm:^7.23.6" + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/plugin-syntax-typescript": "npm:^7.23.3" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 0462241843d14dff9f1a4c49ab182a6f01a5f7679957c786b08165dac3e8d49184011f05ca204183d164c54b9d3496d1b3005f904fa8708e394e6f15bf5548e6 + checksum: a816811129f3fcb0af1aeb52b84285be390ed8a0eedab17d31fa8e6847c4ca39b4b176d44831f20a8561b3f586974053570ad7bdfa51f89566276e6b191786d2 languageName: node linkType: hard @@ -1283,7 +1283,7 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-unicode-escapes@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 561c429183a54b9e4751519a3dfba6014431e9cdc1484fad03bdaf96582dfc72c76a4f8661df2aeeae7c34efd0fa4d02d3b83a2f63763ecf71ecc925f9cc1f60 @@ -1294,8 +1294,8 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-unicode-property-regex@npm:7.23.3" dependencies: - "@babel/helper-create-regexp-features-plugin": ^7.22.15 - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-create-regexp-features-plugin": "npm:^7.22.15" + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 2298461a194758086d17c23c26c7de37aa533af910f9ebf31ebd0893d4aa317468043d23f73edc782ec21151d3c46cf0ff8098a83b725c49a59de28a1d4d6225 @@ -1306,8 +1306,8 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-unicode-regex@npm:7.23.3" dependencies: - "@babel/helper-create-regexp-features-plugin": ^7.22.15 - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-create-regexp-features-plugin": "npm:^7.22.15" + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0-0 checksum: c5f835d17483ba899787f92e313dfa5b0055e3deab332f1d254078a2bba27ede47574b6599fcf34d3763f0c048ae0779dc21d2d8db09295edb4057478dc80a9a @@ -1318,8 +1318,8 @@ __metadata: version: 7.23.3 resolution: "@babel/plugin-transform-unicode-sets-regex@npm:7.23.3" dependencies: - "@babel/helper-create-regexp-features-plugin": ^7.22.15 - "@babel/helper-plugin-utils": ^7.22.5 + "@babel/helper-create-regexp-features-plugin": "npm:^7.22.15" + "@babel/helper-plugin-utils": "npm:^7.22.5" peerDependencies: "@babel/core": ^7.0.0 checksum: 79d0b4c951955ca68235c87b91ab2b393c96285f8aeaa34d6db416d2ddac90000c9bd6e8c4d82b60a2b484da69930507245035f28ba63c6cae341cf3ba68fdef @@ -1330,89 +1330,89 @@ __metadata: version: 7.23.9 resolution: "@babel/preset-env@npm:7.23.9" dependencies: - "@babel/compat-data": ^7.23.5 - "@babel/helper-compilation-targets": ^7.23.6 - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/helper-validator-option": ^7.23.5 - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": ^7.23.3 - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": ^7.23.3 - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": ^7.23.7 - "@babel/plugin-proposal-private-property-in-object": 7.21.0-placeholder-for-preset-env.2 - "@babel/plugin-syntax-async-generators": ^7.8.4 - "@babel/plugin-syntax-class-properties": ^7.12.13 - "@babel/plugin-syntax-class-static-block": ^7.14.5 - "@babel/plugin-syntax-dynamic-import": ^7.8.3 - "@babel/plugin-syntax-export-namespace-from": ^7.8.3 - "@babel/plugin-syntax-import-assertions": ^7.23.3 - "@babel/plugin-syntax-import-attributes": ^7.23.3 - "@babel/plugin-syntax-import-meta": ^7.10.4 - "@babel/plugin-syntax-json-strings": ^7.8.3 - "@babel/plugin-syntax-logical-assignment-operators": ^7.10.4 - "@babel/plugin-syntax-nullish-coalescing-operator": ^7.8.3 - "@babel/plugin-syntax-numeric-separator": ^7.10.4 - "@babel/plugin-syntax-object-rest-spread": ^7.8.3 - "@babel/plugin-syntax-optional-catch-binding": ^7.8.3 - "@babel/plugin-syntax-optional-chaining": ^7.8.3 - "@babel/plugin-syntax-private-property-in-object": ^7.14.5 - "@babel/plugin-syntax-top-level-await": ^7.14.5 - "@babel/plugin-syntax-unicode-sets-regex": ^7.18.6 - "@babel/plugin-transform-arrow-functions": ^7.23.3 - "@babel/plugin-transform-async-generator-functions": ^7.23.9 - "@babel/plugin-transform-async-to-generator": ^7.23.3 - "@babel/plugin-transform-block-scoped-functions": ^7.23.3 - "@babel/plugin-transform-block-scoping": ^7.23.4 - "@babel/plugin-transform-class-properties": ^7.23.3 - "@babel/plugin-transform-class-static-block": ^7.23.4 - "@babel/plugin-transform-classes": ^7.23.8 - "@babel/plugin-transform-computed-properties": ^7.23.3 - "@babel/plugin-transform-destructuring": ^7.23.3 - "@babel/plugin-transform-dotall-regex": ^7.23.3 - "@babel/plugin-transform-duplicate-keys": ^7.23.3 - "@babel/plugin-transform-dynamic-import": ^7.23.4 - "@babel/plugin-transform-exponentiation-operator": ^7.23.3 - "@babel/plugin-transform-export-namespace-from": ^7.23.4 - "@babel/plugin-transform-for-of": ^7.23.6 - "@babel/plugin-transform-function-name": ^7.23.3 - "@babel/plugin-transform-json-strings": ^7.23.4 - "@babel/plugin-transform-literals": ^7.23.3 - "@babel/plugin-transform-logical-assignment-operators": ^7.23.4 - "@babel/plugin-transform-member-expression-literals": ^7.23.3 - "@babel/plugin-transform-modules-amd": ^7.23.3 - "@babel/plugin-transform-modules-commonjs": ^7.23.3 - "@babel/plugin-transform-modules-systemjs": ^7.23.9 - "@babel/plugin-transform-modules-umd": ^7.23.3 - "@babel/plugin-transform-named-capturing-groups-regex": ^7.22.5 - "@babel/plugin-transform-new-target": ^7.23.3 - "@babel/plugin-transform-nullish-coalescing-operator": ^7.23.4 - "@babel/plugin-transform-numeric-separator": ^7.23.4 - "@babel/plugin-transform-object-rest-spread": ^7.23.4 - "@babel/plugin-transform-object-super": ^7.23.3 - "@babel/plugin-transform-optional-catch-binding": ^7.23.4 - "@babel/plugin-transform-optional-chaining": ^7.23.4 - "@babel/plugin-transform-parameters": ^7.23.3 - "@babel/plugin-transform-private-methods": ^7.23.3 - "@babel/plugin-transform-private-property-in-object": ^7.23.4 - "@babel/plugin-transform-property-literals": ^7.23.3 - "@babel/plugin-transform-regenerator": ^7.23.3 - "@babel/plugin-transform-reserved-words": ^7.23.3 - "@babel/plugin-transform-shorthand-properties": ^7.23.3 - "@babel/plugin-transform-spread": ^7.23.3 - "@babel/plugin-transform-sticky-regex": ^7.23.3 - "@babel/plugin-transform-template-literals": ^7.23.3 - "@babel/plugin-transform-typeof-symbol": ^7.23.3 - "@babel/plugin-transform-unicode-escapes": ^7.23.3 - "@babel/plugin-transform-unicode-property-regex": ^7.23.3 - "@babel/plugin-transform-unicode-regex": ^7.23.3 - "@babel/plugin-transform-unicode-sets-regex": ^7.23.3 - "@babel/preset-modules": 0.1.6-no-external-plugins - babel-plugin-polyfill-corejs2: ^0.4.8 - babel-plugin-polyfill-corejs3: ^0.9.0 - babel-plugin-polyfill-regenerator: ^0.5.5 - core-js-compat: ^3.31.0 - semver: ^6.3.1 + "@babel/compat-data": "npm:^7.23.5" + "@babel/helper-compilation-targets": "npm:^7.23.6" + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/helper-validator-option": "npm:^7.23.5" + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "npm:^7.23.3" + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "npm:^7.23.3" + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "npm:^7.23.7" + "@babel/plugin-proposal-private-property-in-object": "npm:7.21.0-placeholder-for-preset-env.2" + "@babel/plugin-syntax-async-generators": "npm:^7.8.4" + "@babel/plugin-syntax-class-properties": "npm:^7.12.13" + "@babel/plugin-syntax-class-static-block": "npm:^7.14.5" + "@babel/plugin-syntax-dynamic-import": "npm:^7.8.3" + "@babel/plugin-syntax-export-namespace-from": "npm:^7.8.3" + "@babel/plugin-syntax-import-assertions": "npm:^7.23.3" + "@babel/plugin-syntax-import-attributes": "npm:^7.23.3" + "@babel/plugin-syntax-import-meta": "npm:^7.10.4" + "@babel/plugin-syntax-json-strings": "npm:^7.8.3" + "@babel/plugin-syntax-logical-assignment-operators": "npm:^7.10.4" + "@babel/plugin-syntax-nullish-coalescing-operator": "npm:^7.8.3" + "@babel/plugin-syntax-numeric-separator": "npm:^7.10.4" + "@babel/plugin-syntax-object-rest-spread": "npm:^7.8.3" + "@babel/plugin-syntax-optional-catch-binding": "npm:^7.8.3" + "@babel/plugin-syntax-optional-chaining": "npm:^7.8.3" + "@babel/plugin-syntax-private-property-in-object": "npm:^7.14.5" + "@babel/plugin-syntax-top-level-await": "npm:^7.14.5" + "@babel/plugin-syntax-unicode-sets-regex": "npm:^7.18.6" + "@babel/plugin-transform-arrow-functions": "npm:^7.23.3" + "@babel/plugin-transform-async-generator-functions": "npm:^7.23.9" + "@babel/plugin-transform-async-to-generator": "npm:^7.23.3" + "@babel/plugin-transform-block-scoped-functions": "npm:^7.23.3" + "@babel/plugin-transform-block-scoping": "npm:^7.23.4" + "@babel/plugin-transform-class-properties": "npm:^7.23.3" + "@babel/plugin-transform-class-static-block": "npm:^7.23.4" + "@babel/plugin-transform-classes": "npm:^7.23.8" + "@babel/plugin-transform-computed-properties": "npm:^7.23.3" + "@babel/plugin-transform-destructuring": "npm:^7.23.3" + "@babel/plugin-transform-dotall-regex": "npm:^7.23.3" + "@babel/plugin-transform-duplicate-keys": "npm:^7.23.3" + "@babel/plugin-transform-dynamic-import": "npm:^7.23.4" + "@babel/plugin-transform-exponentiation-operator": "npm:^7.23.3" + "@babel/plugin-transform-export-namespace-from": "npm:^7.23.4" + "@babel/plugin-transform-for-of": "npm:^7.23.6" + "@babel/plugin-transform-function-name": "npm:^7.23.3" + "@babel/plugin-transform-json-strings": "npm:^7.23.4" + "@babel/plugin-transform-literals": "npm:^7.23.3" + "@babel/plugin-transform-logical-assignment-operators": "npm:^7.23.4" + "@babel/plugin-transform-member-expression-literals": "npm:^7.23.3" + "@babel/plugin-transform-modules-amd": "npm:^7.23.3" + "@babel/plugin-transform-modules-commonjs": "npm:^7.23.3" + "@babel/plugin-transform-modules-systemjs": "npm:^7.23.9" + "@babel/plugin-transform-modules-umd": "npm:^7.23.3" + "@babel/plugin-transform-named-capturing-groups-regex": "npm:^7.22.5" + "@babel/plugin-transform-new-target": "npm:^7.23.3" + "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.23.4" + "@babel/plugin-transform-numeric-separator": "npm:^7.23.4" + "@babel/plugin-transform-object-rest-spread": "npm:^7.23.4" + "@babel/plugin-transform-object-super": "npm:^7.23.3" + "@babel/plugin-transform-optional-catch-binding": "npm:^7.23.4" + "@babel/plugin-transform-optional-chaining": "npm:^7.23.4" + "@babel/plugin-transform-parameters": "npm:^7.23.3" + "@babel/plugin-transform-private-methods": "npm:^7.23.3" + "@babel/plugin-transform-private-property-in-object": "npm:^7.23.4" + "@babel/plugin-transform-property-literals": "npm:^7.23.3" + "@babel/plugin-transform-regenerator": "npm:^7.23.3" + "@babel/plugin-transform-reserved-words": "npm:^7.23.3" + "@babel/plugin-transform-shorthand-properties": "npm:^7.23.3" + "@babel/plugin-transform-spread": "npm:^7.23.3" + "@babel/plugin-transform-sticky-regex": "npm:^7.23.3" + "@babel/plugin-transform-template-literals": "npm:^7.23.3" + "@babel/plugin-transform-typeof-symbol": "npm:^7.23.3" + "@babel/plugin-transform-unicode-escapes": "npm:^7.23.3" + "@babel/plugin-transform-unicode-property-regex": "npm:^7.23.3" + "@babel/plugin-transform-unicode-regex": "npm:^7.23.3" + "@babel/plugin-transform-unicode-sets-regex": "npm:^7.23.3" + "@babel/preset-modules": "npm:0.1.6-no-external-plugins" + babel-plugin-polyfill-corejs2: "npm:^0.4.8" + babel-plugin-polyfill-corejs3: "npm:^0.9.0" + babel-plugin-polyfill-regenerator: "npm:^0.5.5" + core-js-compat: "npm:^3.31.0" + semver: "npm:^6.3.1" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 23a48468ba820c68ba34ea2c1dbc62fd2ff9cf858cfb69e159cabb0c85c72dc4c2266ce20ca84318d8742de050cb061e7c66902fbfddbcb09246afd248847933 + checksum: 0214ac9434a2496eac7f56c0c91164421232ff2083a66e1ccab633ca91e262828e54a5cbdb9036e8fe53d53530b6597aa98c99de8ff07b5193ffd95f21dc9d2c languageName: node linkType: hard @@ -1420,9 +1420,9 @@ __metadata: version: 7.23.3 resolution: "@babel/preset-flow@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/helper-validator-option": ^7.22.15 - "@babel/plugin-transform-flow-strip-types": ^7.23.3 + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/helper-validator-option": "npm:^7.22.15" + "@babel/plugin-transform-flow-strip-types": "npm:^7.23.3" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 60b5dde79621ae89943af459c4dc5b6030795f595a20ca438c8100f8d82c9ebc986881719030521ff5925799518ac5aa7f3fe62af8c33ab96be3681a71f88d03 @@ -1433,12 +1433,12 @@ __metadata: version: 0.1.6-no-external-plugins resolution: "@babel/preset-modules@npm:0.1.6-no-external-plugins" dependencies: - "@babel/helper-plugin-utils": ^7.0.0 - "@babel/types": ^7.4.4 - esutils: ^2.0.2 + "@babel/helper-plugin-utils": "npm:^7.0.0" + "@babel/types": "npm:^7.4.4" + esutils: "npm:^2.0.2" peerDependencies: "@babel/core": ^7.0.0-0 || ^8.0.0-0 <8.0.0 - checksum: 4855e799bc50f2449fb5210f78ea9e8fd46cf4f242243f1e2ed838e2bd702e25e73e822e7f8447722a5f4baa5e67a8f7a0e403f3e7ce04540ff743a9c411c375 + checksum: 039aba98a697b920d6440c622aaa6104bb6076d65356b29dad4b3e6627ec0354da44f9621bafbeefd052cd4ac4d7f88c9a2ab094efcb50963cb352781d0c6428 languageName: node linkType: hard @@ -1446,15 +1446,15 @@ __metadata: version: 7.23.3 resolution: "@babel/preset-react@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/helper-validator-option": ^7.22.15 - "@babel/plugin-transform-react-display-name": ^7.23.3 - "@babel/plugin-transform-react-jsx": ^7.22.15 - "@babel/plugin-transform-react-jsx-development": ^7.22.5 - "@babel/plugin-transform-react-pure-annotations": ^7.23.3 + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/helper-validator-option": "npm:^7.22.15" + "@babel/plugin-transform-react-display-name": "npm:^7.23.3" + "@babel/plugin-transform-react-jsx": "npm:^7.22.15" + "@babel/plugin-transform-react-jsx-development": "npm:^7.22.5" + "@babel/plugin-transform-react-pure-annotations": "npm:^7.23.3" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 2d90961e7e627a74b44551e88ad36a440579e283e8dc27972bf2f50682152bbc77228673a3ea22c0e0d005b70cbc487eccd64897c5e5e0384e5ce18f300b21eb + checksum: ef6aef131b2f36e2883e9da0d832903643cb3c9ad4f32e04fb3eecae59e4221d583139e8d8f973e25c28d15aafa6b3e60fe9f25c5fd09abd3e2df03b8637bdd2 languageName: node linkType: hard @@ -1462,14 +1462,14 @@ __metadata: version: 7.23.3 resolution: "@babel/preset-typescript@npm:7.23.3" dependencies: - "@babel/helper-plugin-utils": ^7.22.5 - "@babel/helper-validator-option": ^7.22.15 - "@babel/plugin-syntax-jsx": ^7.23.3 - "@babel/plugin-transform-modules-commonjs": ^7.23.3 - "@babel/plugin-transform-typescript": ^7.23.3 + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/helper-validator-option": "npm:^7.22.15" + "@babel/plugin-syntax-jsx": "npm:^7.23.3" + "@babel/plugin-transform-modules-commonjs": "npm:^7.23.3" + "@babel/plugin-transform-typescript": "npm:^7.23.3" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 105a2d39bbc464da0f7e1ad7f535c77c5f62d6b410219355b20e552e7d29933567a5c55339b5d0aec1a5c7a0a7dfdf1b54aae601a4fe15a157d54dcbfcb3e854 + checksum: c4add0f3fcbb3f4a305c48db9ccb32694f1308ed9971ccbc1a8a3c76d5a13726addb3c667958092287d7aa080186c5c83dbfefa55eacf94657e6cde39e172848 languageName: node linkType: hard @@ -1477,11 +1477,11 @@ __metadata: version: 7.23.7 resolution: "@babel/register@npm:7.23.7" dependencies: - clone-deep: ^4.0.1 - find-cache-dir: ^2.0.0 - make-dir: ^2.1.0 - pirates: ^4.0.6 - source-map-support: ^0.5.16 + clone-deep: "npm:^4.0.1" + find-cache-dir: "npm:^2.0.0" + make-dir: "npm:^2.1.0" + pirates: "npm:^4.0.6" + source-map-support: "npm:^0.5.16" peerDependencies: "@babel/core": ^7.0.0-0 checksum: c72a6d4856ef04f13490370d805854d2d98a77786bfaec7d85e2c585e1217011c4f3df18197a890e14520906c9111bef95551ba1a9b59c88df4dfc2dfe2c8d1b @@ -1491,7 +1491,7 @@ __metadata: "@babel/regjsgen@npm:^0.8.0": version: 0.8.0 resolution: "@babel/regjsgen@npm:0.8.0" - checksum: 89c338fee774770e5a487382170711014d49a68eb281e74f2b5eac88f38300a4ad545516a7786a8dd5702e9cf009c94c2f582d200f077ac5decd74c56b973730 + checksum: c57fb730b17332b7572574b74364a77d70faa302a281a62819476fa3b09822974fd75af77aea603ad77378395be64e81f89f0e800bf86cbbf21652d49ce12ee8 languageName: node linkType: hard @@ -1499,8 +1499,8 @@ __metadata: version: 7.23.9 resolution: "@babel/runtime@npm:7.23.9" dependencies: - regenerator-runtime: ^0.14.0 - checksum: 6bbebe8d27c0c2dd275d1ac197fc1a6c00e18dab68cc7aaff0adc3195b45862bae9c4cc58975629004b0213955b2ed91e99eccb3d9b39cabea246c657323d667 + regenerator-runtime: "npm:^0.14.0" + checksum: 9a520fe1bf72249f7dd60ff726434251858de15cccfca7aa831bd19d0d3fb17702e116ead82724659b8da3844977e5e13de2bae01eb8a798f2823a669f122be6 languageName: node linkType: hard @@ -1508,10 +1508,10 @@ __metadata: version: 7.23.9 resolution: "@babel/template@npm:7.23.9" dependencies: - "@babel/code-frame": ^7.23.5 - "@babel/parser": ^7.23.9 - "@babel/types": ^7.23.9 - checksum: 6e67414c0f7125d7ecaf20c11fab88085fa98a96c3ef10da0a61e962e04fdf3a18a496a66047005ddd1bb682a7cc7842d556d1db2f3f3f6ccfca97d5e445d342 + "@babel/code-frame": "npm:^7.23.5" + "@babel/parser": "npm:^7.23.9" + "@babel/types": "npm:^7.23.9" + checksum: 1b011ba9354dc2e646561d54b6862e0df51760e6179faadd79be05825b0b6da04911e4e192df943f1766748da3037fd8493615b38707f7cadb0cf0c96601c170 languageName: node linkType: hard @@ -1519,17 +1519,17 @@ __metadata: version: 7.23.9 resolution: "@babel/traverse@npm:7.23.9" dependencies: - "@babel/code-frame": ^7.23.5 - "@babel/generator": ^7.23.6 - "@babel/helper-environment-visitor": ^7.22.20 - "@babel/helper-function-name": ^7.23.0 - "@babel/helper-hoist-variables": ^7.22.5 - "@babel/helper-split-export-declaration": ^7.22.6 - "@babel/parser": ^7.23.9 - "@babel/types": ^7.23.9 - debug: ^4.3.1 - globals: ^11.1.0 - checksum: a932f7aa850e158c00c97aad22f639d48c72805c687290f6a73e30c5c4957c07f5d28310c9bf59648e2980fe6c9d16adeb2ff92a9ca0f97fa75739c1328fc6c3 + "@babel/code-frame": "npm:^7.23.5" + "@babel/generator": "npm:^7.23.6" + "@babel/helper-environment-visitor": "npm:^7.22.20" + "@babel/helper-function-name": "npm:^7.23.0" + "@babel/helper-hoist-variables": "npm:^7.22.5" + "@babel/helper-split-export-declaration": "npm:^7.22.6" + "@babel/parser": "npm:^7.23.9" + "@babel/types": "npm:^7.23.9" + debug: "npm:^4.3.1" + globals: "npm:^11.1.0" + checksum: e2bb845f7f229feb7c338f7e150f5f1abc5395dcd3a6a47f63a25242ec3ec6b165f04a6df7d4849468547faee34eb3cf52487eb0bd867a7d3c42fec2a648266f languageName: node linkType: hard @@ -1537,24 +1537,24 @@ __metadata: version: 7.23.9 resolution: "@babel/types@npm:7.23.9" dependencies: - "@babel/helper-string-parser": ^7.23.4 - "@babel/helper-validator-identifier": ^7.22.20 - to-fast-properties: ^2.0.0 - checksum: 0a9b008e9bfc89beb8c185e620fa0f8ed6c771f1e1b2e01e1596870969096fec7793898a1d64a035176abf1dd13e2668ee30bf699f2d92c210a8128f4b151e65 + "@babel/helper-string-parser": "npm:^7.23.4" + "@babel/helper-validator-identifier": "npm:^7.22.20" + to-fast-properties: "npm:^2.0.0" + checksum: bed9634e5fd0f9dc63c84cfa83316c4cb617192db9fedfea464fca743affe93736d7bf2ebf418ee8358751a9d388e303af87a0c050cb5d87d5870c1b0154f6cb languageName: node linkType: hard "@base2/pretty-print-object@npm:1.0.1": version: 1.0.1 resolution: "@base2/pretty-print-object@npm:1.0.1" - checksum: 1e8a5af578037a9d47d72f815983f9e4efb038e5f03e7635fc893194c5daa723215d71af33267893a9b618656c8eaea7be931b1c063c9b066a40994be0d23545 + checksum: c1b78a521ac712baa076589f3bc81318d07c34a5747e9177b6af37043592252587d98f9b7b59ec174968c6bea31a99fe4d7884121173a449b75fe602b7eb2839 languageName: node linkType: hard "@bcoe/v8-coverage@npm:^0.2.3": version: 0.2.3 resolution: "@bcoe/v8-coverage@npm:0.2.3" - checksum: 850f9305536d0f2bd13e9e0881cb5f02e4f93fad1189f7b2d4bebf694e3206924eadee1068130d43c11b750efcc9405f88a8e42ef098b6d75239c0f047de1a27 + checksum: 1a1f0e356a3bb30b5f1ced6f79c413e6ebacf130421f15fac5fcd8be5ddf98aedb4404d7f5624e3285b700e041f9ef938321f3ca4d359d5b716f96afa120d88d languageName: node linkType: hard @@ -1568,14 +1568,32 @@ __metadata: "@colors/colors@npm:1.5.0": version: 1.5.0 resolution: "@colors/colors@npm:1.5.0" - checksum: d64d5260bed1d5012ae3fc617d38d1afc0329fec05342f4e6b838f46998855ba56e0a73833f4a80fa8378c84810da254f76a8a19c39d038260dc06dc4e007425 + checksum: 9d226461c1e91e95f067be2bdc5e6f99cfe55a721f45afb44122e23e4b8602eeac4ff7325af6b5a369f36396ee1514d3809af3f57769066d80d83790d8e53339 + languageName: node + linkType: hard + +"@colors/colors@npm:1.6.0, @colors/colors@npm:^1.6.0": + version: 1.6.0 + resolution: "@colors/colors@npm:1.6.0" + checksum: 66d00284a3a9a21e5e853b256942e17edbb295f4bd7b9aa7ef06bbb603568d5173eb41b0f64c1e51748bc29d382a23a67d99956e57e7431c64e47e74324182d9 + languageName: node + linkType: hard + +"@dabh/diagnostics@npm:^2.0.2": + version: 2.0.3 + resolution: "@dabh/diagnostics@npm:2.0.3" + dependencies: + colorspace: "npm:1.1.x" + enabled: "npm:2.0.x" + kuler: "npm:^2.0.0" + checksum: 14e449a7f42f063f959b472f6ce02d16457a756e852a1910aaa831b63fc21d86f6c32b2a1aa98a4835b856548c926643b51062d241fb6e9b2b7117996053e6b9 languageName: node linkType: hard "@discoveryjs/json-ext@npm:^0.5.3": version: 0.5.7 resolution: "@discoveryjs/json-ext@npm:0.5.7" - checksum: 2176d301cc258ea5c2324402997cf8134ebb212469c0d397591636cea8d3c02f2b3cf9fd58dcb748c7a0dade77ebdc1b10284fa63e608c033a1db52fddc69918 + checksum: b95682a852448e8ef50d6f8e3b7ba288aab3fd98a2bafbe46881a3db0c6e7248a2debe9e1ee0d4137c521e4743ca5bbcb1c0765c9d7b3e0ef53231506fec42b4 languageName: node linkType: hard @@ -1584,7 +1602,7 @@ __metadata: resolution: "@emotion/use-insertion-effect-with-fallbacks@npm:1.0.1" peerDependencies: react: ">=16.8.0" - checksum: 700b6e5bbb37a9231f203bb3af11295eed01d73b2293abece0bc2a2237015e944d7b5114d4887ad9a79776504aa51ed2a8b0ddbc117c54495dd01a6b22f93786 + checksum: 7d7ead9ba3f615510f550aea67815281ec5a5487de55aafc250f820317afc1fd419bd9e9e27602a0206ec5c152f13dc6130bccad312c1036706c584c65d66ef7 languageName: node linkType: hard @@ -1746,17 +1764,17 @@ __metadata: version: 4.4.0 resolution: "@eslint-community/eslint-utils@npm:4.4.0" dependencies: - eslint-visitor-keys: ^3.3.0 + eslint-visitor-keys: "npm:^3.3.0" peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - checksum: cdfe3ae42b4f572cbfb46d20edafe6f36fc5fb52bf2d90875c58aefe226892b9677fef60820e2832caf864a326fe4fc225714c46e8389ccca04d5f9288aabd22 + checksum: 8d70bcdcd8cd279049183aca747d6c2ed7092a5cf0cf5916faac1ef37ffa74f0c245c2a3a3d3b9979d9dfdd4ca59257b4c5621db699d637b847a2c5e02f491c2 languageName: node linkType: hard "@eslint-community/regexpp@npm:^4.5.1, @eslint-community/regexpp@npm:^4.6.1": version: 4.10.0 resolution: "@eslint-community/regexpp@npm:4.10.0" - checksum: 2a6e345429ea8382aaaf3a61f865cae16ed44d31ca917910033c02dc00d505d939f10b81e079fa14d43b51499c640138e153b7e40743c4c094d9df97d4e56f7b + checksum: 8c36169c815fc5d726078e8c71a5b592957ee60d08c6470f9ce0187c8046af1a00afbda0a065cc40ff18d5d83f82aed9793c6818f7304a74a7488dc9f3ecbd42 languageName: node linkType: hard @@ -1764,30 +1782,30 @@ __metadata: version: 2.1.4 resolution: "@eslint/eslintrc@npm:2.1.4" dependencies: - ajv: ^6.12.4 - debug: ^4.3.2 - espree: ^9.6.0 - globals: ^13.19.0 - ignore: ^5.2.0 - import-fresh: ^3.2.1 - js-yaml: ^4.1.0 - minimatch: ^3.1.2 - strip-json-comments: ^3.1.1 - checksum: 10957c7592b20ca0089262d8c2a8accbad14b4f6507e35416c32ee6b4dbf9cad67dfb77096bbd405405e9ada2b107f3797fe94362e1c55e0b09d6e90dd149127 + ajv: "npm:^6.12.4" + debug: "npm:^4.3.2" + espree: "npm:^9.6.0" + globals: "npm:^13.19.0" + ignore: "npm:^5.2.0" + import-fresh: "npm:^3.2.1" + js-yaml: "npm:^4.1.0" + minimatch: "npm:^3.1.2" + strip-json-comments: "npm:^3.1.1" + checksum: 7a3b14f4b40fc1a22624c3f84d9f467a3d9ea1ca6e9a372116cb92507e485260359465b58e25bcb6c9981b155416b98c9973ad9b796053fd7b3f776a6946bce8 languageName: node linkType: hard "@eslint/js@npm:8.57.0": version: 8.57.0 resolution: "@eslint/js@npm:8.57.0" - checksum: 315dc65b0e9893e2bff139bddace7ea601ad77ed47b4550e73da8c9c2d2766c7a575c3cddf17ef85b8fd6a36ff34f91729d0dcca56e73ca887c10df91a41b0bb + checksum: 3c501ce8a997cf6cbbaf4ed358af5492875e3550c19b9621413b82caa9ae5382c584b0efa79835639e6e0ddaa568caf3499318e5bdab68643ef4199dce5eb0a0 languageName: node linkType: hard "@fal-works/esbuild-plugin-global-externals@npm:^2.1.2": version: 2.1.2 resolution: "@fal-works/esbuild-plugin-global-externals@npm:2.1.2" - checksum: c59715902b9062aa7ff38973f298b509499fd146dbf564dc338b3f9e896da5bffb4ca676c27587fde79b3586003e24d65960acb62f009bca43dca34c76f8cbf7 + checksum: fd68714cccfbd33a8ec31d11ac7c6373100a5e1b8e31941a45c723c802feccb0a00dde946f55cc91d58bff77d405adc2064b22f0faf5ee165968965e5da758a1 languageName: node linkType: hard @@ -1795,8 +1813,8 @@ __metadata: version: 1.6.0 resolution: "@floating-ui/core@npm:1.6.0" dependencies: - "@floating-ui/utils": ^0.2.1 - checksum: 2e25c53b0c124c5c9577972f8ae21d081f2f7895e6695836a53074463e8c65b47722744d6d2b5a993164936da006a268bcfe87fe68fd24dc235b1cb86bed3127 + "@floating-ui/utils": "npm:^0.2.1" + checksum: d6a47cacde193cd8ccb4c268b91ccc4ca254dffaec6242b07fd9bcde526044cc976d27933a7917f9a671de0a0e27f8d358f46400677dbd0c8199de293e9746e1 languageName: node linkType: hard @@ -1804,9 +1822,9 @@ __metadata: version: 1.6.3 resolution: "@floating-ui/dom@npm:1.6.3" dependencies: - "@floating-ui/core": ^1.0.0 - "@floating-ui/utils": ^0.2.0 - checksum: 81cbb18ece3afc37992f436e469e7fabab2e433248e46fff4302d12493a175b0c64310f8a971e6e1eda7218df28ace6b70237b0f3c22fe12a21bba05b5579555 + "@floating-ui/core": "npm:^1.0.0" + "@floating-ui/utils": "npm:^0.2.0" + checksum: 83e97076c7a5f55c3506f574bc53f03d38bed6eb8181920c8733076889371e287e9ae6f28c520a076967759b9b6ff425362832a5cdf16a999069530dbb9cce53 languageName: node linkType: hard @@ -1814,11 +1832,11 @@ __metadata: version: 2.0.8 resolution: "@floating-ui/react-dom@npm:2.0.8" dependencies: - "@floating-ui/dom": ^1.6.1 + "@floating-ui/dom": "npm:^1.6.1" peerDependencies: react: ">=16.8.0" react-dom: ">=16.8.0" - checksum: 5da7f13a69281e38859a3203a608fe9de1d850b332b355c10c0c2427c7b7209a0374c10f6295b6577c1a70237af8b678340bd4cc0a4b1c66436a94755d81e526 + checksum: e57b2a498aecf8de0ec28adf434257fca7893bd9bd7e78b63ac98c63b29b9fc086fc175630154352f3610f5c4a0d329823837f4f6c235cc0459fde6417065590 languageName: node linkType: hard @@ -1826,20 +1844,20 @@ __metadata: version: 0.26.9 resolution: "@floating-ui/react@npm:0.26.9" dependencies: - "@floating-ui/react-dom": ^2.0.8 - "@floating-ui/utils": ^0.2.1 - tabbable: ^6.0.1 + "@floating-ui/react-dom": "npm:^2.0.8" + "@floating-ui/utils": "npm:^0.2.1" + tabbable: "npm:^6.0.1" peerDependencies: react: ">=16.8.0" react-dom: ">=16.8.0" - checksum: 0f2dd6c7bfec77e810e916dbe11aab48ecfb7ea55715fc876736b03ac9da107d863c18b4d22ee0b43a87e354a06c806e5eca38243e3fe90d042ef06722d4f5f3 + checksum: 997f5a471ac6080c5162ad86fbb8e5bc0eca9335c40d8445597a90ba645e5d35ee796c29bdc66d868182afe5901804ecd52b82560332ae123b0c269400421e63 languageName: node linkType: hard "@floating-ui/utils@npm:^0.2.0, @floating-ui/utils@npm:^0.2.1": version: 0.2.1 resolution: "@floating-ui/utils@npm:0.2.1" - checksum: 9ed4380653c7c217cd6f66ae51f20fdce433730dbc77f95b5abfb5a808f5fdb029c6ae249b4e0490a816f2453aa6e586d9a873cd157fdba4690f65628efc6e06 + checksum: 33c9ab346e7b05c5a1e6a95bc902aafcfc2c9d513a147e2491468843bd5607531b06d0b9aa56aa491cbf22a6c2495c18ccfc4c0344baec54a689a7bb8e4898d6 languageName: node linkType: hard @@ -1847,24 +1865,24 @@ __metadata: version: 0.11.14 resolution: "@humanwhocodes/config-array@npm:0.11.14" dependencies: - "@humanwhocodes/object-schema": ^2.0.2 - debug: ^4.3.1 - minimatch: ^3.0.5 - checksum: 861ccce9eaea5de19546653bccf75bf09fe878bc39c3aab00aeee2d2a0e654516adad38dd1098aab5e3af0145bbcbf3f309bdf4d964f8dab9dcd5834ae4c02f2 + "@humanwhocodes/object-schema": "npm:^2.0.2" + debug: "npm:^4.3.1" + minimatch: "npm:^3.0.5" + checksum: 3ffb24ecdfab64014a230e127118d50a1a04d11080cbb748bc21629393d100850496456bbcb4e8c438957fe0934430d731042f1264d6a167b62d32fc2863580a languageName: node linkType: hard "@humanwhocodes/module-importer@npm:^1.0.1": version: 1.0.1 resolution: "@humanwhocodes/module-importer@npm:1.0.1" - checksum: 0fd22007db8034a2cdf2c764b140d37d9020bbfce8a49d3ec5c05290e77d4b0263b1b972b752df8c89e5eaa94073408f2b7d977aed131faf6cf396ebb5d7fb61 + checksum: e993950e346331e5a32eefb27948ecdee2a2c4ab3f072b8f566cd213ef485dd50a3ca497050608db91006f5479e43f91a439aef68d2a313bd3ded06909c7c5b3 languageName: node linkType: hard "@humanwhocodes/object-schema@npm:^2.0.2": version: 2.0.2 resolution: "@humanwhocodes/object-schema@npm:2.0.2" - checksum: 2fc11503361b5fb4f14714c700c02a3f4c7c93e9acd6b87a29f62c522d90470f364d6161b03d1cc618b979f2ae02aed1106fd29d302695d8927e2fc8165ba8ee + checksum: ef915e3e2f34652f3d383b28a9a99cfea476fa991482370889ab14aac8ecd2b38d47cc21932526c6d949da0daf4a4a6bf629d30f41b0caca25e146819cbfa70e languageName: node linkType: hard @@ -1872,13 +1890,13 @@ __metadata: version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" dependencies: - string-width: ^5.1.2 + string-width: "npm:^5.1.2" string-width-cjs: "npm:string-width@^4.2.0" - strip-ansi: ^7.0.1 + strip-ansi: "npm:^7.0.1" strip-ansi-cjs: "npm:strip-ansi@^6.0.1" - wrap-ansi: ^8.1.0 + wrap-ansi: "npm:^8.1.0" wrap-ansi-cjs: "npm:wrap-ansi@^7.0.0" - checksum: 4a473b9b32a7d4d3cfb7a614226e555091ff0c5a29a1734c28c72a182c2f6699b26fc6b5c2131dfd841e86b185aea714c72201d7c98c2fba5f17709333a67aeb + checksum: e9ed5fd27c3aec1095e3a16e0c0cf148d1fee55a38665c35f7b3f86a9b5d00d042ddaabc98e8a1cb7463b9378c15f22a94eb35e99469c201453eb8375191f243 languageName: node linkType: hard @@ -1886,19 +1904,19 @@ __metadata: version: 1.1.0 resolution: "@istanbuljs/load-nyc-config@npm:1.1.0" dependencies: - camelcase: ^5.3.1 - find-up: ^4.1.0 - get-package-type: ^0.1.0 - js-yaml: ^3.13.1 - resolve-from: ^5.0.0 - checksum: d578da5e2e804d5c93228450a1380e1a3c691de4953acc162f387b717258512a3e07b83510a936d9fab03eac90817473917e24f5d16297af3867f59328d58568 + camelcase: "npm:^5.3.1" + find-up: "npm:^4.1.0" + get-package-type: "npm:^0.1.0" + js-yaml: "npm:^3.13.1" + resolve-from: "npm:^5.0.0" + checksum: b000a5acd8d4fe6e34e25c399c8bdbb5d3a202b4e10416e17bfc25e12bab90bb56d33db6089ae30569b52686f4b35ff28ef26e88e21e69821d2b85884bd055b8 languageName: node linkType: hard "@istanbuljs/schema@npm:^0.1.2, @istanbuljs/schema@npm:^0.1.3": version: 0.1.3 resolution: "@istanbuljs/schema@npm:0.1.3" - checksum: 5282759d961d61350f33d9118d16bcaed914ebf8061a52f4fa474b2cb08720c9c81d165e13b82f2e5a8a212cc5af482f0c6fc1ac27b9e067e5394c9a6ed186c9 + checksum: a9b1e49acdf5efc2f5b2359f2df7f90c5c725f2656f16099e8b2cd3a000619ecca9fc48cf693ba789cf0fd989f6e0df6a22bc05574be4223ecdbb7997d04384b languageName: node linkType: hard @@ -1906,13 +1924,13 @@ __metadata: version: 29.7.0 resolution: "@jest/console@npm:29.7.0" dependencies: - "@jest/types": ^29.6.3 - "@types/node": "*" - chalk: ^4.0.0 - jest-message-util: ^29.7.0 - jest-util: ^29.7.0 - slash: ^3.0.0 - checksum: 0e3624e32c5a8e7361e889db70b170876401b7d70f509a2538c31d5cd50deb0c1ae4b92dc63fe18a0902e0a48c590c21d53787a0df41a52b34fa7cab96c384d6 + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + slash: "npm:^3.0.0" + checksum: 4a80c750e8a31f344233cb9951dee9b77bf6b89377cb131f8b3cde07ff218f504370133a5963f6a786af4d2ce7f85642db206ff7a15f99fe58df4c38ac04899e languageName: node linkType: hard @@ -1920,40 +1938,40 @@ __metadata: version: 29.7.0 resolution: "@jest/core@npm:29.7.0" dependencies: - "@jest/console": ^29.7.0 - "@jest/reporters": ^29.7.0 - "@jest/test-result": ^29.7.0 - "@jest/transform": ^29.7.0 - "@jest/types": ^29.6.3 - "@types/node": "*" - ansi-escapes: ^4.2.1 - chalk: ^4.0.0 - ci-info: ^3.2.0 - exit: ^0.1.2 - graceful-fs: ^4.2.9 - jest-changed-files: ^29.7.0 - jest-config: ^29.7.0 - jest-haste-map: ^29.7.0 - jest-message-util: ^29.7.0 - jest-regex-util: ^29.6.3 - jest-resolve: ^29.7.0 - jest-resolve-dependencies: ^29.7.0 - jest-runner: ^29.7.0 - jest-runtime: ^29.7.0 - jest-snapshot: ^29.7.0 - jest-util: ^29.7.0 - jest-validate: ^29.7.0 - jest-watcher: ^29.7.0 - micromatch: ^4.0.4 - pretty-format: ^29.7.0 - slash: ^3.0.0 - strip-ansi: ^6.0.0 + "@jest/console": "npm:^29.7.0" + "@jest/reporters": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + ansi-escapes: "npm:^4.2.1" + chalk: "npm:^4.0.0" + ci-info: "npm:^3.2.0" + exit: "npm:^0.1.2" + graceful-fs: "npm:^4.2.9" + jest-changed-files: "npm:^29.7.0" + jest-config: "npm:^29.7.0" + jest-haste-map: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-resolve-dependencies: "npm:^29.7.0" + jest-runner: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + jest-watcher: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + pretty-format: "npm:^29.7.0" + slash: "npm:^3.0.0" + strip-ansi: "npm:^6.0.0" peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: node-notifier: optional: true - checksum: af759c9781cfc914553320446ce4e47775ae42779e73621c438feb1e4231a5d4862f84b1d8565926f2d1aab29b3ec3dcfdc84db28608bdf5f29867124ebcfc0d + checksum: ab6ac2e562d083faac7d8152ec1cc4eccc80f62e9579b69ed40aedf7211a6b2d57024a6cd53c4e35fd051c39a236e86257d1d99ebdb122291969a0a04563b51e languageName: node linkType: hard @@ -1961,11 +1979,11 @@ __metadata: version: 29.7.0 resolution: "@jest/environment@npm:29.7.0" dependencies: - "@jest/fake-timers": ^29.7.0 - "@jest/types": ^29.6.3 - "@types/node": "*" - jest-mock: ^29.7.0 - checksum: 6fb398143b2543d4b9b8d1c6dbce83fa5247f84f550330604be744e24c2bd2178bb893657d62d1b97cf2f24baf85c450223f8237cccb71192c36a38ea2272934 + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + jest-mock: "npm:^29.7.0" + checksum: 90b5844a9a9d8097f2cf107b1b5e57007c552f64315da8c1f51217eeb0a9664889d3f145cdf8acf23a84f4d8309a6675e27d5b059659a004db0ea9546d1c81a8 languageName: node linkType: hard @@ -1973,8 +1991,8 @@ __metadata: version: 29.7.0 resolution: "@jest/expect-utils@npm:29.7.0" dependencies: - jest-get-type: ^29.6.3 - checksum: 75eb177f3d00b6331bcaa057e07c0ccb0733a1d0a1943e1d8db346779039cb7f103789f16e502f888a3096fb58c2300c38d1f3748b36a7fa762eb6f6d1b160ed + jest-get-type: "npm:^29.6.3" + checksum: ef8d379778ef574a17bde2801a6f4469f8022a46a5f9e385191dc73bb1fc318996beaed4513fbd7055c2847227a1bed2469977821866534593a6e52a281499ee languageName: node linkType: hard @@ -1982,9 +2000,9 @@ __metadata: version: 29.7.0 resolution: "@jest/expect@npm:29.7.0" dependencies: - expect: ^29.7.0 - jest-snapshot: ^29.7.0 - checksum: a01cb85fd9401bab3370618f4b9013b90c93536562222d920e702a0b575d239d74cecfe98010aaec7ad464f67cf534a353d92d181646a4b792acaa7e912ae55e + expect: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + checksum: fea6c3317a8da5c840429d90bfe49d928e89c9e89fceee2149b93a11b7e9c73d2f6e4d7cdf647163da938fc4e2169e4490be6bae64952902bc7a701033fd4880 languageName: node linkType: hard @@ -1992,13 +2010,13 @@ __metadata: version: 29.7.0 resolution: "@jest/fake-timers@npm:29.7.0" dependencies: - "@jest/types": ^29.6.3 - "@sinonjs/fake-timers": ^10.0.2 - "@types/node": "*" - jest-message-util: ^29.7.0 - jest-mock: ^29.7.0 - jest-util: ^29.7.0 - checksum: caf2bbd11f71c9241b458d1b5a66cbe95debc5a15d96442444b5d5c7ba774f523c76627c6931cca5e10e76f0d08761f6f1f01a608898f4751a0eee54fc3d8d00 + "@jest/types": "npm:^29.6.3" + "@sinonjs/fake-timers": "npm:^10.0.2" + "@types/node": "npm:*" + jest-message-util: "npm:^29.7.0" + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 9b394e04ffc46f91725ecfdff34c4e043eb7a16e1d78964094c9db3fde0b1c8803e45943a980e8c740d0a3d45661906de1416ca5891a538b0660481a3a828c27 languageName: node linkType: hard @@ -2006,10 +2024,10 @@ __metadata: version: 29.7.0 resolution: "@jest/globals@npm:29.7.0" dependencies: - "@jest/environment": ^29.7.0 - "@jest/expect": ^29.7.0 - "@jest/types": ^29.6.3 - jest-mock: ^29.7.0 + "@jest/environment": "npm:^29.7.0" + "@jest/expect": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + jest-mock: "npm:^29.7.0" checksum: 97dbb9459135693ad3a422e65ca1c250f03d82b2a77f6207e7fa0edd2c9d2015fbe4346f3dc9ebff1678b9d8da74754d4d440b7837497f8927059c0642a22123 languageName: node linkType: hard @@ -2018,36 +2036,36 @@ __metadata: version: 29.7.0 resolution: "@jest/reporters@npm:29.7.0" dependencies: - "@bcoe/v8-coverage": ^0.2.3 - "@jest/console": ^29.7.0 - "@jest/test-result": ^29.7.0 - "@jest/transform": ^29.7.0 - "@jest/types": ^29.6.3 - "@jridgewell/trace-mapping": ^0.3.18 - "@types/node": "*" - chalk: ^4.0.0 - collect-v8-coverage: ^1.0.0 - exit: ^0.1.2 - glob: ^7.1.3 - graceful-fs: ^4.2.9 - istanbul-lib-coverage: ^3.0.0 - istanbul-lib-instrument: ^6.0.0 - istanbul-lib-report: ^3.0.0 - istanbul-lib-source-maps: ^4.0.0 - istanbul-reports: ^3.1.3 - jest-message-util: ^29.7.0 - jest-util: ^29.7.0 - jest-worker: ^29.7.0 - slash: ^3.0.0 - string-length: ^4.0.1 - strip-ansi: ^6.0.0 - v8-to-istanbul: ^9.0.1 + "@bcoe/v8-coverage": "npm:^0.2.3" + "@jest/console": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@jridgewell/trace-mapping": "npm:^0.3.18" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + collect-v8-coverage: "npm:^1.0.0" + exit: "npm:^0.1.2" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + istanbul-lib-coverage: "npm:^3.0.0" + istanbul-lib-instrument: "npm:^6.0.0" + istanbul-lib-report: "npm:^3.0.0" + istanbul-lib-source-maps: "npm:^4.0.0" + istanbul-reports: "npm:^3.1.3" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" + slash: "npm:^3.0.0" + string-length: "npm:^4.0.1" + strip-ansi: "npm:^6.0.0" + v8-to-istanbul: "npm:^9.0.1" peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: node-notifier: optional: true - checksum: 7eadabd62cc344f629024b8a268ecc8367dba756152b761bdcb7b7e570a3864fc51b2a9810cd310d85e0a0173ef002ba4528d5ea0329fbf66ee2a3ada9c40455 + checksum: a17d1644b26dea14445cedd45567f4ba7834f980be2ef74447204e14238f121b50d8b858fde648083d2cd8f305f81ba434ba49e37a5f4237a6f2a61180cc73dc languageName: node linkType: hard @@ -2055,7 +2073,7 @@ __metadata: version: 29.6.3 resolution: "@jest/schemas@npm:29.6.3" dependencies: - "@sinclair/typebox": ^0.27.8 + "@sinclair/typebox": "npm:^0.27.8" checksum: 910040425f0fc93cd13e68c750b7885590b8839066dfa0cd78e7def07bbb708ad869381f725945d66f2284de5663bbecf63e8fdd856e2ae6e261ba30b1687e93 languageName: node linkType: hard @@ -2064,9 +2082,9 @@ __metadata: version: 29.6.3 resolution: "@jest/source-map@npm:29.6.3" dependencies: - "@jridgewell/trace-mapping": ^0.3.18 - callsites: ^3.0.0 - graceful-fs: ^4.2.9 + "@jridgewell/trace-mapping": "npm:^0.3.18" + callsites: "npm:^3.0.0" + graceful-fs: "npm:^4.2.9" checksum: bcc5a8697d471396c0003b0bfa09722c3cd879ad697eb9c431e6164e2ea7008238a01a07193dfe3cbb48b1d258eb7251f6efcea36f64e1ebc464ea3c03ae2deb languageName: node linkType: hard @@ -2075,11 +2093,11 @@ __metadata: version: 29.7.0 resolution: "@jest/test-result@npm:29.7.0" dependencies: - "@jest/console": ^29.7.0 - "@jest/types": ^29.6.3 - "@types/istanbul-lib-coverage": ^2.0.0 - collect-v8-coverage: ^1.0.0 - checksum: 67b6317d526e335212e5da0e768e3b8ab8a53df110361b80761353ad23b6aea4432b7c5665bdeb87658ea373b90fb1afe02ed3611ef6c858c7fba377505057fa + "@jest/console": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/istanbul-lib-coverage": "npm:^2.0.0" + collect-v8-coverage: "npm:^1.0.0" + checksum: c073ab7dfe3c562bff2b8fee6cc724ccc20aa96bcd8ab48ccb2aa309b4c0c1923a9e703cea386bd6ae9b71133e92810475bb9c7c22328fc63f797ad3324ed189 languageName: node linkType: hard @@ -2087,11 +2105,11 @@ __metadata: version: 29.7.0 resolution: "@jest/test-sequencer@npm:29.7.0" dependencies: - "@jest/test-result": ^29.7.0 - graceful-fs: ^4.2.9 - jest-haste-map: ^29.7.0 - slash: ^3.0.0 - checksum: 73f43599017946be85c0b6357993b038f875b796e2f0950487a82f4ebcb115fa12131932dd9904026b4ad8be131fe6e28bd8d0aa93b1563705185f9804bff8bd + "@jest/test-result": "npm:^29.7.0" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + slash: "npm:^3.0.0" + checksum: 4420c26a0baa7035c5419b0892ff8ffe9a41b1583ec54a10db3037cd46a7e29dd3d7202f8aa9d376e9e53be5f8b1bc0d16e1de6880a6d319b033b01dc4c8f639 languageName: node linkType: hard @@ -2099,22 +2117,22 @@ __metadata: version: 29.7.0 resolution: "@jest/transform@npm:29.7.0" dependencies: - "@babel/core": ^7.11.6 - "@jest/types": ^29.6.3 - "@jridgewell/trace-mapping": ^0.3.18 - babel-plugin-istanbul: ^6.1.1 - chalk: ^4.0.0 - convert-source-map: ^2.0.0 - fast-json-stable-stringify: ^2.1.0 - graceful-fs: ^4.2.9 - jest-haste-map: ^29.7.0 - jest-regex-util: ^29.6.3 - jest-util: ^29.7.0 - micromatch: ^4.0.4 - pirates: ^4.0.4 - slash: ^3.0.0 - write-file-atomic: ^4.0.2 - checksum: 0f8ac9f413903b3cb6d240102db848f2a354f63971ab885833799a9964999dd51c388162106a807f810071f864302cdd8e3f0c241c29ce02d85a36f18f3f40ab + "@babel/core": "npm:^7.11.6" + "@jest/types": "npm:^29.6.3" + "@jridgewell/trace-mapping": "npm:^0.3.18" + babel-plugin-istanbul: "npm:^6.1.1" + chalk: "npm:^4.0.0" + convert-source-map: "npm:^2.0.0" + fast-json-stable-stringify: "npm:^2.1.0" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-util: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + pirates: "npm:^4.0.4" + slash: "npm:^3.0.0" + write-file-atomic: "npm:^4.0.2" + checksum: 30f42293545ab037d5799c81d3e12515790bb58513d37f788ce32d53326d0d72ebf5b40f989e6896739aa50a5f77be44686e510966370d58511d5ad2637c68c1 languageName: node linkType: hard @@ -2122,12 +2140,12 @@ __metadata: version: 27.5.1 resolution: "@jest/types@npm:27.5.1" dependencies: - "@types/istanbul-lib-coverage": ^2.0.0 - "@types/istanbul-reports": ^3.0.0 - "@types/node": "*" - "@types/yargs": ^16.0.0 - chalk: ^4.0.0 - checksum: d1f43cc946d87543ddd79d49547aab2399481d34025d5c5f2025d3d99c573e1d9832fa83cef25e9d9b07a8583500229d15bbb07b8e233d127d911d133e2f14b1 + "@types/istanbul-lib-coverage": "npm:^2.0.0" + "@types/istanbul-reports": "npm:^3.0.0" + "@types/node": "npm:*" + "@types/yargs": "npm:^16.0.0" + chalk: "npm:^4.0.0" + checksum: d3ca1655673539c54665f3e9135dc70887feb6b667b956e712c38f42e513ae007d3593b8075aecea8f2db7119f911773010f17f93be070b1725fbc6225539b6e languageName: node linkType: hard @@ -2135,13 +2153,13 @@ __metadata: version: 29.6.3 resolution: "@jest/types@npm:29.6.3" dependencies: - "@jest/schemas": ^29.6.3 - "@types/istanbul-lib-coverage": ^2.0.0 - "@types/istanbul-reports": ^3.0.0 - "@types/node": "*" - "@types/yargs": ^17.0.8 - chalk: ^4.0.0 - checksum: a0bcf15dbb0eca6bdd8ce61a3fb055349d40268622a7670a3b2eb3c3dbafe9eb26af59938366d520b86907b9505b0f9b29b85cec11579a9e580694b87cd90fcc + "@jest/schemas": "npm:^29.6.3" + "@types/istanbul-lib-coverage": "npm:^2.0.0" + "@types/istanbul-reports": "npm:^3.0.0" + "@types/node": "npm:*" + "@types/yargs": "npm:^17.0.8" + chalk: "npm:^4.0.0" + checksum: f74bf512fd09bbe2433a2ad460b04668b7075235eea9a0c77d6a42222c10a79b9747dc2b2a623f140ed40d6865a2ed8f538f3cbb75169120ea863f29a7ed76cd languageName: node linkType: hard @@ -2149,17 +2167,17 @@ __metadata: version: 0.3.0 resolution: "@joshwooding/vite-plugin-react-docgen-typescript@npm:0.3.0" dependencies: - glob: ^7.2.0 - glob-promise: ^4.2.0 - magic-string: ^0.27.0 - react-docgen-typescript: ^2.2.2 + glob: "npm:^7.2.0" + glob-promise: "npm:^4.2.0" + magic-string: "npm:^0.27.0" + react-docgen-typescript: "npm:^2.2.2" peerDependencies: typescript: ">= 4.3.x" vite: ^3.0.0 || ^4.0.0 || ^5.0.0 peerDependenciesMeta: typescript: optional: true - checksum: 3fe2dc68dcb43920cc08bc5cc2937953bed1080e9c453dc3f513156b9a862fe6af0cda94b70272a4844a27964070129f8d0d31056211b1486a8fd9f6e1c20559 + checksum: 9237499394b1f5f1320c9a489dbf5db2ba4b1d68081bf767a08895b70d0d0830adb9f0f1e2c5c94202e5bee63fe031ea2b91870a6bc806ed5e370be6b06df2e8 languageName: node linkType: hard @@ -2167,17 +2185,17 @@ __metadata: version: 0.3.4 resolution: "@jridgewell/gen-mapping@npm:0.3.4" dependencies: - "@jridgewell/set-array": ^1.0.1 - "@jridgewell/sourcemap-codec": ^1.4.10 - "@jridgewell/trace-mapping": ^0.3.9 - checksum: 944080268f57919e354c57ea0c787c0b23d6b5be77440a468f8ccad24919e3fcefbd3833ce3b9836d89761503af4cbb750483acdb7fdc15213dde1c26430251d + "@jridgewell/set-array": "npm:^1.0.1" + "@jridgewell/sourcemap-codec": "npm:^1.4.10" + "@jridgewell/trace-mapping": "npm:^0.3.9" + checksum: c111a3d52fffd63a719035f9a453e0a9b4ba403a559b2f170f81e385ba5ed9cd4549575e166b20d3534e2aad9ea8473b8b17cee11b1c6595323be90d4e4c50d1 languageName: node linkType: hard "@jridgewell/resolve-uri@npm:^3.1.0": version: 3.1.2 resolution: "@jridgewell/resolve-uri@npm:3.1.2" - checksum: 83b85f72c59d1c080b4cbec0fef84528963a1b5db34e4370fa4bd1e3ff64a0d80e0cee7369d11d73c704e0286fb2865b530acac7a871088fbe92b5edf1000870 + checksum: 97106439d750a409c22c8bff822d648f6a71f3aa9bc8e5129efdc36343cd3096ddc4eeb1c62d2fe48e9bdd4db37b05d4646a17114ecebd3bbcacfa2de51c3c1d languageName: node linkType: hard @@ -2191,7 +2209,7 @@ __metadata: "@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.13, @jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.4.15": version: 1.4.15 resolution: "@jridgewell/sourcemap-codec@npm:1.4.15" - checksum: b881c7e503db3fc7f3c1f35a1dd2655a188cc51a3612d76efc8a6eb74728bef5606e6758ee77423e564092b4a518aba569bbb21c9bac5ab7a35b0c6ae7e344c8 + checksum: 89960ac087781b961ad918978975bcdf2051cd1741880469783c42de64239703eab9db5230d776d8e6a09d73bb5e4cb964e07d93ee6e2e7aea5a7d726e865c09 languageName: node linkType: hard @@ -2199,16 +2217,16 @@ __metadata: version: 0.3.23 resolution: "@jridgewell/trace-mapping@npm:0.3.23" dependencies: - "@jridgewell/resolve-uri": ^3.1.0 - "@jridgewell/sourcemap-codec": ^1.4.14 - checksum: a4ebaf196a500c9a65a667ba873f7836ba76b0581ed1c6bd33450b8093182f1c4aeb9c66a4467419cffd15694faecfa027ffbeca3aea5de3d322aa7d6bc41802 + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: eb8d167f8aeb3ac55e7726eda1bb6240787987fd66d480edbe15fc98ad594ec10cb584289f649e2074b9e117862c82efdec07db13850f3dc4cb242258bb2b67d languageName: node linkType: hard "@juggle/resize-observer@npm:^3.3.1": version: 3.4.0 resolution: "@juggle/resize-observer@npm:3.4.0" - checksum: 2505028c05cc2e17639fcad06218b1c4b60f932a4ebb4b41ab546ef8c157031ae377e3f560903801f6d01706dbefd4943b6c4704bf19ed86dfa1c62f1473a570 + checksum: 73d1d00ee9132fb6f0aea0531940a6b93603e935590bd450fc6285a328d906102eeeb95dea77b2edac0e779031a9708aa8c82502bd298ee4dd26e7dff48f397a languageName: node linkType: hard @@ -2216,11 +2234,11 @@ __metadata: version: 2.3.0 resolution: "@mdx-js/react@npm:2.3.0" dependencies: - "@types/mdx": ^2.0.0 - "@types/react": ">=16" + "@types/mdx": "npm:^2.0.0" + "@types/react": "npm:>=16" peerDependencies: react: ">=16" - checksum: f45fe779556e6cd9a787f711274480e0638b63c460f192ebdcd77cc07ffa61e23c98cb46dd46e577093e1cb4997a232a848d1fb0ba850ae204422cf603add524 + checksum: bce1cb1dde0a9a2b786cd9167b9e2bc0e3be52c195a4a79aaf1677470566d1fd2979d01baca2380c76aa4a1a27cd89f051484e595fdc4144a428d6af39bb667a languageName: node linkType: hard @@ -2228,10 +2246,10 @@ __metadata: version: 3.0.9 resolution: "@ndelangen/get-tarball@npm:3.0.9" dependencies: - gunzip-maybe: ^1.4.2 - pump: ^3.0.0 - tar-fs: ^2.1.1 - checksum: 7fa8ac40b4e85738a4ee6bf891bc27fce2445b65b4477e0ec86aed0fa62ab18bdf5d193ce04553ad9bfa639e1eef33b8b30da4ef3e7218f12bf95f24c8786e5b + gunzip-maybe: "npm:^1.4.2" + pump: "npm:^3.0.0" + tar-fs: "npm:^2.1.1" + checksum: 39697cef2b92f6e08e3590467cc6da88cd6757b2a27cb9208879c2316ed71d6be4608892ee0a86eb0343140da1a5df498f93a32c2aaf8f1fbd90f883f08b5f63 languageName: node linkType: hard @@ -2239,9 +2257,9 @@ __metadata: version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" dependencies: - "@nodelib/fs.stat": 2.0.5 - run-parallel: ^1.1.9 - checksum: a970d595bd23c66c880e0ef1817791432dbb7acbb8d44b7e7d0e7a22f4521260d4a83f7f9fd61d44fda4610105577f8f58a60718105fb38352baed612fd79e59 + "@nodelib/fs.stat": "npm:2.0.5" + run-parallel: "npm:^1.1.9" + checksum: 6ab2a9b8a1d67b067922c36f259e3b3dfd6b97b219c540877a4944549a4d49ea5ceba5663905ab5289682f1f3c15ff441d02f0447f620a42e1cb5e1937174d4b languageName: node linkType: hard @@ -2256,9 +2274,9 @@ __metadata: version: 1.2.8 resolution: "@nodelib/fs.walk@npm:1.2.8" dependencies: - "@nodelib/fs.scandir": 2.1.5 - fastq: ^1.6.0 - checksum: 190c643f156d8f8f277bf2a6078af1ffde1fd43f498f187c2db24d35b4b4b5785c02c7dc52e356497b9a1b65b13edc996de08de0b961c32844364da02986dc53 + "@nodelib/fs.scandir": "npm:2.1.5" + fastq: "npm:^1.6.0" + checksum: 40033e33e96e97d77fba5a238e4bba4487b8284678906a9f616b5579ddaf868a18874c0054a75402c9fbaaa033a25ceae093af58c9c30278e35c23c9479e79b0 languageName: node linkType: hard @@ -2266,12 +2284,12 @@ __metadata: version: 2.2.1 resolution: "@npmcli/agent@npm:2.2.1" dependencies: - agent-base: ^7.1.0 - http-proxy-agent: ^7.0.0 - https-proxy-agent: ^7.0.1 - lru-cache: ^10.0.1 - socks-proxy-agent: ^8.0.1 - checksum: c69aca42dbba393f517bc5777ee872d38dc98ea0e5e93c1f6d62b82b8fecdc177a57ea045f07dda1a770c592384b2dd92a5e79e21e2a7cf51c9159466a8f9c9b + agent-base: "npm:^7.1.0" + http-proxy-agent: "npm:^7.0.0" + https-proxy-agent: "npm:^7.0.1" + lru-cache: "npm:^10.0.1" + socks-proxy-agent: "npm:^8.0.1" + checksum: d4a48128f61e47f2f5c89315a5350e265dc619987e635bd62b52b29c7ed93536e724e721418c0ce352ceece86c13043c67aba1b70c3f5cc72fce6bb746706162 languageName: node linkType: hard @@ -2279,15 +2297,15 @@ __metadata: version: 3.1.0 resolution: "@npmcli/fs@npm:3.1.0" dependencies: - semver: ^7.3.5 - checksum: a50a6818de5fc557d0b0e6f50ec780a7a02ab8ad07e5ac8b16bf519e0ad60a144ac64f97d05c443c3367235d337182e1d012bbac0eb8dbae8dc7b40b193efd0e + semver: "npm:^7.3.5" + checksum: f3a7ab3a31de65e42aeb6ed03ed035ef123d2de7af4deb9d4a003d27acc8618b57d9fb9d259fe6c28ca538032a028f37337264388ba27d26d37fff7dde22476e languageName: node linkType: hard "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" - checksum: 6ad6a00fc4f2f2cfc6bff76fb1d88b8ee20bc0601e18ebb01b6d4be583733a860239a521a7fbca73b612e66705078809483549d2b18f370eb346c5155c8e4a0f + checksum: 115e8ceeec6bc69dff2048b35c0ab4f8bbee12d8bb6c1f4af758604586d802b6e669dcb02dda61d078de42c2b4ddce41b3d9e726d7daa6b4b850f4adbf7333ff languageName: node linkType: hard @@ -2302,7 +2320,7 @@ __metadata: version: 1.0.1 resolution: "@radix-ui/number@npm:1.0.1" dependencies: - "@babel/runtime": ^7.13.10 + "@babel/runtime": "npm:^7.13.10" checksum: 621ea8b7d4195d1a65a9c0aee918e8335e7f198088eec91577512c89c2ba3a3bab4a767cfb872a2b9c3092a78ff41cad9a924845a939f6bb87fe9356241ea0ea languageName: node linkType: hard @@ -2311,7 +2329,7 @@ __metadata: version: 1.0.1 resolution: "@radix-ui/primitive@npm:1.0.1" dependencies: - "@babel/runtime": ^7.13.10 + "@babel/runtime": "npm:^7.13.10" checksum: 2b93e161d3fdabe9a64919def7fa3ceaecf2848341e9211520c401181c9eaebb8451c630b066fad2256e5c639c95edc41de0ba59c40eff37e799918d019822d1 languageName: node linkType: hard @@ -2320,8 +2338,8 @@ __metadata: version: 1.0.3 resolution: "@radix-ui/react-arrow@npm:1.0.3" dependencies: - "@babel/runtime": ^7.13.10 - "@radix-ui/react-primitive": 1.0.3 + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/react-primitive": "npm:1.0.3" peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2340,11 +2358,11 @@ __metadata: version: 1.0.3 resolution: "@radix-ui/react-collection@npm:1.0.3" dependencies: - "@babel/runtime": ^7.13.10 - "@radix-ui/react-compose-refs": 1.0.1 - "@radix-ui/react-context": 1.0.1 - "@radix-ui/react-primitive": 1.0.3 - "@radix-ui/react-slot": 1.0.2 + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/react-compose-refs": "npm:1.0.1" + "@radix-ui/react-context": "npm:1.0.1" + "@radix-ui/react-primitive": "npm:1.0.3" + "@radix-ui/react-slot": "npm:1.0.2" peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2355,7 +2373,7 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: acfbc9b0b2c553d343c22f02c9f098bc5cfa99e6e48df91c0d671855013f8b877ade9c657b7420a7aa523b5aceadea32a60dd72c23b1291f415684fb45d00cff + checksum: 2ac740ab746f411942dc95100f1eb60b9a3670960a805e266533fa1bc7dec31a6dabddd746ab788ebd5a9c22b468e38922f39d30447925515f8e44f0a3b2e56c languageName: node linkType: hard @@ -2363,7 +2381,7 @@ __metadata: version: 1.0.1 resolution: "@radix-ui/react-compose-refs@npm:1.0.1" dependencies: - "@babel/runtime": ^7.13.10 + "@babel/runtime": "npm:^7.13.10" peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 @@ -2378,14 +2396,14 @@ __metadata: version: 1.0.1 resolution: "@radix-ui/react-context@npm:1.0.1" dependencies: - "@babel/runtime": ^7.13.10 + "@babel/runtime": "npm:^7.13.10" peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 peerDependenciesMeta: "@types/react": optional: true - checksum: 60e9b81d364f40c91a6213ec953f7c64fcd9d75721205a494a5815b3e5ae0719193429b62ee6c7002cd6aaf70f8c0e2f08bdbaba9ffcc233044d32b56d2127d1 + checksum: a02187a3bae3a0f1be5fab5ad19c1ef06ceff1028d957e4d9994f0186f594a9c3d93ee34bacb86d1fa8eb274493362944398e1c17054d12cb3b75384f9ae564b languageName: node linkType: hard @@ -2393,7 +2411,7 @@ __metadata: version: 1.0.1 resolution: "@radix-ui/react-direction@npm:1.0.1" dependencies: - "@babel/runtime": ^7.13.10 + "@babel/runtime": "npm:^7.13.10" peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 @@ -2408,12 +2426,12 @@ __metadata: version: 1.0.4 resolution: "@radix-ui/react-dismissable-layer@npm:1.0.4" dependencies: - "@babel/runtime": ^7.13.10 - "@radix-ui/primitive": 1.0.1 - "@radix-ui/react-compose-refs": 1.0.1 - "@radix-ui/react-primitive": 1.0.3 - "@radix-ui/react-use-callback-ref": 1.0.1 - "@radix-ui/react-use-escape-keydown": 1.0.3 + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/primitive": "npm:1.0.1" + "@radix-ui/react-compose-refs": "npm:1.0.1" + "@radix-ui/react-primitive": "npm:1.0.3" + "@radix-ui/react-use-callback-ref": "npm:1.0.1" + "@radix-ui/react-use-escape-keydown": "npm:1.0.3" peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2424,7 +2442,7 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: ea86004ed56a10609dd84eef39dc1e57b400d687a35be41bb4aaa06dc7ad6dbd0a8da281e08c8c077fdbd523122e4d860cb7438a60c664f024f77c8b41299ec6 + checksum: bcc14f0704fdc19430a2b922106a278e64401decffd6e47f427aa5de2d63367ba3e848b012c464a6b39a6e057060e41ad16964385941735a329e319cea46711a languageName: node linkType: hard @@ -2432,7 +2450,7 @@ __metadata: version: 1.0.1 resolution: "@radix-ui/react-focus-guards@npm:1.0.1" dependencies: - "@babel/runtime": ^7.13.10 + "@babel/runtime": "npm:^7.13.10" peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 @@ -2447,10 +2465,10 @@ __metadata: version: 1.0.3 resolution: "@radix-ui/react-focus-scope@npm:1.0.3" dependencies: - "@babel/runtime": ^7.13.10 - "@radix-ui/react-compose-refs": 1.0.1 - "@radix-ui/react-primitive": 1.0.3 - "@radix-ui/react-use-callback-ref": 1.0.1 + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/react-compose-refs": "npm:1.0.1" + "@radix-ui/react-primitive": "npm:1.0.3" + "@radix-ui/react-use-callback-ref": "npm:1.0.1" peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2461,7 +2479,7 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: e5b1a089071fbe77aca11124a4ad9623fc2bcaf4c019759b0cd044bf0878ecc924131ee09c6a22d38a3f094684ef68ed18fa65c8d891918412e0afc685a464e0 + checksum: d62631cc06a2f37d483d106f3732ffc00831498fc2306df51c675d7cdb9727169512a1ca43ce06d1bfd578e8d8d67a80858c7531579bacaf6079d3aaf0ca8663 languageName: node linkType: hard @@ -2469,8 +2487,8 @@ __metadata: version: 1.0.1 resolution: "@radix-ui/react-id@npm:1.0.1" dependencies: - "@babel/runtime": ^7.13.10 - "@radix-ui/react-use-layout-effect": 1.0.1 + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/react-use-layout-effect": "npm:1.0.1" peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 @@ -2485,17 +2503,17 @@ __metadata: version: 1.1.2 resolution: "@radix-ui/react-popper@npm:1.1.2" dependencies: - "@babel/runtime": ^7.13.10 - "@floating-ui/react-dom": ^2.0.0 - "@radix-ui/react-arrow": 1.0.3 - "@radix-ui/react-compose-refs": 1.0.1 - "@radix-ui/react-context": 1.0.1 - "@radix-ui/react-primitive": 1.0.3 - "@radix-ui/react-use-callback-ref": 1.0.1 - "@radix-ui/react-use-layout-effect": 1.0.1 - "@radix-ui/react-use-rect": 1.0.1 - "@radix-ui/react-use-size": 1.0.1 - "@radix-ui/rect": 1.0.1 + "@babel/runtime": "npm:^7.13.10" + "@floating-ui/react-dom": "npm:^2.0.0" + "@radix-ui/react-arrow": "npm:1.0.3" + "@radix-ui/react-compose-refs": "npm:1.0.1" + "@radix-ui/react-context": "npm:1.0.1" + "@radix-ui/react-primitive": "npm:1.0.3" + "@radix-ui/react-use-callback-ref": "npm:1.0.1" + "@radix-ui/react-use-layout-effect": "npm:1.0.1" + "@radix-ui/react-use-rect": "npm:1.0.1" + "@radix-ui/react-use-size": "npm:1.0.1" + "@radix-ui/rect": "npm:1.0.1" peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2506,7 +2524,7 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 4929daa0d1cccada3cff50de0e00c0d186ffea97a5f28545c77fa85ff9bc5c105a54dddac400c2e2dcac631f0f7ea88e59f2e5ad0f80bb2cb7b62cc7cd30400f + checksum: be32677e846ef93e8cbf219550e55b99583cb927b572a9ee466b0c242156d42ddc70f43135e22acffe48bba4cd3fe28888cc3f929947e078d8732bee958df4c4 languageName: node linkType: hard @@ -2514,8 +2532,8 @@ __metadata: version: 1.0.3 resolution: "@radix-ui/react-portal@npm:1.0.3" dependencies: - "@babel/runtime": ^7.13.10 - "@radix-ui/react-primitive": 1.0.3 + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/react-primitive": "npm:1.0.3" peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2534,8 +2552,8 @@ __metadata: version: 1.0.3 resolution: "@radix-ui/react-primitive@npm:1.0.3" dependencies: - "@babel/runtime": ^7.13.10 - "@radix-ui/react-slot": 1.0.2 + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/react-slot": "npm:1.0.2" peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2546,7 +2564,7 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 9402bc22923c8e5c479051974a721c301535c36521c0237b83e5fa213d013174e77f3ad7905e6d60ef07e14f88ec7f4ea69891dc7a2b39047f8d3640e8f8d713 + checksum: bedb934ac07c710dc5550a7bfc7065d47e099d958cde1d37e4b1947ae5451f1b7e6f8ff5965e242578bf2c619065e6038c3a3aa779e5eafa7da3e3dbc685799f languageName: node linkType: hard @@ -2554,16 +2572,16 @@ __metadata: version: 1.0.4 resolution: "@radix-ui/react-roving-focus@npm:1.0.4" dependencies: - "@babel/runtime": ^7.13.10 - "@radix-ui/primitive": 1.0.1 - "@radix-ui/react-collection": 1.0.3 - "@radix-ui/react-compose-refs": 1.0.1 - "@radix-ui/react-context": 1.0.1 - "@radix-ui/react-direction": 1.0.1 - "@radix-ui/react-id": 1.0.1 - "@radix-ui/react-primitive": 1.0.3 - "@radix-ui/react-use-callback-ref": 1.0.1 - "@radix-ui/react-use-controllable-state": 1.0.1 + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/primitive": "npm:1.0.1" + "@radix-ui/react-collection": "npm:1.0.3" + "@radix-ui/react-compose-refs": "npm:1.0.1" + "@radix-ui/react-context": "npm:1.0.1" + "@radix-ui/react-direction": "npm:1.0.1" + "@radix-ui/react-id": "npm:1.0.1" + "@radix-ui/react-primitive": "npm:1.0.3" + "@radix-ui/react-use-callback-ref": "npm:1.0.1" + "@radix-ui/react-use-controllable-state": "npm:1.0.1" peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2574,7 +2592,7 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 69b1c82c2d9db3ba71549a848f2704200dab1b2cd22d050c1e081a78b9a567dbfdc7fd0403ee010c19b79652de69924d8ca2076cd031d6552901e4213493ffc7 + checksum: a23ffb1e3e29a8209b94ce3857bf559dcf2175c4f316169dc47d018e8e94cd018dc914331a1d1762f32448e2594b7c8945efaa7059056f9940ce92cc35cc7026 languageName: node linkType: hard @@ -2582,28 +2600,28 @@ __metadata: version: 1.2.2 resolution: "@radix-ui/react-select@npm:1.2.2" dependencies: - "@babel/runtime": ^7.13.10 - "@radix-ui/number": 1.0.1 - "@radix-ui/primitive": 1.0.1 - "@radix-ui/react-collection": 1.0.3 - "@radix-ui/react-compose-refs": 1.0.1 - "@radix-ui/react-context": 1.0.1 - "@radix-ui/react-direction": 1.0.1 - "@radix-ui/react-dismissable-layer": 1.0.4 - "@radix-ui/react-focus-guards": 1.0.1 - "@radix-ui/react-focus-scope": 1.0.3 - "@radix-ui/react-id": 1.0.1 - "@radix-ui/react-popper": 1.1.2 - "@radix-ui/react-portal": 1.0.3 - "@radix-ui/react-primitive": 1.0.3 - "@radix-ui/react-slot": 1.0.2 - "@radix-ui/react-use-callback-ref": 1.0.1 - "@radix-ui/react-use-controllable-state": 1.0.1 - "@radix-ui/react-use-layout-effect": 1.0.1 - "@radix-ui/react-use-previous": 1.0.1 - "@radix-ui/react-visually-hidden": 1.0.3 - aria-hidden: ^1.1.1 - react-remove-scroll: 2.5.5 + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/number": "npm:1.0.1" + "@radix-ui/primitive": "npm:1.0.1" + "@radix-ui/react-collection": "npm:1.0.3" + "@radix-ui/react-compose-refs": "npm:1.0.1" + "@radix-ui/react-context": "npm:1.0.1" + "@radix-ui/react-direction": "npm:1.0.1" + "@radix-ui/react-dismissable-layer": "npm:1.0.4" + "@radix-ui/react-focus-guards": "npm:1.0.1" + "@radix-ui/react-focus-scope": "npm:1.0.3" + "@radix-ui/react-id": "npm:1.0.1" + "@radix-ui/react-popper": "npm:1.1.2" + "@radix-ui/react-portal": "npm:1.0.3" + "@radix-ui/react-primitive": "npm:1.0.3" + "@radix-ui/react-slot": "npm:1.0.2" + "@radix-ui/react-use-callback-ref": "npm:1.0.1" + "@radix-ui/react-use-controllable-state": "npm:1.0.1" + "@radix-ui/react-use-layout-effect": "npm:1.0.1" + "@radix-ui/react-use-previous": "npm:1.0.1" + "@radix-ui/react-visually-hidden": "npm:1.0.3" + aria-hidden: "npm:^1.1.1" + react-remove-scroll: "npm:2.5.5" peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2614,7 +2632,7 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: af7b63cc9e2c6006ec08163392d244941e9e03534e7add1b7c5a86059d0eb8a0398d4f3e80d43ff22126874a02b985e44f1722d1de9218922f7aa653d09412e3 + checksum: 4d7b6d9d988f78764783a4b2fd6523457ff735436829e122dae824bdea4f2835ad0150dfc060517d6c29d953ef61ee12d7ce10cf160593e56967e528bf6f8ee5 languageName: node linkType: hard @@ -2622,8 +2640,8 @@ __metadata: version: 1.0.3 resolution: "@radix-ui/react-separator@npm:1.0.3" dependencies: - "@babel/runtime": ^7.13.10 - "@radix-ui/react-primitive": 1.0.3 + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/react-primitive": "npm:1.0.3" peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2634,7 +2652,7 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 42f8c95e404de2ce9387040d78049808a48d423cd4c3bad8cca92c4b0bcbdcb3566b5b52a920d4e939a74b51188697f20a012221f0e630fc7f56de64096c15d2 + checksum: b5ea8f1996c86d3f9df73c72926f3d1a400a2eb46a482a345d486651c503895af2ccf9d7723f97a4e612f7c1317eb622078ddf014b13e2b26070d8cf0ad0da1d languageName: node linkType: hard @@ -2642,15 +2660,15 @@ __metadata: version: 1.0.2 resolution: "@radix-ui/react-slot@npm:1.0.2" dependencies: - "@babel/runtime": ^7.13.10 - "@radix-ui/react-compose-refs": 1.0.1 + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/react-compose-refs": "npm:1.0.1" peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 peerDependenciesMeta: "@types/react": optional: true - checksum: edf5edf435ff594bea7e198bf16d46caf81b6fb559493acad4fa8c308218896136acb16f9b7238c788fd13e94a904f2fd0b6d834e530e4cae94522cdb8f77ce9 + checksum: 734866561e991438fbcf22af06e56b272ed6ee8f7b536489ee3bf2f736f8b53bf6bc14ebde94834aa0aceda854d018a0ce20bb171defffbaed1f566006cbb887 languageName: node linkType: hard @@ -2658,14 +2676,14 @@ __metadata: version: 1.0.4 resolution: "@radix-ui/react-toggle-group@npm:1.0.4" dependencies: - "@babel/runtime": ^7.13.10 - "@radix-ui/primitive": 1.0.1 - "@radix-ui/react-context": 1.0.1 - "@radix-ui/react-direction": 1.0.1 - "@radix-ui/react-primitive": 1.0.3 - "@radix-ui/react-roving-focus": 1.0.4 - "@radix-ui/react-toggle": 1.0.3 - "@radix-ui/react-use-controllable-state": 1.0.1 + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/primitive": "npm:1.0.1" + "@radix-ui/react-context": "npm:1.0.1" + "@radix-ui/react-direction": "npm:1.0.1" + "@radix-ui/react-primitive": "npm:1.0.3" + "@radix-ui/react-roving-focus": "npm:1.0.4" + "@radix-ui/react-toggle": "npm:1.0.3" + "@radix-ui/react-use-controllable-state": "npm:1.0.1" peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2676,7 +2694,7 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: b6c11fbbc3ca857ff68c0fa31f293c0d0111bcc8aa0cde2566214c090907530bfcb3b862f81585c2b02d8989b5c7971acff4d5c07c429870d80bd5602e30d376 + checksum: 96ea35f0e959399f239ff3b75dcad72d5880c66966114c80293ab1450801c87353c0cb2a7a4a5e9825f43c9bd3d881f312a9c14bdacfa70f4050d406bec98c2b languageName: node linkType: hard @@ -2684,10 +2702,10 @@ __metadata: version: 1.0.3 resolution: "@radix-ui/react-toggle@npm:1.0.3" dependencies: - "@babel/runtime": ^7.13.10 - "@radix-ui/primitive": 1.0.1 - "@radix-ui/react-primitive": 1.0.3 - "@radix-ui/react-use-controllable-state": 1.0.1 + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/primitive": "npm:1.0.1" + "@radix-ui/react-primitive": "npm:1.0.3" + "@radix-ui/react-use-controllable-state": "npm:1.0.1" peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2706,14 +2724,14 @@ __metadata: version: 1.0.4 resolution: "@radix-ui/react-toolbar@npm:1.0.4" dependencies: - "@babel/runtime": ^7.13.10 - "@radix-ui/primitive": 1.0.1 - "@radix-ui/react-context": 1.0.1 - "@radix-ui/react-direction": 1.0.1 - "@radix-ui/react-primitive": 1.0.3 - "@radix-ui/react-roving-focus": 1.0.4 - "@radix-ui/react-separator": 1.0.3 - "@radix-ui/react-toggle-group": 1.0.4 + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/primitive": "npm:1.0.1" + "@radix-ui/react-context": "npm:1.0.1" + "@radix-ui/react-direction": "npm:1.0.1" + "@radix-ui/react-primitive": "npm:1.0.3" + "@radix-ui/react-roving-focus": "npm:1.0.4" + "@radix-ui/react-separator": "npm:1.0.3" + "@radix-ui/react-toggle-group": "npm:1.0.4" peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2724,7 +2742,7 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 7ebee1f8add6510108979433c5b38627e2de9d48ef2172ca15274b9edbbc106ff43bcd47ff733b03ed2215b92e7af364ff82c79e5a1728374847e2b1e315552c + checksum: 57f75b6617d4e2bb8f7782d6065e70fd0db44038588b3e8e5f8cd1101dc2c94744bd52b9c011c7b722cb5f9ca96d21fc78ee7caac07722894453019fd5ade3b0 languageName: node linkType: hard @@ -2732,7 +2750,7 @@ __metadata: version: 1.0.1 resolution: "@radix-ui/react-use-callback-ref@npm:1.0.1" dependencies: - "@babel/runtime": ^7.13.10 + "@babel/runtime": "npm:^7.13.10" peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 @@ -2747,8 +2765,8 @@ __metadata: version: 1.0.1 resolution: "@radix-ui/react-use-controllable-state@npm:1.0.1" dependencies: - "@babel/runtime": ^7.13.10 - "@radix-ui/react-use-callback-ref": 1.0.1 + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/react-use-callback-ref": "npm:1.0.1" peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 @@ -2763,8 +2781,8 @@ __metadata: version: 1.0.3 resolution: "@radix-ui/react-use-escape-keydown@npm:1.0.3" dependencies: - "@babel/runtime": ^7.13.10 - "@radix-ui/react-use-callback-ref": 1.0.1 + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/react-use-callback-ref": "npm:1.0.1" peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 @@ -2779,7 +2797,7 @@ __metadata: version: 1.0.1 resolution: "@radix-ui/react-use-layout-effect@npm:1.0.1" dependencies: - "@babel/runtime": ^7.13.10 + "@babel/runtime": "npm:^7.13.10" peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 @@ -2794,7 +2812,7 @@ __metadata: version: 1.0.1 resolution: "@radix-ui/react-use-previous@npm:1.0.1" dependencies: - "@babel/runtime": ^7.13.10 + "@babel/runtime": "npm:^7.13.10" peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 @@ -2809,8 +2827,8 @@ __metadata: version: 1.0.1 resolution: "@radix-ui/react-use-rect@npm:1.0.1" dependencies: - "@babel/runtime": ^7.13.10 - "@radix-ui/rect": 1.0.1 + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/rect": "npm:1.0.1" peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 @@ -2825,8 +2843,8 @@ __metadata: version: 1.0.1 resolution: "@radix-ui/react-use-size@npm:1.0.1" dependencies: - "@babel/runtime": ^7.13.10 - "@radix-ui/react-use-layout-effect": 1.0.1 + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/react-use-layout-effect": "npm:1.0.1" peerDependencies: "@types/react": "*" react: ^16.8 || ^17.0 || ^18.0 @@ -2841,8 +2859,8 @@ __metadata: version: 1.0.3 resolution: "@radix-ui/react-visually-hidden@npm:1.0.3" dependencies: - "@babel/runtime": ^7.13.10 - "@radix-ui/react-primitive": 1.0.3 + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/react-primitive": "npm:1.0.3" peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2861,8 +2879,8 @@ __metadata: version: 1.0.1 resolution: "@radix-ui/rect@npm:1.0.1" dependencies: - "@babel/runtime": ^7.13.10 - checksum: aeec13b234a946052512d05239067d2d63422f9ec70bf2fe7acfd6b9196693fc33fbaf43c2667c167f777d90a095c6604eb487e0bce79e230b6df0f6cacd6a55 + "@babel/runtime": "npm:^7.13.10" + checksum: e25492cb8a683246161d781f0f3205f79507280a60f50eb763f06e8b6fa211b940b784aa581131ed76695bd5df5d1033a6246b43a6996cf8959a326fe4d3eb00 languageName: node linkType: hard @@ -2870,10 +2888,10 @@ __metadata: version: 1.9.7 resolution: "@reduxjs/toolkit@npm:1.9.7" dependencies: - immer: ^9.0.21 - redux: ^4.2.1 - redux-thunk: ^2.4.2 - reselect: ^4.1.8 + immer: "npm:^9.0.21" + redux: "npm:^4.2.1" + redux-thunk: "npm:^2.4.2" + reselect: "npm:^4.1.8" peerDependencies: react: ^16.9.0 || ^17.0.0 || ^18 react-redux: ^7.2.1 || ^8.0.2 @@ -2882,14 +2900,143 @@ __metadata: optional: true react-redux: optional: true - checksum: ac25dec73a5d2df9fc7fbe98c14ccc73919e5ee1d6f251db0d2ec8f90273f92ef39c26716704bf56b5a40189f72d94b4526dc3a8c7ac3986f5daf44442bcc364 + checksum: 11c718270bb378e5b26e172eb84cc549d6f263748b6f330b07ee9c366c6474b013fd410e5b2f65a5742e73b7873a3ac14e06cae4bb01480ba03b423c4fd92583 languageName: node linkType: hard "@remix-run/router@npm:1.6.2": version: 1.6.2 resolution: "@remix-run/router@npm:1.6.2" - checksum: 5969d313bff6ba5c75917910090cebafda84b9d3b4b453fae6b3d60fea9f938078578ffca769c532ab7ce252cd4a207b78d1024d7c727ab80dd572e62fd3b3f2 + checksum: c261c3b52f08d7fcacce9c66d68dba3b6f0c8263ea15f69f9f1c89734685cdfe4f383c879324acade68cb331d48e3deca9ec00734abe08d9694e529096907f40 + languageName: node + linkType: hard + +"@resvg/resvg-js-android-arm-eabi@npm:2.6.2": + version: 2.6.2 + resolution: "@resvg/resvg-js-android-arm-eabi@npm:2.6.2" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@resvg/resvg-js-android-arm64@npm:2.6.2": + version: 2.6.2 + resolution: "@resvg/resvg-js-android-arm64@npm:2.6.2" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@resvg/resvg-js-darwin-arm64@npm:2.6.2": + version: 2.6.2 + resolution: "@resvg/resvg-js-darwin-arm64@npm:2.6.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@resvg/resvg-js-darwin-x64@npm:2.6.2": + version: 2.6.2 + resolution: "@resvg/resvg-js-darwin-x64@npm:2.6.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@resvg/resvg-js-linux-arm-gnueabihf@npm:2.6.2": + version: 2.6.2 + resolution: "@resvg/resvg-js-linux-arm-gnueabihf@npm:2.6.2" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@resvg/resvg-js-linux-arm64-gnu@npm:2.6.2": + version: 2.6.2 + resolution: "@resvg/resvg-js-linux-arm64-gnu@npm:2.6.2" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@resvg/resvg-js-linux-arm64-musl@npm:2.6.2": + version: 2.6.2 + resolution: "@resvg/resvg-js-linux-arm64-musl@npm:2.6.2" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@resvg/resvg-js-linux-x64-gnu@npm:2.6.2": + version: 2.6.2 + resolution: "@resvg/resvg-js-linux-x64-gnu@npm:2.6.2" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@resvg/resvg-js-linux-x64-musl@npm:2.6.2": + version: 2.6.2 + resolution: "@resvg/resvg-js-linux-x64-musl@npm:2.6.2" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@resvg/resvg-js-win32-arm64-msvc@npm:2.6.2": + version: 2.6.2 + resolution: "@resvg/resvg-js-win32-arm64-msvc@npm:2.6.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@resvg/resvg-js-win32-ia32-msvc@npm:2.6.2": + version: 2.6.2 + resolution: "@resvg/resvg-js-win32-ia32-msvc@npm:2.6.2" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@resvg/resvg-js-win32-x64-msvc@npm:2.6.2": + version: 2.6.2 + resolution: "@resvg/resvg-js-win32-x64-msvc@npm:2.6.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@resvg/resvg-js@npm:^2.6.0": + version: 2.6.2 + resolution: "@resvg/resvg-js@npm:2.6.2" + dependencies: + "@resvg/resvg-js-android-arm-eabi": "npm:2.6.2" + "@resvg/resvg-js-android-arm64": "npm:2.6.2" + "@resvg/resvg-js-darwin-arm64": "npm:2.6.2" + "@resvg/resvg-js-darwin-x64": "npm:2.6.2" + "@resvg/resvg-js-linux-arm-gnueabihf": "npm:2.6.2" + "@resvg/resvg-js-linux-arm64-gnu": "npm:2.6.2" + "@resvg/resvg-js-linux-arm64-musl": "npm:2.6.2" + "@resvg/resvg-js-linux-x64-gnu": "npm:2.6.2" + "@resvg/resvg-js-linux-x64-musl": "npm:2.6.2" + "@resvg/resvg-js-win32-arm64-msvc": "npm:2.6.2" + "@resvg/resvg-js-win32-ia32-msvc": "npm:2.6.2" + "@resvg/resvg-js-win32-x64-msvc": "npm:2.6.2" + dependenciesMeta: + "@resvg/resvg-js-android-arm-eabi": + optional: true + "@resvg/resvg-js-android-arm64": + optional: true + "@resvg/resvg-js-darwin-arm64": + optional: true + "@resvg/resvg-js-darwin-x64": + optional: true + "@resvg/resvg-js-linux-arm-gnueabihf": + optional: true + "@resvg/resvg-js-linux-arm64-gnu": + optional: true + "@resvg/resvg-js-linux-arm64-musl": + optional: true + "@resvg/resvg-js-linux-x64-gnu": + optional: true + "@resvg/resvg-js-linux-x64-musl": + optional: true + "@resvg/resvg-js-win32-arm64-msvc": + optional: true + "@resvg/resvg-js-win32-ia32-msvc": + optional: true + "@resvg/resvg-js-win32-x64-msvc": + optional: true + checksum: f3fbba7d11e912a931955fdec911db7913c4dae9bf63e5fa5b97faad2b845068438e281bf5cb72371ecd8c95ff31a1f40d413e996f95972e5c8fd47ab5ea3ecd languageName: node linkType: hard @@ -2897,9 +3044,9 @@ __metadata: version: 4.2.1 resolution: "@rollup/pluginutils@npm:4.2.1" dependencies: - estree-walker: ^2.0.1 - picomatch: ^2.2.2 - checksum: 6bc41f22b1a0f1efec3043899e4d3b6b1497b3dea4d94292d8f83b4cf07a1073ecbaedd562a22d11913ff7659f459677b01b09e9598a98936e746780ecc93a12 + estree-walker: "npm:^2.0.1" + picomatch: "npm:^2.2.2" + checksum: 503a6f0a449e11a2873ac66cfdfb9a3a0b77ffa84c5cad631f5e4bc1063c850710e8d5cd5dab52477c0d66cda2ec719865726dbe753318cd640bab3fff7ca476 languageName: node linkType: hard @@ -2907,22 +3054,22 @@ __metadata: version: 5.1.0 resolution: "@rollup/pluginutils@npm:5.1.0" dependencies: - "@types/estree": ^1.0.0 - estree-walker: ^2.0.2 - picomatch: ^2.3.1 + "@types/estree": "npm:^1.0.0" + estree-walker: "npm:^2.0.2" + picomatch: "npm:^2.3.1" peerDependencies: rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 peerDependenciesMeta: rollup: optional: true - checksum: 3cc5a6d91452a6eabbfd1ae79b4dd1f1e809d2eecda6e175deb784e75b0911f47e9ecce73f8dd315d6a8b3f362582c91d3c0f66908b6ced69345b3cbe28f8ce8 + checksum: abb15eaec5b36f159ec351b48578401bedcefdfa371d24a914cfdbb1e27d0ebfbf895299ec18ccc343d247e71f2502cba21202bc1362d7ef27d5ded699e5c2b2 languageName: node linkType: hard "@sinclair/typebox@npm:^0.27.8": version: 0.27.8 resolution: "@sinclair/typebox@npm:0.27.8" - checksum: 00bd7362a3439021aa1ea51b0e0d0a0e8ca1351a3d54c606b115fdcc49b51b16db6e5f43b4fe7a28c38688523e22a94d49dd31168868b655f0d4d50f032d07a1 + checksum: 297f95ff77c82c54de8c9907f186076e715ff2621c5222ba50b8d40a170661c0c5242c763cba2a4791f0f91cb1d8ffa53ea1d7294570cf8cd4694c0e383e484d languageName: node linkType: hard @@ -2930,8 +3077,8 @@ __metadata: version: 3.0.1 resolution: "@sinonjs/commons@npm:3.0.1" dependencies: - type-detect: 4.0.8 - checksum: a7c3e7cc612352f4004873747d9d8b2d4d90b13a6d483f685598c945a70e734e255f1ca5dc49702515533c403b32725defff148177453b3f3915bcb60e9d4601 + type-detect: "npm:4.0.8" + checksum: a0af217ba7044426c78df52c23cedede6daf377586f3ac58857c565769358ab1f44ebf95ba04bbe38814fba6e316ca6f02870a009328294fc2c555d0f85a7117 languageName: node linkType: hard @@ -2939,8 +3086,8 @@ __metadata: version: 10.3.0 resolution: "@sinonjs/fake-timers@npm:10.3.0" dependencies: - "@sinonjs/commons": ^3.0.0 - checksum: 614d30cb4d5201550c940945d44c9e0b6d64a888ff2cd5b357f95ad6721070d6b8839cd10e15b76bf5e14af0bcc1d8f9ec00d49a46318f1f669a4bec1d7f3148 + "@sinonjs/commons": "npm:^3.0.0" + checksum: 78155c7bd866a85df85e22028e046b8d46cf3e840f72260954f5e3ed5bd97d66c595524305a6841ffb3f681a08f6e5cef572a2cce5442a8a232dc29fb409b83e languageName: node linkType: hard @@ -2948,13 +3095,13 @@ __metadata: version: 7.6.17 resolution: "@storybook/addon-actions@npm:7.6.17" dependencies: - "@storybook/core-events": 7.6.17 - "@storybook/global": ^5.0.0 - "@types/uuid": ^9.0.1 - dequal: ^2.0.2 - polished: ^4.2.2 - uuid: ^9.0.0 - checksum: bc512cb1664b614b39fe00340f4eb6bd3311fd26828a5fd6f02448427c6b20bebe17d1f17de2fff1f2a16195b277945920208b924f0a7cca6f4155eec70b79d9 + "@storybook/core-events": "npm:7.6.17" + "@storybook/global": "npm:^5.0.0" + "@types/uuid": "npm:^9.0.1" + dequal: "npm:^2.0.2" + polished: "npm:^4.2.2" + uuid: "npm:^9.0.0" + checksum: 79ec0da9bc1c8a8990b33d937c28c02f84d4febfc1c660fb5ebfd6b246a02ba4f6ad7f31577306dad4a11dca969edc660a9e7e323c1747eb60156ce3fcefa6ec languageName: node linkType: hard @@ -2962,10 +3109,10 @@ __metadata: version: 7.6.17 resolution: "@storybook/addon-backgrounds@npm:7.6.17" dependencies: - "@storybook/global": ^5.0.0 - memoizerific: ^1.11.3 - ts-dedent: ^2.0.0 - checksum: 7198cf392638b94e7b7e40ee18155ea742f70937ebe3ac38fe6ec2208d4568112d5a80d1bbc636c466c8b182aa93bad139f57287008d6026133fc976a441cace + "@storybook/global": "npm:^5.0.0" + memoizerific: "npm:^1.11.3" + ts-dedent: "npm:^2.0.0" + checksum: ee237ae5e1b0d696b2726d80137b4f8bc75740f34e9b94bbab3a1d04ea6304c67de0feb72650c7556ee05aa4db4143cfde7794bbe15ec2e36cd36d3aeaa13707 languageName: node linkType: hard @@ -2973,10 +3120,10 @@ __metadata: version: 7.6.17 resolution: "@storybook/addon-controls@npm:7.6.17" dependencies: - "@storybook/blocks": 7.6.17 - lodash: ^4.17.21 - ts-dedent: ^2.0.0 - checksum: a50d281d8c3a39d411b6a997f16cbd001db431f4c5e27bdb0da10fb211c83fb8671d74b851563caa2a13afca7f26f08cba16bc50c53bd629c9883c2214c6aacd + "@storybook/blocks": "npm:7.6.17" + lodash: "npm:^4.17.21" + ts-dedent: "npm:^2.0.0" + checksum: d9ae67dc3a208e07a07576529df3f34d41d8b3e4a1acc31573850ea39c8680c4676e6536108fef00c156b67ec3dd9cc5ae4d08dbc0e261b475e401511692d905 languageName: node linkType: hard @@ -2984,29 +3131,29 @@ __metadata: version: 7.6.17 resolution: "@storybook/addon-docs@npm:7.6.17" dependencies: - "@jest/transform": ^29.3.1 - "@mdx-js/react": ^2.1.5 - "@storybook/blocks": 7.6.17 - "@storybook/client-logger": 7.6.17 - "@storybook/components": 7.6.17 - "@storybook/csf-plugin": 7.6.17 - "@storybook/csf-tools": 7.6.17 - "@storybook/global": ^5.0.0 - "@storybook/mdx2-csf": ^1.0.0 - "@storybook/node-logger": 7.6.17 - "@storybook/postinstall": 7.6.17 - "@storybook/preview-api": 7.6.17 - "@storybook/react-dom-shim": 7.6.17 - "@storybook/theming": 7.6.17 - "@storybook/types": 7.6.17 - fs-extra: ^11.1.0 - remark-external-links: ^8.0.0 - remark-slug: ^6.0.0 - ts-dedent: ^2.0.0 + "@jest/transform": "npm:^29.3.1" + "@mdx-js/react": "npm:^2.1.5" + "@storybook/blocks": "npm:7.6.17" + "@storybook/client-logger": "npm:7.6.17" + "@storybook/components": "npm:7.6.17" + "@storybook/csf-plugin": "npm:7.6.17" + "@storybook/csf-tools": "npm:7.6.17" + "@storybook/global": "npm:^5.0.0" + "@storybook/mdx2-csf": "npm:^1.0.0" + "@storybook/node-logger": "npm:7.6.17" + "@storybook/postinstall": "npm:7.6.17" + "@storybook/preview-api": "npm:7.6.17" + "@storybook/react-dom-shim": "npm:7.6.17" + "@storybook/theming": "npm:7.6.17" + "@storybook/types": "npm:7.6.17" + fs-extra: "npm:^11.1.0" + remark-external-links: "npm:^8.0.0" + remark-slug: "npm:^6.0.0" + ts-dedent: "npm:^2.0.0" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: df42569b89d0d466b8a4a23c02e8c15a874ebc13315e4868c7532731595342b53245075792814dbe7dd02d70c667ea4648d2889a5577e52184e326b6cbbe176e + checksum: c4e1442b837350773a67990448c5bfdfd8060757bc5842cc1f617b01be8408dc566f90045423c321bf99b65976f62a63d916793c7591920cf42f908545ec6b2b languageName: node linkType: hard @@ -3014,20 +3161,20 @@ __metadata: version: 7.6.17 resolution: "@storybook/addon-essentials@npm:7.6.17" dependencies: - "@storybook/addon-actions": 7.6.17 - "@storybook/addon-backgrounds": 7.6.17 - "@storybook/addon-controls": 7.6.17 - "@storybook/addon-docs": 7.6.17 - "@storybook/addon-highlight": 7.6.17 - "@storybook/addon-measure": 7.6.17 - "@storybook/addon-outline": 7.6.17 - "@storybook/addon-toolbars": 7.6.17 - "@storybook/addon-viewport": 7.6.17 - "@storybook/core-common": 7.6.17 - "@storybook/manager-api": 7.6.17 - "@storybook/node-logger": 7.6.17 - "@storybook/preview-api": 7.6.17 - ts-dedent: ^2.0.0 + "@storybook/addon-actions": "npm:7.6.17" + "@storybook/addon-backgrounds": "npm:7.6.17" + "@storybook/addon-controls": "npm:7.6.17" + "@storybook/addon-docs": "npm:7.6.17" + "@storybook/addon-highlight": "npm:7.6.17" + "@storybook/addon-measure": "npm:7.6.17" + "@storybook/addon-outline": "npm:7.6.17" + "@storybook/addon-toolbars": "npm:7.6.17" + "@storybook/addon-viewport": "npm:7.6.17" + "@storybook/core-common": "npm:7.6.17" + "@storybook/manager-api": "npm:7.6.17" + "@storybook/node-logger": "npm:7.6.17" + "@storybook/preview-api": "npm:7.6.17" + ts-dedent: "npm:^2.0.0" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -3039,8 +3186,8 @@ __metadata: version: 7.6.17 resolution: "@storybook/addon-highlight@npm:7.6.17" dependencies: - "@storybook/global": ^5.0.0 - checksum: 0b5f5dae9aae48b9386b167b4941f7b790a9e2502c352f21b1e114379cda7f4897f0e61babe9967fb258c551004cade1a0cbeddbc2e917c6a18b65617b503b09 + "@storybook/global": "npm:^5.0.0" + checksum: b2d213b101013de5da40d6b66999b36e66a321834684c5c594de1a5c96e527d9ee9add844006e0ca36a4a638cea8325c9db0e3618616da04a1039b8d18b92ea3 languageName: node linkType: hard @@ -3048,12 +3195,12 @@ __metadata: version: 7.6.17 resolution: "@storybook/addon-interactions@npm:7.6.17" dependencies: - "@storybook/global": ^5.0.0 - "@storybook/types": 7.6.17 - jest-mock: ^27.0.6 - polished: ^4.2.2 - ts-dedent: ^2.2.0 - checksum: 8274ecec01ad379f53f7f2bf4383d2345013dd327bd6f1fd9515b8401c83f0a36a913dd8751b5a8bbb73fcecb51f62b5c26eb6d16dd73b91be1751f6d3712e8a + "@storybook/global": "npm:^5.0.0" + "@storybook/types": "npm:7.6.17" + jest-mock: "npm:^27.0.6" + polished: "npm:^4.2.2" + ts-dedent: "npm:^2.2.0" + checksum: 4e22b7113137a6bd02a5ab10a0ea156871a8175a668837106750697e0691d734f30a21e3b44a4346c763da19180552adf823a67f8fd8b8bbc276d5e15b4d0271 languageName: node linkType: hard @@ -3061,15 +3208,15 @@ __metadata: version: 7.6.17 resolution: "@storybook/addon-links@npm:7.6.17" dependencies: - "@storybook/csf": ^0.1.2 - "@storybook/global": ^5.0.0 - ts-dedent: ^2.0.0 + "@storybook/csf": "npm:^0.1.2" + "@storybook/global": "npm:^5.0.0" + ts-dedent: "npm:^2.0.0" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 peerDependenciesMeta: react: optional: true - checksum: 7738870c04e65213140bca680158fde866351d09ac8bd7415423a1ad29054aecb21ff7971fa13a945a44d969c8d5b5e27599ecad8dbe22aa95f23ed59be33945 + checksum: 9865c3ff69257350ec13c17afd797de931df702419926f34a931fe136938b3e2323a54fdd162db374c6f56f3d9844c723b46097f90bf005ca582dc0820211ac5 languageName: node linkType: hard @@ -3077,9 +3224,9 @@ __metadata: version: 7.6.17 resolution: "@storybook/addon-measure@npm:7.6.17" dependencies: - "@storybook/global": ^5.0.0 - tiny-invariant: ^1.3.1 - checksum: c6db620a92e09ef5780e897f4b119e1efbe781b424706d1de55b71f6a3805c0c4620bfb3ab33998a317c246e4383f62769082b47bbd2f1aec4962eed812b952a + "@storybook/global": "npm:^5.0.0" + tiny-invariant: "npm:^1.3.1" + checksum: 098e3ac71ca5467cfc96af6e4e9cc5b4ba6ff4dce910d2823ab659ed764d38df1a5ecec293ed9ef5ea04b27c11a1f6962e1d658dc48363fe7aa0ef0d569ac47d languageName: node linkType: hard @@ -3087,16 +3234,16 @@ __metadata: version: 7.6.17 resolution: "@storybook/addon-outline@npm:7.6.17" dependencies: - "@storybook/global": ^5.0.0 - ts-dedent: ^2.0.0 - checksum: f85b2c41d02faafd37507ad52d6626dc078fa72ef6b915e5996b3b9f1fe4eb820a00f76bb9818bc3c20eeec9767b2bd942c27a5fea54cadaa526ac319e5355e5 + "@storybook/global": "npm:^5.0.0" + ts-dedent: "npm:^2.0.0" + checksum: b6dafc517ac1cedb4709803110c87580105d2049bc1020f2d7e254c9202993d8a411c2b7e8972a97085d1240ff6af8f9d3728d4b5fa33a5052cbcc9fdc4012e7 languageName: node linkType: hard "@storybook/addon-toolbars@npm:7.6.17": version: 7.6.17 resolution: "@storybook/addon-toolbars@npm:7.6.17" - checksum: 7e10d346e78ac5b9d8a653a6ab942cea8809b9b544d7e986246b742e65817ec1f475294ff581516781aab287df556b84676186b4a7c38a885fd64e80ecd2b846 + checksum: c1e051a5d9d1627aff9293c8ba33622c22851d443469227e36f018e005b8143fe5346512a3fe5ce6571af9d69ae051d3254a81fa7ed7f24b115d514dcd901eac languageName: node linkType: hard @@ -3104,8 +3251,8 @@ __metadata: version: 7.6.17 resolution: "@storybook/addon-viewport@npm:7.6.17" dependencies: - memoizerific: ^1.11.3 - checksum: 26ff73639c47d8363fcfe7ba84bebb327c54309480e8499109d7e319e31e22fe7b18e9bf7246961dd625c14e53d7e5a1e724ad6efd3623d54bad38221f20c1f9 + memoizerific: "npm:^1.11.3" + checksum: 96e7648fff610d9c8233103e7285d15cd3a585049907ef11f0c714f6c6e721bda41a85963ec12e18ffe4a697801a340767f10d3842251c7db0edc5692dc8c14b languageName: node linkType: hard @@ -3113,33 +3260,33 @@ __metadata: version: 7.6.17 resolution: "@storybook/blocks@npm:7.6.17" dependencies: - "@storybook/channels": 7.6.17 - "@storybook/client-logger": 7.6.17 - "@storybook/components": 7.6.17 - "@storybook/core-events": 7.6.17 - "@storybook/csf": ^0.1.2 - "@storybook/docs-tools": 7.6.17 - "@storybook/global": ^5.0.0 - "@storybook/manager-api": 7.6.17 - "@storybook/preview-api": 7.6.17 - "@storybook/theming": 7.6.17 - "@storybook/types": 7.6.17 - "@types/lodash": ^4.14.167 - color-convert: ^2.0.1 - dequal: ^2.0.2 - lodash: ^4.17.21 - markdown-to-jsx: ^7.1.8 - memoizerific: ^1.11.3 - polished: ^4.2.2 - react-colorful: ^5.1.2 - telejson: ^7.2.0 - tocbot: ^4.20.1 - ts-dedent: ^2.0.0 - util-deprecate: ^1.0.2 + "@storybook/channels": "npm:7.6.17" + "@storybook/client-logger": "npm:7.6.17" + "@storybook/components": "npm:7.6.17" + "@storybook/core-events": "npm:7.6.17" + "@storybook/csf": "npm:^0.1.2" + "@storybook/docs-tools": "npm:7.6.17" + "@storybook/global": "npm:^5.0.0" + "@storybook/manager-api": "npm:7.6.17" + "@storybook/preview-api": "npm:7.6.17" + "@storybook/theming": "npm:7.6.17" + "@storybook/types": "npm:7.6.17" + "@types/lodash": "npm:^4.14.167" + color-convert: "npm:^2.0.1" + dequal: "npm:^2.0.2" + lodash: "npm:^4.17.21" + markdown-to-jsx: "npm:^7.1.8" + memoizerific: "npm:^1.11.3" + polished: "npm:^4.2.2" + react-colorful: "npm:^5.1.2" + telejson: "npm:^7.2.0" + tocbot: "npm:^4.20.1" + ts-dedent: "npm:^2.0.0" + util-deprecate: "npm:^1.0.2" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: 5db4092f77a073997586641b97f84ce78f03fc16fcfe50444b8ac7583a9169b8729be285ee5f84fa3eb8544fb1d6f1beceaedff3041ed4f9bfb43512520266cc + checksum: 82667ec9f5b2a812658b30974a98bd2c06b80c850f0cc310f5ff41d00d63ee5087af84de2e9619ba2b641b39c4f9cfa54f831fa8e11983b23869c4ce657f57b7 languageName: node linkType: hard @@ -3147,23 +3294,23 @@ __metadata: version: 7.6.17 resolution: "@storybook/builder-manager@npm:7.6.17" dependencies: - "@fal-works/esbuild-plugin-global-externals": ^2.1.2 - "@storybook/core-common": 7.6.17 - "@storybook/manager": 7.6.17 - "@storybook/node-logger": 7.6.17 - "@types/ejs": ^3.1.1 - "@types/find-cache-dir": ^3.2.1 - "@yarnpkg/esbuild-plugin-pnp": ^3.0.0-rc.10 - browser-assert: ^1.2.1 - ejs: ^3.1.8 - esbuild: ^0.18.0 - esbuild-plugin-alias: ^0.2.1 - express: ^4.17.3 - find-cache-dir: ^3.0.0 - fs-extra: ^11.1.0 - process: ^0.11.10 - util: ^0.12.4 - checksum: 6f2fd8d5cc8dac3fc50c60c514b5ee2b06efa57902b1d5120364673f80a2c43730b8f84b6473b108df38827a8e3f33f7a58b657e7a85bd85962b0f2a41e8c5c3 + "@fal-works/esbuild-plugin-global-externals": "npm:^2.1.2" + "@storybook/core-common": "npm:7.6.17" + "@storybook/manager": "npm:7.6.17" + "@storybook/node-logger": "npm:7.6.17" + "@types/ejs": "npm:^3.1.1" + "@types/find-cache-dir": "npm:^3.2.1" + "@yarnpkg/esbuild-plugin-pnp": "npm:^3.0.0-rc.10" + browser-assert: "npm:^1.2.1" + ejs: "npm:^3.1.8" + esbuild: "npm:^0.18.0" + esbuild-plugin-alias: "npm:^0.2.1" + express: "npm:^4.17.3" + find-cache-dir: "npm:^3.0.0" + fs-extra: "npm:^11.1.0" + process: "npm:^0.11.10" + util: "npm:^0.12.4" + checksum: ad544213969e13bf67931026d5b6c2060617fa12b3939f37b604ff1f697ee785ff12e17ebb057bf076e7c7da2cbfdee76300d71e916d1ea3aa42242077740cec languageName: node linkType: hard @@ -3171,22 +3318,22 @@ __metadata: version: 7.6.17 resolution: "@storybook/builder-vite@npm:7.6.17" dependencies: - "@storybook/channels": 7.6.17 - "@storybook/client-logger": 7.6.17 - "@storybook/core-common": 7.6.17 - "@storybook/csf-plugin": 7.6.17 - "@storybook/node-logger": 7.6.17 - "@storybook/preview": 7.6.17 - "@storybook/preview-api": 7.6.17 - "@storybook/types": 7.6.17 - "@types/find-cache-dir": ^3.2.1 - browser-assert: ^1.2.1 - es-module-lexer: ^0.9.3 - express: ^4.17.3 - find-cache-dir: ^3.0.0 - fs-extra: ^11.1.0 - magic-string: ^0.30.0 - rollup: ^2.25.0 || ^3.3.0 + "@storybook/channels": "npm:7.6.17" + "@storybook/client-logger": "npm:7.6.17" + "@storybook/core-common": "npm:7.6.17" + "@storybook/csf-plugin": "npm:7.6.17" + "@storybook/node-logger": "npm:7.6.17" + "@storybook/preview": "npm:7.6.17" + "@storybook/preview-api": "npm:7.6.17" + "@storybook/types": "npm:7.6.17" + "@types/find-cache-dir": "npm:^3.2.1" + browser-assert: "npm:^1.2.1" + es-module-lexer: "npm:^0.9.3" + express: "npm:^4.17.3" + find-cache-dir: "npm:^3.0.0" + fs-extra: "npm:^11.1.0" + magic-string: "npm:^0.30.0" + rollup: "npm:^2.25.0 || ^3.3.0" peerDependencies: "@preact/preset-vite": "*" typescript: ">= 4.3.x" @@ -3199,7 +3346,7 @@ __metadata: optional: true vite-plugin-glimmerx: optional: true - checksum: 106ea15c8fcfe98accaff82f8d72622e7a391beee7c73a757017bcf3311f62695d88f65731f792e9c63b143436f7f17dd24d1172f15ddf8ddcf7d8868bf9b448 + checksum: 1fa346b3cdd20fd25b1f114f4c9de6c035f4b895088d5f8c4805d0fb50dd399f2bb19e4a0153943edd8151365c2a05ced615eb1a1399c567d92f67782c875b1c languageName: node linkType: hard @@ -3207,13 +3354,13 @@ __metadata: version: 7.6.17 resolution: "@storybook/channels@npm:7.6.17" dependencies: - "@storybook/client-logger": 7.6.17 - "@storybook/core-events": 7.6.17 - "@storybook/global": ^5.0.0 - qs: ^6.10.0 - telejson: ^7.2.0 - tiny-invariant: ^1.3.1 - checksum: b1c1a9ce0bcca16659eb8372394a2f0965ebae26e2add44c7db5f869a00141ab59763917761c7fa1feb81bd1244225e8bcd6f8144f7432ade16e2c868b300926 + "@storybook/client-logger": "npm:7.6.17" + "@storybook/core-events": "npm:7.6.17" + "@storybook/global": "npm:^5.0.0" + qs: "npm:^6.10.0" + telejson: "npm:^7.2.0" + tiny-invariant: "npm:^1.3.1" + checksum: 6a3ea0b94b76a5b4e3614d5ad04207ea71eb9f67fa5f8cad51f2d9199003d8390b471669798b1f306b70b2d3834a87f0588fbd0dd50fd7ea275e18916cc4462a languageName: node linkType: hard @@ -3221,50 +3368,50 @@ __metadata: version: 7.6.17 resolution: "@storybook/cli@npm:7.6.17" dependencies: - "@babel/core": ^7.23.2 - "@babel/preset-env": ^7.23.2 - "@babel/types": ^7.23.0 - "@ndelangen/get-tarball": ^3.0.7 - "@storybook/codemod": 7.6.17 - "@storybook/core-common": 7.6.17 - "@storybook/core-events": 7.6.17 - "@storybook/core-server": 7.6.17 - "@storybook/csf-tools": 7.6.17 - "@storybook/node-logger": 7.6.17 - "@storybook/telemetry": 7.6.17 - "@storybook/types": 7.6.17 - "@types/semver": ^7.3.4 - "@yarnpkg/fslib": 2.10.3 - "@yarnpkg/libzip": 2.3.0 - chalk: ^4.1.0 - commander: ^6.2.1 - cross-spawn: ^7.0.3 - detect-indent: ^6.1.0 - envinfo: ^7.7.3 - execa: ^5.0.0 - express: ^4.17.3 - find-up: ^5.0.0 - fs-extra: ^11.1.0 - get-npm-tarball-url: ^2.0.3 - get-port: ^5.1.1 - giget: ^1.0.0 - globby: ^11.0.2 - jscodeshift: ^0.15.1 - leven: ^3.1.0 - ora: ^5.4.1 - prettier: ^2.8.0 - prompts: ^2.4.0 - puppeteer-core: ^2.1.1 - read-pkg-up: ^7.0.1 - semver: ^7.3.7 - strip-json-comments: ^3.0.1 - tempy: ^1.0.1 - ts-dedent: ^2.0.0 - util-deprecate: ^1.0.2 + "@babel/core": "npm:^7.23.2" + "@babel/preset-env": "npm:^7.23.2" + "@babel/types": "npm:^7.23.0" + "@ndelangen/get-tarball": "npm:^3.0.7" + "@storybook/codemod": "npm:7.6.17" + "@storybook/core-common": "npm:7.6.17" + "@storybook/core-events": "npm:7.6.17" + "@storybook/core-server": "npm:7.6.17" + "@storybook/csf-tools": "npm:7.6.17" + "@storybook/node-logger": "npm:7.6.17" + "@storybook/telemetry": "npm:7.6.17" + "@storybook/types": "npm:7.6.17" + "@types/semver": "npm:^7.3.4" + "@yarnpkg/fslib": "npm:2.10.3" + "@yarnpkg/libzip": "npm:2.3.0" + chalk: "npm:^4.1.0" + commander: "npm:^6.2.1" + cross-spawn: "npm:^7.0.3" + detect-indent: "npm:^6.1.0" + envinfo: "npm:^7.7.3" + execa: "npm:^5.0.0" + express: "npm:^4.17.3" + find-up: "npm:^5.0.0" + fs-extra: "npm:^11.1.0" + get-npm-tarball-url: "npm:^2.0.3" + get-port: "npm:^5.1.1" + giget: "npm:^1.0.0" + globby: "npm:^11.0.2" + jscodeshift: "npm:^0.15.1" + leven: "npm:^3.1.0" + ora: "npm:^5.4.1" + prettier: "npm:^2.8.0" + prompts: "npm:^2.4.0" + puppeteer-core: "npm:^2.1.1" + read-pkg-up: "npm:^7.0.1" + semver: "npm:^7.3.7" + strip-json-comments: "npm:^3.0.1" + tempy: "npm:^1.0.1" + ts-dedent: "npm:^2.0.0" + util-deprecate: "npm:^1.0.2" bin: getstorybook: ./bin/index.js sb: ./bin/index.js - checksum: 81787acc86220313038461c4d0a8c414b91a8c945ec185dd7e074dcf83a7b41d698dac481ed81fad045a2b9b364549eb38f0d8284520caa4cd61de9a9f876a24 + checksum: 8be534cd4fafa5b0a9ffea94d945d442a6976491d4db19494640630000472929de72c037d8cb583d0afa4fe28341e73519b1e199548282aefedb91c6ffc91438 languageName: node linkType: hard @@ -3272,8 +3419,8 @@ __metadata: version: 7.6.17 resolution: "@storybook/client-logger@npm:7.6.17" dependencies: - "@storybook/global": ^5.0.0 - checksum: 216feb7dcc5778d9b39c9deba1eeda0f7253cd0fe2515a7e99a49d2abd6ca6d697a70162c8b34b92ab14531910dd8671200725fd016c09d769893023031c6080 + "@storybook/global": "npm:^5.0.0" + checksum: a6e4f76eee426fcf9aae4ae660d0b81d71f60b29e36d81136901d73b79d19799df4f86f740d023c076f954d8c8e732cad8b0c91b3dffe774509a155f613d4f2c languageName: node linkType: hard @@ -3281,21 +3428,21 @@ __metadata: version: 7.6.17 resolution: "@storybook/codemod@npm:7.6.17" dependencies: - "@babel/core": ^7.23.2 - "@babel/preset-env": ^7.23.2 - "@babel/types": ^7.23.0 - "@storybook/csf": ^0.1.2 - "@storybook/csf-tools": 7.6.17 - "@storybook/node-logger": 7.6.17 - "@storybook/types": 7.6.17 - "@types/cross-spawn": ^6.0.2 - cross-spawn: ^7.0.3 - globby: ^11.0.2 - jscodeshift: ^0.15.1 - lodash: ^4.17.21 - prettier: ^2.8.0 - recast: ^0.23.1 - checksum: 7cd89a7dcf66acd5c102053df4cdc93b6c407a014f653d7c1f0bb1b010e83d006c7d8ab8d0feb52ee09f120b0336e9df12fc8f8c52c20144dd790f49627d865b + "@babel/core": "npm:^7.23.2" + "@babel/preset-env": "npm:^7.23.2" + "@babel/types": "npm:^7.23.0" + "@storybook/csf": "npm:^0.1.2" + "@storybook/csf-tools": "npm:7.6.17" + "@storybook/node-logger": "npm:7.6.17" + "@storybook/types": "npm:7.6.17" + "@types/cross-spawn": "npm:^6.0.2" + cross-spawn: "npm:^7.0.3" + globby: "npm:^11.0.2" + jscodeshift: "npm:^0.15.1" + lodash: "npm:^4.17.21" + prettier: "npm:^2.8.0" + recast: "npm:^0.23.1" + checksum: bf8125f375308782da65323a36d89ee5096ce4f81cdfa6faea8d420329efe7b31f2c74b265e6520201e22d54f9b158d247055c3cc037e7c12dd31293f64b98e6 languageName: node linkType: hard @@ -3303,20 +3450,20 @@ __metadata: version: 7.6.17 resolution: "@storybook/components@npm:7.6.17" dependencies: - "@radix-ui/react-select": ^1.2.2 - "@radix-ui/react-toolbar": ^1.0.4 - "@storybook/client-logger": 7.6.17 - "@storybook/csf": ^0.1.2 - "@storybook/global": ^5.0.0 - "@storybook/theming": 7.6.17 - "@storybook/types": 7.6.17 - memoizerific: ^1.11.3 - use-resize-observer: ^9.1.0 - util-deprecate: ^1.0.2 + "@radix-ui/react-select": "npm:^1.2.2" + "@radix-ui/react-toolbar": "npm:^1.0.4" + "@storybook/client-logger": "npm:7.6.17" + "@storybook/csf": "npm:^0.1.2" + "@storybook/global": "npm:^5.0.0" + "@storybook/theming": "npm:7.6.17" + "@storybook/types": "npm:7.6.17" + memoizerific: "npm:^1.11.3" + use-resize-observer: "npm:^9.1.0" + util-deprecate: "npm:^1.0.2" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: eb56530745b8561239d210accff71b2eff73ff3b169dc60948f8d6c2c37a01bc4f44b49514ed45d13411dde6a50f04869a6589f3c480b588d7a450972550e446 + checksum: 2e288e0e66bdca16a0b74bd160b54ed55595a303143449176b776ea1345fe8e9a52717339e8fe1ed231919bde42f57bbb555d8338ef24e83cdcfe0445d2ee81b languageName: node linkType: hard @@ -3324,9 +3471,9 @@ __metadata: version: 7.6.17 resolution: "@storybook/core-client@npm:7.6.17" dependencies: - "@storybook/client-logger": 7.6.17 - "@storybook/preview-api": 7.6.17 - checksum: 5bc150d8c636c5ffd40a4a8f72e60d689580508bb41aa6b544c9ff8b20860afd000498bdd77c6de62f56651f99ef88af67e4b47caba41ec27e5a748099f690f0 + "@storybook/client-logger": "npm:7.6.17" + "@storybook/preview-api": "npm:7.6.17" + checksum: adb1bc7d32810612b1c108ebefdba73ec156e57dcdf1078366eb2d3ae20919526e0d3cef26090ebd90244a7c67c0abc2d066ec03de32b48b6b674824e1a1b095 languageName: node linkType: hard @@ -3334,30 +3481,30 @@ __metadata: version: 7.6.17 resolution: "@storybook/core-common@npm:7.6.17" dependencies: - "@storybook/core-events": 7.6.17 - "@storybook/node-logger": 7.6.17 - "@storybook/types": 7.6.17 - "@types/find-cache-dir": ^3.2.1 - "@types/node": ^18.0.0 - "@types/node-fetch": ^2.6.4 - "@types/pretty-hrtime": ^1.0.0 - chalk: ^4.1.0 - esbuild: ^0.18.0 - esbuild-register: ^3.5.0 - file-system-cache: 2.3.0 - find-cache-dir: ^3.0.0 - find-up: ^5.0.0 - fs-extra: ^11.1.0 - glob: ^10.0.0 - handlebars: ^4.7.7 - lazy-universal-dotenv: ^4.0.0 - node-fetch: ^2.0.0 - picomatch: ^2.3.0 - pkg-dir: ^5.0.0 - pretty-hrtime: ^1.0.3 - resolve-from: ^5.0.0 - ts-dedent: ^2.0.0 - checksum: 28d881453228237d3d653f5e5b62499520864ba733ccfa480e4e7bb72c37be0ee1711b0b6060720f10172b113a5243c7e73187c867567f0c677c88466935b5ab + "@storybook/core-events": "npm:7.6.17" + "@storybook/node-logger": "npm:7.6.17" + "@storybook/types": "npm:7.6.17" + "@types/find-cache-dir": "npm:^3.2.1" + "@types/node": "npm:^18.0.0" + "@types/node-fetch": "npm:^2.6.4" + "@types/pretty-hrtime": "npm:^1.0.0" + chalk: "npm:^4.1.0" + esbuild: "npm:^0.18.0" + esbuild-register: "npm:^3.5.0" + file-system-cache: "npm:2.3.0" + find-cache-dir: "npm:^3.0.0" + find-up: "npm:^5.0.0" + fs-extra: "npm:^11.1.0" + glob: "npm:^10.0.0" + handlebars: "npm:^4.7.7" + lazy-universal-dotenv: "npm:^4.0.0" + node-fetch: "npm:^2.0.0" + picomatch: "npm:^2.3.0" + pkg-dir: "npm:^5.0.0" + pretty-hrtime: "npm:^1.0.3" + resolve-from: "npm:^5.0.0" + ts-dedent: "npm:^2.0.0" + checksum: 80ff478a8e11871a898cd6a5e26b3c939c987194b384fd22baee39275d21679522e905932a0903339e7a1f1ae355b3e778095cb7a8cf48482dccf9385455ad76 languageName: node linkType: hard @@ -3365,8 +3512,8 @@ __metadata: version: 7.6.17 resolution: "@storybook/core-events@npm:7.6.17" dependencies: - ts-dedent: ^2.0.0 - checksum: 7463d8349211f23e9a25e08d85b04b9f6b24ee8747c775a8ec41ac4ff208e06f5183487d0f92af1e820f9c5044393a28e2065e5183a43b758f65deaab3ac3b61 + ts-dedent: "npm:^2.0.0" + checksum: 07b54f574972c0a36e7356ef9908318c8132d33543b7e3a1d4f7e3cae08f4790fe8ee8dfca0b178025601c7267f2e947b15767e745f178bb8876c43498bc592f languageName: node linkType: hard @@ -3374,48 +3521,48 @@ __metadata: version: 7.6.17 resolution: "@storybook/core-server@npm:7.6.17" dependencies: - "@aw-web-design/x-default-browser": 1.4.126 - "@discoveryjs/json-ext": ^0.5.3 - "@storybook/builder-manager": 7.6.17 - "@storybook/channels": 7.6.17 - "@storybook/core-common": 7.6.17 - "@storybook/core-events": 7.6.17 - "@storybook/csf": ^0.1.2 - "@storybook/csf-tools": 7.6.17 - "@storybook/docs-mdx": ^0.1.0 - "@storybook/global": ^5.0.0 - "@storybook/manager": 7.6.17 - "@storybook/node-logger": 7.6.17 - "@storybook/preview-api": 7.6.17 - "@storybook/telemetry": 7.6.17 - "@storybook/types": 7.6.17 - "@types/detect-port": ^1.3.0 - "@types/node": ^18.0.0 - "@types/pretty-hrtime": ^1.0.0 - "@types/semver": ^7.3.4 - better-opn: ^3.0.2 - chalk: ^4.1.0 - cli-table3: ^0.6.1 - compression: ^1.7.4 - detect-port: ^1.3.0 - express: ^4.17.3 - fs-extra: ^11.1.0 - globby: ^11.0.2 - ip: ^2.0.1 - lodash: ^4.17.21 - open: ^8.4.0 - pretty-hrtime: ^1.0.3 - prompts: ^2.4.0 - read-pkg-up: ^7.0.1 - semver: ^7.3.7 - telejson: ^7.2.0 - tiny-invariant: ^1.3.1 - ts-dedent: ^2.0.0 - util: ^0.12.4 - util-deprecate: ^1.0.2 - watchpack: ^2.2.0 - ws: ^8.2.3 - checksum: 47dc08900a682a77ed2cc4e842586c66a800d3feb3644429d8048ee57f9c0fe26606f017862992121408695f65ee85ad907c2635b40dc24f44f27873277ce380 + "@aw-web-design/x-default-browser": "npm:1.4.126" + "@discoveryjs/json-ext": "npm:^0.5.3" + "@storybook/builder-manager": "npm:7.6.17" + "@storybook/channels": "npm:7.6.17" + "@storybook/core-common": "npm:7.6.17" + "@storybook/core-events": "npm:7.6.17" + "@storybook/csf": "npm:^0.1.2" + "@storybook/csf-tools": "npm:7.6.17" + "@storybook/docs-mdx": "npm:^0.1.0" + "@storybook/global": "npm:^5.0.0" + "@storybook/manager": "npm:7.6.17" + "@storybook/node-logger": "npm:7.6.17" + "@storybook/preview-api": "npm:7.6.17" + "@storybook/telemetry": "npm:7.6.17" + "@storybook/types": "npm:7.6.17" + "@types/detect-port": "npm:^1.3.0" + "@types/node": "npm:^18.0.0" + "@types/pretty-hrtime": "npm:^1.0.0" + "@types/semver": "npm:^7.3.4" + better-opn: "npm:^3.0.2" + chalk: "npm:^4.1.0" + cli-table3: "npm:^0.6.1" + compression: "npm:^1.7.4" + detect-port: "npm:^1.3.0" + express: "npm:^4.17.3" + fs-extra: "npm:^11.1.0" + globby: "npm:^11.0.2" + ip: "npm:^2.0.1" + lodash: "npm:^4.17.21" + open: "npm:^8.4.0" + pretty-hrtime: "npm:^1.0.3" + prompts: "npm:^2.4.0" + read-pkg-up: "npm:^7.0.1" + semver: "npm:^7.3.7" + telejson: "npm:^7.2.0" + tiny-invariant: "npm:^1.3.1" + ts-dedent: "npm:^2.0.0" + util: "npm:^0.12.4" + util-deprecate: "npm:^1.0.2" + watchpack: "npm:^2.2.0" + ws: "npm:^8.2.3" + checksum: 20118728a4acf593f25fbcbbf9e4d945b2f9a6bce0e9366ad27da1bc19a9c596fe15a1e74629aff75fc04e752d22a0887f236e04a90fdf4c99f60c2851812cb6 languageName: node linkType: hard @@ -3423,8 +3570,8 @@ __metadata: version: 7.6.17 resolution: "@storybook/csf-plugin@npm:7.6.17" dependencies: - "@storybook/csf-tools": 7.6.17 - unplugin: ^1.3.1 + "@storybook/csf-tools": "npm:7.6.17" + unplugin: "npm:^1.3.1" checksum: d3689b7a4d22f4b06f889a20e3d54c9f72bf1a6e5aa732cba7d60068b468745c099dbf333f7750a34309d9fcbada15fb895961f92c5e4e1279e60055df4cfef5 languageName: node linkType: hard @@ -3433,16 +3580,16 @@ __metadata: version: 7.6.17 resolution: "@storybook/csf-tools@npm:7.6.17" dependencies: - "@babel/generator": ^7.23.0 - "@babel/parser": ^7.23.0 - "@babel/traverse": ^7.23.2 - "@babel/types": ^7.23.0 - "@storybook/csf": ^0.1.2 - "@storybook/types": 7.6.17 - fs-extra: ^11.1.0 - recast: ^0.23.1 - ts-dedent: ^2.0.0 - checksum: d1f92f08a559dbbd09302364da1ec570a57278322523c9e8ce577fb2fa768b84ade3733a93eaec83f1b13f64eb37be2c079e8b7820b29dd929482ddf0855bf68 + "@babel/generator": "npm:^7.23.0" + "@babel/parser": "npm:^7.23.0" + "@babel/traverse": "npm:^7.23.2" + "@babel/types": "npm:^7.23.0" + "@storybook/csf": "npm:^0.1.2" + "@storybook/types": "npm:7.6.17" + fs-extra: "npm:^11.1.0" + recast: "npm:^0.23.1" + ts-dedent: "npm:^2.0.0" + checksum: d21fe4e09d1688465099bc3eef3088b0bde697fcf7618695a5f53d4dd50a84d2160a42734cee19cd5f3ff95c42d123bec471422b985c3f03fac45b126d638b3c languageName: node linkType: hard @@ -3450,8 +3597,8 @@ __metadata: version: 0.0.1 resolution: "@storybook/csf@npm:0.0.1" dependencies: - lodash: ^4.17.15 - checksum: fb57fa028b08a51edf44e1a2bf4be40a4607f5c6ccb58aae8924f476a42b9bbd61a0ad521cfc82196f23e6a912caae0a615e70a755e6800b284c91c509fd2de6 + lodash: "npm:^4.17.15" + checksum: f6bb019bccd8abc14e45a85258158b7bd8cc525887ac8dc9151ed8c4908be3b5f5523da8a7a9b96ff11b13b6c1744e1a0e070560d63d836b950f595f9a5719d4 languageName: node linkType: hard @@ -3459,15 +3606,15 @@ __metadata: version: 0.1.2 resolution: "@storybook/csf@npm:0.1.2" dependencies: - type-fest: ^2.19.0 - checksum: 22038dfd5e46cd9565c3dec615918c0712eb5fc5f56e9ec89cfa75d7b48667b8fcbf7e9d1f46c9f4d440eee074f1d23a84dc56a937add37b28ddf890fdedfb8a + type-fest: "npm:^2.19.0" + checksum: 11168df65e7b6bd0e5d31e7e805c8ba80397fc190cb33424e043b72bbd85d8f826dba082503992d7f606b72484337ab9d091eca947550613e241fbef57780d4c languageName: node linkType: hard "@storybook/docs-mdx@npm:^0.1.0": version: 0.1.0 resolution: "@storybook/docs-mdx@npm:0.1.0" - checksum: a7770842c3947a761bcbe776a9c4fd35163d30c3274fca034169f69ff614242eaa4cacaa2c95fd215827081ef9a43f4774d521a6f43a4d063ea5f4ea14b1d69a + checksum: f830eda81606a8af86d2bbf9ed6e36c70d9e88442990684139629742f2cc5d7ddddba91338fe2fc5e9b98e74af1940a9899fde471a8bfbfec744deaa990592e7 languageName: node linkType: hard @@ -3475,21 +3622,21 @@ __metadata: version: 7.6.17 resolution: "@storybook/docs-tools@npm:7.6.17" dependencies: - "@storybook/core-common": 7.6.17 - "@storybook/preview-api": 7.6.17 - "@storybook/types": 7.6.17 - "@types/doctrine": ^0.0.3 - assert: ^2.1.0 - doctrine: ^3.0.0 - lodash: ^4.17.21 - checksum: 62700508d74ab40717095e1684c036c4b2b9e104c397cd2ffcf455e116b90ba8e51cda3f501934eb31c5bc8646a99cd4c46b362bb833772cd0898ba2bd8e2544 + "@storybook/core-common": "npm:7.6.17" + "@storybook/preview-api": "npm:7.6.17" + "@storybook/types": "npm:7.6.17" + "@types/doctrine": "npm:^0.0.3" + assert: "npm:^2.1.0" + doctrine: "npm:^3.0.0" + lodash: "npm:^4.17.21" + checksum: c2900d523b0490cb9cb1ff81764540d40064a2960fbda52ba77a8f09bd998e46440af2d629eb13a22f8de824d7991d6b44ef90f718adeb11569ce498286d3ea4 languageName: node linkType: hard "@storybook/global@npm:^5.0.0": version: 5.0.0 resolution: "@storybook/global@npm:5.0.0" - checksum: ede0ad35ec411fe31c61150dbd118fef344d1d0e72bf5d3502368e35cf68126f6b7ae4a0ab5e2ffe2f0baa3b4286f03ad069ba3e098e1725449ef08b7e154ba8 + checksum: 0e7b495f4fe7f36447e793926f1c0460ec07fd66f0da68e3150da5878f6043c9eeb9b41614a45c5ec0d48d5d383c59ca8f88b6dc7882a2a784ac9b20375d8edb languageName: node linkType: hard @@ -3497,49 +3644,49 @@ __metadata: version: 7.6.17 resolution: "@storybook/manager-api@npm:7.6.17" dependencies: - "@storybook/channels": 7.6.17 - "@storybook/client-logger": 7.6.17 - "@storybook/core-events": 7.6.17 - "@storybook/csf": ^0.1.2 - "@storybook/global": ^5.0.0 - "@storybook/router": 7.6.17 - "@storybook/theming": 7.6.17 - "@storybook/types": 7.6.17 - dequal: ^2.0.2 - lodash: ^4.17.21 - memoizerific: ^1.11.3 - store2: ^2.14.2 - telejson: ^7.2.0 - ts-dedent: ^2.0.0 - checksum: 54c0b7a703fe928c93cbe4230b2d7e30317c064f4c34339bcf41c1d638892c47b33dc6b7fd5aaf4c559a4170e9eb442b49cb6144f2f9085bc4a999b6cc1b2028 + "@storybook/channels": "npm:7.6.17" + "@storybook/client-logger": "npm:7.6.17" + "@storybook/core-events": "npm:7.6.17" + "@storybook/csf": "npm:^0.1.2" + "@storybook/global": "npm:^5.0.0" + "@storybook/router": "npm:7.6.17" + "@storybook/theming": "npm:7.6.17" + "@storybook/types": "npm:7.6.17" + dequal: "npm:^2.0.2" + lodash: "npm:^4.17.21" + memoizerific: "npm:^1.11.3" + store2: "npm:^2.14.2" + telejson: "npm:^7.2.0" + ts-dedent: "npm:^2.0.0" + checksum: 0e21042d06effabdd89a9b1edb972584a894eddb0e5551fd5f0e58e22b4fc8ef3c760f01b63f37dd22e3287f4bb16a508b1792014ce676854e32351fbb903328 languageName: node linkType: hard "@storybook/manager@npm:7.6.17": version: 7.6.17 resolution: "@storybook/manager@npm:7.6.17" - checksum: f961367cabc088bad2942fe6a34e6ca1f801068d5f7d63f0387b2bb6eb0a216d3a5d813195994f4704f7a9fabf09f0bd85373c7df960769775bfe21029655216 + checksum: 41dc7609235410088e20839e233bfbb833242defeb70fab951d95c72cd8a29db2e2a90a49bfd011dd944d14c33370f76c8af638969549bbea8907f14d1d867ef languageName: node linkType: hard "@storybook/mdx2-csf@npm:^1.0.0": version: 1.1.0 resolution: "@storybook/mdx2-csf@npm:1.1.0" - checksum: 5ccdb13f4e59b989499f76e54ffaffb96b5710a696346efe19989b3373f375703adf516780894b270fa64a7e765b55274dc18575fc4a84e7fa92b844a4467c5d + checksum: acc368a8c8915e9487aa8e0c59241a39533d83635ddcc37fa4095cc239268a75900ec2bbfff65b573ead6ebcadcb1de0e4d70c9112faf105e0821de0a4803ca2 languageName: node linkType: hard "@storybook/node-logger@npm:7.6.17": version: 7.6.17 resolution: "@storybook/node-logger@npm:7.6.17" - checksum: cb39fa5a93b84a52251e324000a0cad7df1e56553542d06ebc50f3aea0b790b7b3774f7c4a6bb4d3bf6764eb7951caa82decd8e091ef4c73aa5c09c9fa446f40 + checksum: 10f9141caabf8377492470f242ec75008a680a22632ec47f5bc2e37886938eddfb3b25c6c4f757df92badd5c23ea19f8712c0448f4e620dae2ca82cdf0236efb languageName: node linkType: hard "@storybook/postinstall@npm:7.6.17": version: 7.6.17 resolution: "@storybook/postinstall@npm:7.6.17" - checksum: d33f6a0e1ed2596fe29e91d835ec0b3c92ef68703ca7f709b191b5236af34f85d9b97c587509e2e614228c2f8b6cf8c41c5f869b902e1661a59a81fb7a54b0d4 + checksum: bdafa7bbcf8e6bbb94e5b4590bd3af2433a31cb2bb03716e91303f41fafd20a34bca906b97a3c675ac5c7de53d2b145cf1ac070d25bdd0cec23db815bbceb1b3 languageName: node linkType: hard @@ -3547,28 +3694,28 @@ __metadata: version: 7.6.17 resolution: "@storybook/preview-api@npm:7.6.17" dependencies: - "@storybook/channels": 7.6.17 - "@storybook/client-logger": 7.6.17 - "@storybook/core-events": 7.6.17 - "@storybook/csf": ^0.1.2 - "@storybook/global": ^5.0.0 - "@storybook/types": 7.6.17 - "@types/qs": ^6.9.5 - dequal: ^2.0.2 - lodash: ^4.17.21 - memoizerific: ^1.11.3 - qs: ^6.10.0 - synchronous-promise: ^2.0.15 - ts-dedent: ^2.0.0 - util-deprecate: ^1.0.2 - checksum: f448058f6f8b9d5a88083454d8296df79effc2f6500f4cb3406d18914ca2f972623a77fafc7f7c35bba077fe8ea4fa73965007bd130484dfa6be95a7c7a0e863 + "@storybook/channels": "npm:7.6.17" + "@storybook/client-logger": "npm:7.6.17" + "@storybook/core-events": "npm:7.6.17" + "@storybook/csf": "npm:^0.1.2" + "@storybook/global": "npm:^5.0.0" + "@storybook/types": "npm:7.6.17" + "@types/qs": "npm:^6.9.5" + dequal: "npm:^2.0.2" + lodash: "npm:^4.17.21" + memoizerific: "npm:^1.11.3" + qs: "npm:^6.10.0" + synchronous-promise: "npm:^2.0.15" + ts-dedent: "npm:^2.0.0" + util-deprecate: "npm:^1.0.2" + checksum: 4a2b8350b3d048966313cf6a1edadf36e59af1455425a0eba05255e6ae9be3afe986045d6ee08f3b7198ad285bd8841761e50e8c48ecbdbbd993b438e64b2d58 languageName: node linkType: hard "@storybook/preview@npm:7.6.17": version: 7.6.17 resolution: "@storybook/preview@npm:7.6.17" - checksum: 05433836892b553db29ae3e3e7fbcbfab02db2538032c24180990aee8a99b7cd225176d6c48b4123da74d41cd8dc42d1f782c5bb33c3fb4d61177e1ceef1754e + checksum: 3359606fbe96df4fbb1da9e8644cbbf8315703fedb4576d6765a8dffc65ff6df19fa1d0898c1ecc9f3b9432e55fbf30ac75f8f2dd2df03cd8fe09f50e14d10ab languageName: node linkType: hard @@ -3578,7 +3725,7 @@ __metadata: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: 24c26515785542b7ad4602f89164413c48b2863e24536e29a30f23e9afad19262e5bdb6b4319a1bda47d7651aa0fa439451ace45ff89966dbbfc0eb9ff32566f + checksum: c1424ee03c2404e2970078719a0fd1b8c0f23bfd0c3161758bbfa5d9a69969dd4e0b78d1566058ebaa6c148a70fbb462217cfd1f05499ae84218b550a1bc2477 languageName: node linkType: hard @@ -3586,18 +3733,18 @@ __metadata: version: 7.6.17 resolution: "@storybook/react-vite@npm:7.6.17" dependencies: - "@joshwooding/vite-plugin-react-docgen-typescript": 0.3.0 - "@rollup/pluginutils": ^5.0.2 - "@storybook/builder-vite": 7.6.17 - "@storybook/react": 7.6.17 - "@vitejs/plugin-react": ^3.0.1 - magic-string: ^0.30.0 - react-docgen: ^7.0.0 + "@joshwooding/vite-plugin-react-docgen-typescript": "npm:0.3.0" + "@rollup/pluginutils": "npm:^5.0.2" + "@storybook/builder-vite": "npm:7.6.17" + "@storybook/react": "npm:7.6.17" + "@vitejs/plugin-react": "npm:^3.0.1" + magic-string: "npm:^0.30.0" + react-docgen: "npm:^7.0.0" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 vite: ^3.0.0 || ^4.0.0 || ^5.0.0 - checksum: cf1b71d44f53cf0da9c5383ed1ca407e0f639b439361d5ee5bf8bef4e1f39469b6b9af7fc632c5628d68890422409b3051c0296b8c873f5f7d31ea70529827b4 + checksum: a8db6321421c0e67006b5dc869c5cf732e7738c58d7f0a808baa07c5b1dfd54f4ffe4f13d0e3b7d33fa27d8d24dc6a960d41490f736e7e517b3ca2bfb8ec3f9a languageName: node linkType: hard @@ -3605,27 +3752,27 @@ __metadata: version: 7.6.17 resolution: "@storybook/react@npm:7.6.17" dependencies: - "@storybook/client-logger": 7.6.17 - "@storybook/core-client": 7.6.17 - "@storybook/docs-tools": 7.6.17 - "@storybook/global": ^5.0.0 - "@storybook/preview-api": 7.6.17 - "@storybook/react-dom-shim": 7.6.17 - "@storybook/types": 7.6.17 - "@types/escodegen": ^0.0.6 - "@types/estree": ^0.0.51 - "@types/node": ^18.0.0 - acorn: ^7.4.1 - acorn-jsx: ^5.3.1 - acorn-walk: ^7.2.0 - escodegen: ^2.1.0 - html-tags: ^3.1.0 - lodash: ^4.17.21 - prop-types: ^15.7.2 - react-element-to-jsx-string: ^15.0.0 - ts-dedent: ^2.0.0 - type-fest: ~2.19 - util-deprecate: ^1.0.2 + "@storybook/client-logger": "npm:7.6.17" + "@storybook/core-client": "npm:7.6.17" + "@storybook/docs-tools": "npm:7.6.17" + "@storybook/global": "npm:^5.0.0" + "@storybook/preview-api": "npm:7.6.17" + "@storybook/react-dom-shim": "npm:7.6.17" + "@storybook/types": "npm:7.6.17" + "@types/escodegen": "npm:^0.0.6" + "@types/estree": "npm:^0.0.51" + "@types/node": "npm:^18.0.0" + acorn: "npm:^7.4.1" + acorn-jsx: "npm:^5.3.1" + acorn-walk: "npm:^7.2.0" + escodegen: "npm:^2.1.0" + html-tags: "npm:^3.1.0" + lodash: "npm:^4.17.21" + prop-types: "npm:^15.7.2" + react-element-to-jsx-string: "npm:^15.0.0" + ts-dedent: "npm:^2.0.0" + type-fest: "npm:~2.19" + util-deprecate: "npm:^1.0.2" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -3633,7 +3780,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 4c2bc6a26208e06004a212e485c2a8f24ddec1a53777c04ded415bd2eb32981209f641fe72fb5f951760b0018d4cc2639d74f9997f4967852b03e29292c3ce73 + checksum: 7582967e72448b6b23e086c616784637b91f05550b85858e530b9c1492bf1568b5f86e8e357ccc6b99186e77512e11a6644b762b9ee804736bf17b5e473f7adf languageName: node linkType: hard @@ -3641,10 +3788,10 @@ __metadata: version: 7.6.17 resolution: "@storybook/router@npm:7.6.17" dependencies: - "@storybook/client-logger": 7.6.17 - memoizerific: ^1.11.3 - qs: ^6.10.0 - checksum: a4baaaaf5c04d6d2c9d3e3675c3c00356fc1e48089fc398c1a65922a53607ddcd278cc555caa30e96dfa8296262fc9618dc20c06825dea86884ce02df30420c4 + "@storybook/client-logger": "npm:7.6.17" + memoizerific: "npm:^1.11.3" + qs: "npm:^6.10.0" + checksum: 370157c9bed6bfdbc3605d2edb17a78c15f03c6176568b22aaa5adaa5bf814b049bb030aa24a836848411c6dd645276d105953f5efdfaac08cacf4e8b4b81312 languageName: node linkType: hard @@ -3652,15 +3799,15 @@ __metadata: version: 7.6.17 resolution: "@storybook/telemetry@npm:7.6.17" dependencies: - "@storybook/client-logger": 7.6.17 - "@storybook/core-common": 7.6.17 - "@storybook/csf-tools": 7.6.17 - chalk: ^4.1.0 - detect-package-manager: ^2.0.1 - fetch-retry: ^5.0.2 - fs-extra: ^11.1.0 - read-pkg-up: ^7.0.1 - checksum: 95fe05aed56a3e5898802f32e89eac4422a65411bf00bfcc4c79f7a5a115786e94efecb9d4f324f25af8a214d9e106fd64467f60ff486ff92f43c61a5242713a + "@storybook/client-logger": "npm:7.6.17" + "@storybook/core-common": "npm:7.6.17" + "@storybook/csf-tools": "npm:7.6.17" + chalk: "npm:^4.1.0" + detect-package-manager: "npm:^2.0.1" + fetch-retry: "npm:^5.0.2" + fs-extra: "npm:^11.1.0" + read-pkg-up: "npm:^7.0.1" + checksum: ffbab1025e972ba77521ed6107ace7ab4ccd1ba8d5cb93e5bcc3ac45731ea8a742dee3d2a42ecbc32456fd13bf6c0f9d93707a0615106d58759e7a6dcf53736c languageName: node linkType: hard @@ -3668,10 +3815,10 @@ __metadata: version: 0.2.2 resolution: "@storybook/testing-library@npm:0.2.2" dependencies: - "@testing-library/dom": ^9.0.0 - "@testing-library/user-event": ^14.4.0 - ts-dedent: ^2.2.0 - checksum: 8ccdc1fbbb3472264c56b0aaf2f1c5d273f1ae9b230a53adf9cf82bf82c1a555550894f0e8869c206fa07b1fe8423da4d56590377756c58de3ec560b35a96c46 + "@testing-library/dom": "npm:^9.0.0" + "@testing-library/user-event": "npm:^14.4.0" + ts-dedent: "npm:^2.2.0" + checksum: 85a8c39b432009c5ebac40569deef48b54f3b65f91bd1902bc86d4f130433bff2866c8bece9acdbd3f5cc92da5a1401f7405d11457570c96d3a30ba21c976b7b languageName: node linkType: hard @@ -3679,14 +3826,14 @@ __metadata: version: 7.6.17 resolution: "@storybook/theming@npm:7.6.17" dependencies: - "@emotion/use-insertion-effect-with-fallbacks": ^1.0.0 - "@storybook/client-logger": 7.6.17 - "@storybook/global": ^5.0.0 - memoizerific: ^1.11.3 + "@emotion/use-insertion-effect-with-fallbacks": "npm:^1.0.0" + "@storybook/client-logger": "npm:7.6.17" + "@storybook/global": "npm:^5.0.0" + memoizerific: "npm:^1.11.3" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: 0b0e910678166ee720db3c257558b7f787e883032e23d25e0cf35ce00e8eea390b4cc5471e6b247e02cddc8263de780ec7ba7ddee6b64a3c36ae54087128668b + checksum: 1fb988364b02ddcd84f18800c5f952c8d90a1b20b129821dee8a965136f14a1d15973903be7239513d9fc3b3419cbc0e79e305ecea26b15d86cc689a439a8d38 languageName: node linkType: hard @@ -3694,11 +3841,11 @@ __metadata: version: 7.6.17 resolution: "@storybook/types@npm:7.6.17" dependencies: - "@storybook/channels": 7.6.17 - "@types/babel__core": ^7.0.0 - "@types/express": ^4.7.0 - file-system-cache: 2.3.0 - checksum: 7ba71e3a8a15078a098cec35d78f37293fb01dba9d37dd9d040584531100c34811ba80b72b7b192d1e41f197ffb1bc20818ce72e9f348602f104d972def6ac51 + "@storybook/channels": "npm:7.6.17" + "@types/babel__core": "npm:^7.0.0" + "@types/express": "npm:^4.7.0" + file-system-cache: "npm:2.3.0" + checksum: 6105905f8df6c7dad957c95718fc009b0cd6e96106ed3dab8c148af919464488532920449ab2fd21a0a6aea049098a4c7ab26248b6d2859e2a9d5f23149d908b languageName: node linkType: hard @@ -3770,7 +3917,7 @@ __metadata: resolution: "@svgr/babel-plugin-transform-svg-component@npm:7.0.0" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: cf5be9c6b24a4a9c0afefe67214370af1cd562d9a06082a89486ec25298a223766cdf57591c92750764068a0d27377c3ce3a9609d18eaae59f64c94e60f2b25c + checksum: 0cd529df17943386d74d5d739779e377993cd531fb2607582a451dbe602eb28e051cf5260c90d7ce1578deed8be829552ea793f17e3a4e549764e67aeb983452 languageName: node linkType: hard @@ -3778,14 +3925,14 @@ __metadata: version: 7.0.0 resolution: "@svgr/babel-preset@npm:7.0.0" dependencies: - "@svgr/babel-plugin-add-jsx-attribute": ^7.0.0 - "@svgr/babel-plugin-remove-jsx-attribute": ^7.0.0 - "@svgr/babel-plugin-remove-jsx-empty-expression": ^7.0.0 - "@svgr/babel-plugin-replace-jsx-attribute-value": ^7.0.0 - "@svgr/babel-plugin-svg-dynamic-title": ^7.0.0 - "@svgr/babel-plugin-svg-em-dimensions": ^7.0.0 - "@svgr/babel-plugin-transform-react-native-svg": ^7.0.0 - "@svgr/babel-plugin-transform-svg-component": ^7.0.0 + "@svgr/babel-plugin-add-jsx-attribute": "npm:^7.0.0" + "@svgr/babel-plugin-remove-jsx-attribute": "npm:^7.0.0" + "@svgr/babel-plugin-remove-jsx-empty-expression": "npm:^7.0.0" + "@svgr/babel-plugin-replace-jsx-attribute-value": "npm:^7.0.0" + "@svgr/babel-plugin-svg-dynamic-title": "npm:^7.0.0" + "@svgr/babel-plugin-svg-em-dimensions": "npm:^7.0.0" + "@svgr/babel-plugin-transform-react-native-svg": "npm:^7.0.0" + "@svgr/babel-plugin-transform-svg-component": "npm:^7.0.0" peerDependencies: "@babel/core": ^7.0.0-0 checksum: 8c3ff1df1627b2db03e4755281b02e7f440323c9c9f71e3c8ebdab0e1966e24ca16686224da72a92e34b722e693bfa408aca5c62d42b02382e0c528bd3860be6 @@ -3796,11 +3943,11 @@ __metadata: version: 7.0.0 resolution: "@svgr/core@npm:7.0.0" dependencies: - "@babel/core": ^7.21.3 - "@svgr/babel-preset": ^7.0.0 - camelcase: ^6.2.0 - cosmiconfig: ^8.1.3 - checksum: 34fa14557baf560c78a6d7ac79401d35feb081d54ab55ee1b43d1649a89b322b4ecc7dba3daca0063af1f639c499cea1c46e34e5b066655ae7dc3553c1a64672 + "@babel/core": "npm:^7.21.3" + "@svgr/babel-preset": "npm:^7.0.0" + camelcase: "npm:^6.2.0" + cosmiconfig: "npm:^8.1.3" + checksum: 47cd50b74dcf1be2e41ae894da425346d83c5f4caf5f268ac9f215b24936b72afaf6da1774b7963fde2634710cf5dac60c57f722a4ba7a8a4a5794b0bc515cb7 languageName: node linkType: hard @@ -3808,9 +3955,9 @@ __metadata: version: 7.0.0 resolution: "@svgr/hast-util-to-babel-ast@npm:7.0.0" dependencies: - "@babel/types": ^7.21.3 - entities: ^4.4.0 - checksum: c2168c36c8d25e876da879815728310cf204579c97a73908ce33b063cccfb9a18b6e53f53c6daf81506a96761d84b6261bf64faf26f16453f27e73cb322a9256 + "@babel/types": "npm:^7.21.3" + entities: "npm:^4.4.0" + checksum: 71cf7fc641fef2f20ec5c90a77223a1c85aad7015617236d232b9738660bb4982cb60a62364010151ca1f6ec5927a89d2714a9c5a3248b83dafa056cf64496c4 languageName: node linkType: hard @@ -3818,10 +3965,10 @@ __metadata: version: 7.0.0 resolution: "@svgr/plugin-jsx@npm:7.0.0" dependencies: - "@babel/core": ^7.21.3 - "@svgr/babel-preset": ^7.0.0 - "@svgr/hast-util-to-babel-ast": ^7.0.0 - svg-parser: ^2.0.4 + "@babel/core": "npm:^7.21.3" + "@svgr/babel-preset": "npm:^7.0.0" + "@svgr/hast-util-to-babel-ast": "npm:^7.0.0" + svg-parser: "npm:^2.0.4" checksum: 009421b8e3f32bf13ebec4d47c7997106cd806c6922349871f2d9a77cd3304f55d30630dd8948ff77a9ead2ee1869ac39ad65cf95ab95b2192ef21d5704bd367 languageName: node linkType: hard @@ -3830,15 +3977,15 @@ __metadata: version: 9.3.4 resolution: "@testing-library/dom@npm:9.3.4" dependencies: - "@babel/code-frame": ^7.10.4 - "@babel/runtime": ^7.12.5 - "@types/aria-query": ^5.0.1 - aria-query: 5.1.3 - chalk: ^4.1.0 - dom-accessibility-api: ^0.5.9 - lz-string: ^1.5.0 - pretty-format: ^27.0.2 - checksum: dfd6fb0d6c7b4dd716ba3c47309bc9541b4a55772cb61758b4f396b3785efe2dbc75dc63423545c039078c7ffcc5e4b8c67c2db1b6af4799580466036f70026f + "@babel/code-frame": "npm:^7.10.4" + "@babel/runtime": "npm:^7.12.5" + "@types/aria-query": "npm:^5.0.1" + aria-query: "npm:5.1.3" + chalk: "npm:^4.1.0" + dom-accessibility-api: "npm:^0.5.9" + lz-string: "npm:^1.5.0" + pretty-format: "npm:^27.0.2" + checksum: 510da752ea76f4a10a0a4e3a77917b0302cf03effe576cd3534cab7e796533ee2b0e9fb6fb11b911a1ebd7c70a0bb6f235bf4f816c9b82b95b8fe0cddfd10975 languageName: node linkType: hard @@ -3846,14 +3993,14 @@ __metadata: version: 6.4.2 resolution: "@testing-library/jest-dom@npm:6.4.2" dependencies: - "@adobe/css-tools": ^4.3.2 - "@babel/runtime": ^7.9.2 - aria-query: ^5.0.0 - chalk: ^3.0.0 - css.escape: ^1.5.1 - dom-accessibility-api: ^0.6.3 - lodash: ^4.17.15 - redent: ^3.0.0 + "@adobe/css-tools": "npm:^4.3.2" + "@babel/runtime": "npm:^7.9.2" + aria-query: "npm:^5.0.0" + chalk: "npm:^3.0.0" + css.escape: "npm:^1.5.1" + dom-accessibility-api: "npm:^0.6.3" + lodash: "npm:^4.17.15" + redent: "npm:^3.0.0" peerDependencies: "@jest/globals": ">= 28" "@types/bun": "*" @@ -3871,7 +4018,7 @@ __metadata: optional: true vitest: optional: true - checksum: 631aeadbf4e738080ae095242cf1a29a0b4ee2f09c8bdd0d3f00a923707da64c1617e088ba9a961d098481afabdc1d19149fb7ef98edf15132348eb222f345ae + checksum: 7ee1e51caffad032734a4a43a00bf72d49080cf1bbf53021b443e91c7fa3762a66f55ce68f1c6643590fe66fbc4df92142659b8cf17c92166a3fb22691987e0d languageName: node linkType: hard @@ -3879,13 +4026,13 @@ __metadata: version: 14.2.1 resolution: "@testing-library/react@npm:14.2.1" dependencies: - "@babel/runtime": ^7.12.5 - "@testing-library/dom": ^9.0.0 - "@types/react-dom": ^18.0.0 + "@babel/runtime": "npm:^7.12.5" + "@testing-library/dom": "npm:^9.0.0" + "@types/react-dom": "npm:^18.0.0" peerDependencies: react: ^18.0.0 react-dom: ^18.0.0 - checksum: 7054ae69a0e06c0777da8105fa08fac7e8dac570476a065285d7b993947acda5c948598764a203ebaac759c161c562d6712f19f5bd08be3f09a07e23baee5426 + checksum: e02b2f32ae79665a79fc4d8ee053fd3832bfcd4753aa1dba05cdece1a9f59c72a0fae91e0a9387597dcb686d631a722729f2878e38dc95e6f23b291ad8d09b6c languageName: node linkType: hard @@ -3894,7 +4041,7 @@ __metadata: resolution: "@testing-library/user-event@npm:14.5.2" peerDependencies: "@testing-library/dom": ">=7.21.4" - checksum: d76937dffcf0082fbf3bb89eb2b81a31bf5448048dd61c33928c5f10e33a58e035321d39145cefd469bb5a499c68a5b4086b22f1a44e3e7c7e817dc5f6782867 + checksum: 49821459d81c6bc435d97128d6386ca24f1e4b3ba8e46cb5a96fe3643efa6e002d88c1b02b7f2ec58da593e805c59b78d7fdf0db565c1f02ba782f63ee984040 languageName: node linkType: hard @@ -3908,14 +4055,14 @@ __metadata: "@trysound/sax@npm:0.2.0": version: 0.2.0 resolution: "@trysound/sax@npm:0.2.0" - checksum: 11226c39b52b391719a2a92e10183e4260d9651f86edced166da1d95f39a0a1eaa470e44d14ac685ccd6d3df7e2002433782872c0feeb260d61e80f21250e65c + checksum: 7379713eca480ac0d9b6c7b063e06b00a7eac57092354556c81027066eb65b61ea141a69d0cc2e15d32e05b2834d4c9c2184793a5e36bbf5daf05ee5676af18c languageName: node linkType: hard "@types/aria-query@npm:^5.0.1": version: 5.0.4 resolution: "@types/aria-query@npm:5.0.4" - checksum: ad8b87e4ad64255db5f0a73bc2b4da9b146c38a3a8ab4d9306154334e0fc67ae64e76bfa298eebd1e71830591fb15987e5de7111bdb36a2221bdc379e3415fb0 + checksum: c0084c389dc030daeaf0115a92ce43a3f4d42fc8fef2d0e22112d87a42798d4a15aac413019d4a63f868327d52ad6740ab99609462b442fe6b9286b172d2e82e languageName: node linkType: hard @@ -3923,12 +4070,12 @@ __metadata: version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" dependencies: - "@babel/parser": ^7.20.7 - "@babel/types": ^7.20.7 - "@types/babel__generator": "*" - "@types/babel__template": "*" - "@types/babel__traverse": "*" - checksum: a3226f7930b635ee7a5e72c8d51a357e799d19cbf9d445710fa39ab13804f79ab1a54b72ea7d8e504659c7dfc50675db974b526142c754398d7413aa4bc30845 + "@babel/parser": "npm:^7.20.7" + "@babel/types": "npm:^7.20.7" + "@types/babel__generator": "npm:*" + "@types/babel__template": "npm:*" + "@types/babel__traverse": "npm:*" + checksum: c32838d280b5ab59d62557f9e331d3831f8e547ee10b4f85cb78753d97d521270cebfc73ce501e9fb27fe71884d1ba75e18658692c2f4117543f0fc4e3e118b3 languageName: node linkType: hard @@ -3936,8 +4083,8 @@ __metadata: version: 7.6.8 resolution: "@types/babel__generator@npm:7.6.8" dependencies: - "@babel/types": ^7.0.0 - checksum: 5b332ea336a2efffbdeedb92b6781949b73498606ddd4205462f7d96dafd45ff3618770b41de04c4881e333dd84388bfb8afbdf6f2764cbd98be550d85c6bb48 + "@babel/types": "npm:^7.0.0" + checksum: b53c215e9074c69d212402990b0ca8fa57595d09e10d94bda3130aa22b55d796e50449199867879e4ea0ee968f3a2099e009cfb21a726a53324483abbf25cd30 languageName: node linkType: hard @@ -3945,8 +4092,8 @@ __metadata: version: 7.4.4 resolution: "@types/babel__template@npm:7.4.4" dependencies: - "@babel/parser": ^7.1.0 - "@babel/types": ^7.0.0 + "@babel/parser": "npm:^7.1.0" + "@babel/types": "npm:^7.0.0" checksum: d7a02d2a9b67e822694d8e6a7ddb8f2b71a1d6962dfd266554d2513eefbb205b33ca71a0d163b1caea3981ccf849211f9964d8bd0727124d18ace45aa6c9ae29 languageName: node linkType: hard @@ -3955,8 +4102,8 @@ __metadata: version: 7.20.5 resolution: "@types/babel__traverse@npm:7.20.5" dependencies: - "@babel/types": ^7.20.7 - checksum: 608e0ab4fc31cd47011d98942e6241b34d461608c0c0e153377c5fd822c436c475f1ded76a56bfa76a1adf8d9266b727bbf9bfac90c4cb152c97f30dadc5b7e8 + "@babel/types": "npm:^7.20.7" + checksum: f0352d537448e1e37f27e6bb8c962d7893720a92fde9d8601a68a93dbc14e15c088b4c0c8f71021d0966d09fba802ef3de11fdb6766c33993f8cf24f1277c6a9 languageName: node linkType: hard @@ -3964,8 +4111,8 @@ __metadata: version: 1.19.5 resolution: "@types/body-parser@npm:1.19.5" dependencies: - "@types/connect": "*" - "@types/node": "*" + "@types/connect": "npm:*" + "@types/node": "npm:*" checksum: 1e251118c4b2f61029cc43b0dc028495f2d1957fe8ee49a707fb940f86a9bd2f9754230805598278fe99958b49e9b7e66eec8ef6a50ab5c1f6b93e1ba2aaba82 languageName: node linkType: hard @@ -3974,7 +4121,7 @@ __metadata: version: 3.4.38 resolution: "@types/connect@npm:3.4.38" dependencies: - "@types/node": "*" + "@types/node": "npm:*" checksum: 7eb1bc5342a9604facd57598a6c62621e244822442976c443efb84ff745246b10d06e8b309b6e80130026a396f19bf6793b7cecd7380169f369dac3bfc46fb99 languageName: node linkType: hard @@ -3983,7 +4130,7 @@ __metadata: version: 6.0.6 resolution: "@types/cross-spawn@npm:6.0.6" dependencies: - "@types/node": "*" + "@types/node": "npm:*" checksum: b4172927cd1387cf037c3ade785ef46c87537b7bc2803d7f6663b4904d0c5d6f726415d1adb2fee4fecb21746738f11336076449265d46be4ce110cc3a8c8436 languageName: node linkType: hard @@ -3998,35 +4145,35 @@ __metadata: "@types/doctrine@npm:^0.0.3": version: 0.0.3 resolution: "@types/doctrine@npm:0.0.3" - checksum: 7ca9c8ff4d2da437785151c9eef0dd80b8fa12e0ff0fcb988458a78de4b6f0fc92727ba5bbee446e1df615a91f03053c5783b30b7c21ab6ceab6a42557e93e50 + checksum: 398c30efc903a750c71166c7385d763c98605723dfae23f0134d6de4d365a8f0a5585a0fe6f959569ff33646e7f43fa83bacb5f2a4d5929cd0f6163d06e4f6b3 languageName: node linkType: hard "@types/doctrine@npm:^0.0.9": version: 0.0.9 resolution: "@types/doctrine@npm:0.0.9" - checksum: 3909eaca42e7386b2ab866f082b78da3e00718d2fa323597e254feb0556c678b41f2c490729067433630083ac9c806ec6ae1e146754f7f8ba7d3e43ed68d6500 + checksum: 64ef06e6eea2f4f9684d259fedbcb8bf21c954630b96ea2e04875ca42763552b0ba3b01b3dd27ec0f9ea6f8b3b0dba4965d31d5a925cd4c6225fd13a93ae9354 languageName: node linkType: hard "@types/ejs@npm:^3.1.1": version: 3.1.5 resolution: "@types/ejs@npm:3.1.5" - checksum: e142266283051f27a7f79329871b311687dede19ae20268d882e4de218c65e1311d28a300b85579ca67157a8d601b7234daa50c2f99b252b121d27b4e5b21468 + checksum: 918898fd279108087722c1713e2ddb0c152ab839397946d164db8a18b5bbd732af9746373882a9bcf4843d35c6b191a8f569a7a4e51e90726d24501b39f40367 languageName: node linkType: hard "@types/emscripten@npm:^1.39.6": version: 1.39.10 resolution: "@types/emscripten@npm:1.39.10" - checksum: 1721da76593f9194e0b7c90a581e2d31c23bd4eb28f93030cd1dc58216cdf1e692c045274f2eedaed29c652c25c9a4dff2e503b11bd1258d07095c009a1956b1 + checksum: 6ed97aa115761e83665897b3d5d259895db60c10d2378c1bf84f94746c3c178715004812f5f42bcfb6e439664144f812318e8175103c76806aa6eaaf126a94f0 languageName: node linkType: hard "@types/escodegen@npm:^0.0.6": version: 0.0.6 resolution: "@types/escodegen@npm:0.0.6" - checksum: 7b25aeedd48dbef68345224082c6bc774845cbfc1d9b2ce91a477130fe7ccabf33da126c1d6d55e5dfd838db429a7c80890628a167e5aa55b6a4620974da38d3 + checksum: 2e91554a47eb98076a3ba6e3548640e50b28a0f5b69134f99dd1e0ce5223c0a1726f23d25aafd40e4c7961d7c3c519782949aa606d58d0e7431c7fb1ec011c4c languageName: node linkType: hard @@ -4034,23 +4181,23 @@ __metadata: version: 8.56.4 resolution: "@types/eslint@npm:8.56.4" dependencies: - "@types/estree": "*" - "@types/json-schema": "*" - checksum: 3d0798c8694c47519d28cca56187b70731fd7d1d2a0b6d25433b071ed297a1fb3404250daea8ba48aac9b18de223c196004a196d4f0b8d3c2b88b45cb13de12b + "@types/estree": "npm:*" + "@types/json-schema": "npm:*" + checksum: bb8018f0c27839dd0b8c515ac4e6fac39500c36ba20007a6ecca2fe5e5f81cbecca2be8f6f649bdafd5556b8c6d5285d8506ae61cc8570f71fd4e6b07042f641 languageName: node linkType: hard "@types/estree@npm:*, @types/estree@npm:^1.0.0": version: 1.0.5 resolution: "@types/estree@npm:1.0.5" - checksum: dd8b5bed28e6213b7acd0fb665a84e693554d850b0df423ac8076cc3ad5823a6bc26b0251d080bdc545af83179ede51dd3f6fa78cad2c46ed1f29624ddf3e41a + checksum: 7de6d928dd4010b0e20c6919e1a6c27b61f8d4567befa89252055fad503d587ecb9a1e3eab1b1901f923964d7019796db810b7fd6430acb26c32866d126fd408 languageName: node linkType: hard "@types/estree@npm:^0.0.51": version: 0.0.51 resolution: "@types/estree@npm:0.0.51" - checksum: e56a3bcf759fd9185e992e7fdb3c6a5f81e8ff120e871641607581fb3728d16c811702a7d40fa5f869b7f7b4437ab6a87eb8d98ffafeee51e85bbe955932a189 + checksum: b566c7a3fc8a81ca3d9e00a717e90b8f5d567e2476b4f6d76a20ec6da33ec28165b8f989ed8dd0c9df41405199777ec36a4f85f32a347fbc6c3f696a3128b6e7 languageName: node linkType: hard @@ -4058,11 +4205,11 @@ __metadata: version: 4.17.43 resolution: "@types/express-serve-static-core@npm:4.17.43" dependencies: - "@types/node": "*" - "@types/qs": "*" - "@types/range-parser": "*" - "@types/send": "*" - checksum: 08e940cae52eb1388a7b5f61d65f028e783add77d1854243ae920a6a2dfb5febb6acaafbcf38be9d678b0411253b9bc325893c463a93302405f24135664ab1e4 + "@types/node": "npm:*" + "@types/qs": "npm:*" + "@types/range-parser": "npm:*" + "@types/send": "npm:*" + checksum: 9079e137470e0456bb8e77ae66df9505ee12591e94860bde574cfe52c5c60bbc5bf7dd44f5689c3cbb1baf0aa84442d9a21f53dcd921d18745727293cd5a5fd6 languageName: node linkType: hard @@ -4070,11 +4217,11 @@ __metadata: version: 4.17.21 resolution: "@types/express@npm:4.17.21" dependencies: - "@types/body-parser": "*" - "@types/express-serve-static-core": ^4.17.33 - "@types/qs": "*" - "@types/serve-static": "*" - checksum: fb238298630370a7392c7abdc80f495ae6c716723e114705d7e3fb67e3850b3859bbfd29391463a3fb8c0b32051847935933d99e719c0478710f8098ee7091c5 + "@types/body-parser": "npm:*" + "@types/express-serve-static-core": "npm:^4.17.33" + "@types/qs": "npm:*" + "@types/serve-static": "npm:*" + checksum: 7a6d26cf6f43d3151caf4fec66ea11c9d23166e4f3102edfe45a94170654a54ea08cf3103d26b3928d7ebcc24162c90488e33986b7e3a5f8941225edd5eb18c7 languageName: node linkType: hard @@ -4089,8 +4236,8 @@ __metadata: version: 7.2.0 resolution: "@types/glob@npm:7.2.0" dependencies: - "@types/minimatch": "*" - "@types/node": "*" + "@types/minimatch": "npm:*" + "@types/node": "npm:*" checksum: 6ae717fedfdfdad25f3d5a568323926c64f52ef35897bcac8aca8e19bc50c0bd84630bbd063e5d52078b2137d8e7d3c26eabebd1a2f03ff350fff8a91e79fc19 languageName: node linkType: hard @@ -4099,7 +4246,7 @@ __metadata: version: 4.1.9 resolution: "@types/graceful-fs@npm:4.1.9" dependencies: - "@types/node": "*" + "@types/node": "npm:*" checksum: 79d746a8f053954bba36bd3d94a90c78de995d126289d656fb3271dd9f1229d33f678da04d10bce6be440494a5a73438e2e363e92802d16b8315b051036c5256 languageName: node linkType: hard @@ -4108,7 +4255,7 @@ __metadata: version: 2.3.10 resolution: "@types/hast@npm:2.3.10" dependencies: - "@types/unist": ^2 + "@types/unist": "npm:^2" checksum: 41531b7fbf590b02452996fc63272479c20a07269e370bd6514982cbcd1819b4b84d3ea620f2410d1b9541a23d08ce2eeb0a592145d05e00e249c3d56700d460 languageName: node linkType: hard @@ -4117,8 +4264,8 @@ __metadata: version: 3.3.5 resolution: "@types/hoist-non-react-statics@npm:3.3.5" dependencies: - "@types/react": "*" - hoist-non-react-statics: ^3.3.0 + "@types/react": "npm:*" + hoist-non-react-statics: "npm:^3.3.0" checksum: b645b062a20cce6ab1245ada8274051d8e2e0b2ee5c6bd58215281d0ec6dae2f26631af4e2e7c8abe238cdcee73fcaededc429eef569e70908f82d0cc0ea31d7 languageName: node linkType: hard @@ -4141,7 +4288,7 @@ __metadata: version: 3.0.3 resolution: "@types/istanbul-lib-report@npm:3.0.3" dependencies: - "@types/istanbul-lib-coverage": "*" + "@types/istanbul-lib-coverage": "npm:*" checksum: b91e9b60f865ff08cb35667a427b70f6c2c63e88105eadd29a112582942af47ed99c60610180aa8dcc22382fa405033f141c119c69b95db78c4c709fbadfeeb4 languageName: node linkType: hard @@ -4150,7 +4297,7 @@ __metadata: version: 3.0.4 resolution: "@types/istanbul-reports@npm:3.0.4" dependencies: - "@types/istanbul-lib-report": "*" + "@types/istanbul-lib-report": "npm:*" checksum: 93eb18835770b3431f68ae9ac1ca91741ab85f7606f310a34b3586b5a34450ec038c3eed7ab19266635499594de52ff73723a54a72a75b9f7d6a956f01edee95 languageName: node linkType: hard @@ -4159,9 +4306,9 @@ __metadata: version: 29.5.12 resolution: "@types/jest@npm:29.5.12" dependencies: - expect: ^29.0.0 - pretty-format: ^29.0.0 - checksum: 19b1efdeed9d9a60a81edc8226cdeae5af7479e493eaed273e01243891c9651f7b8b4c08fc633a7d0d1d379b091c4179bbaa0807af62542325fd72f2dd17ce1c + expect: "npm:^29.0.0" + pretty-format: "npm:^29.0.0" + checksum: 312e8dcf92cdd5a5847d6426f0940829bca6fe6b5a917248f3d7f7ef5d85c9ce78ef05e47d2bbabc40d41a930e0e36db2d443d2610a9e3db9062da2d5c904211 languageName: node linkType: hard @@ -4169,38 +4316,38 @@ __metadata: version: 20.0.1 resolution: "@types/jsdom@npm:20.0.1" dependencies: - "@types/node": "*" - "@types/tough-cookie": "*" - parse5: ^7.0.0 - checksum: d55402c5256ef451f93a6e3d3881f98339fe73a5ac2030588df056d6835df8367b5a857b48d27528289057e26dcdd3f502edc00cb877c79174cb3a4c7f2198c1 + "@types/node": "npm:*" + "@types/tough-cookie": "npm:*" + parse5: "npm:^7.0.0" + checksum: 15fbb9a0bfb4a5845cf6e795f2fd12400aacfca53b8c7e5bca4a3e5e8fa8629f676327964d64258aefb127d2d8a2be86dad46359efbfca0e8c9c2b790e7f8a88 languageName: node linkType: hard "@types/json-schema@npm:*, @types/json-schema@npm:^7.0.12, @types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.9": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" - checksum: 97ed0cb44d4070aecea772b7b2e2ed971e10c81ec87dd4ecc160322ffa55ff330dace1793489540e3e318d90942064bb697cc0f8989391797792d919737b3b98 + checksum: 1a3c3e06236e4c4aab89499c428d585527ce50c24fe8259e8b3926d3df4cfbbbcf306cfc73ddfb66cbafc973116efd15967020b0f738f63e09e64c7d260519e7 languageName: node linkType: hard "@types/json5@npm:^0.0.29": version: 0.0.29 resolution: "@types/json5@npm:0.0.29" - checksum: e60b153664572116dfea673c5bda7778dbff150498f44f998e34b5886d8afc47f16799280e4b6e241c0472aef1bc36add771c569c68fc5125fc2ae519a3eb9ac + checksum: 4e5aed58cabb2bbf6f725da13421aa50a49abb6bc17bfab6c31b8774b073fa7b50d557c61f961a09a85f6056151190f8ac95f13f5b48136ba5841f7d4484ec56 languageName: node linkType: hard "@types/lodash@npm:^4.14.167": version: 4.14.202 resolution: "@types/lodash@npm:4.14.202" - checksum: a91acf3564a568c6f199912f3eb2c76c99c5a0d7e219394294213b3f2d54f672619f0fde4da22b29dc5d4c31457cd799acc2e5cb6bd90f9af04a1578483b6ff7 + checksum: 1bb9760a5b1dda120132c4b987330d67979c95dbc22612678682cd61b00302e190f4207228f3728580059cdab5582362262e3819aea59960c1017bd2b9fb26f6 languageName: node linkType: hard "@types/mdx@npm:^2.0.0": version: 2.0.11 resolution: "@types/mdx@npm:2.0.11" - checksum: 4199e8d58f0a40be908fe8148959a4e9779f94339f83327495dd5a6609fc82cd5f6ff7391bf6077d7d2ca73cedec3557a51cac5044f0d6f2ae37e40019813dfe + checksum: 54d1ac0dc6c1c8d68f7537ecfab415767f34b4eee9d74f1d302b217307fb72bc976bf2616fdca654bdb01c1e4d152fb094b52f6502d8f6d6a063d3b9a8f9b81a languageName: node linkType: hard @@ -4228,7 +4375,7 @@ __metadata: "@types/minimatch@npm:*": version: 5.1.2 resolution: "@types/minimatch@npm:5.1.2" - checksum: 0391a282860c7cb6fe262c12b99564732401bdaa5e395bee9ca323c312c1a0f45efbf34dce974682036e857db59a5c9b1da522f3d6055aeead7097264c8705a8 + checksum: 94db5060d20df2b80d77b74dd384df3115f01889b5b6c40fa2dfa27cfc03a68fb0ff7c1f2a0366070263eb2e9d6bfd8c87111d4bc3ae93c3f291297c1bf56c85 languageName: node linkType: hard @@ -4236,9 +4383,9 @@ __metadata: version: 2.6.11 resolution: "@types/node-fetch@npm:2.6.11" dependencies: - "@types/node": "*" - form-data: ^4.0.0 - checksum: 180e4d44c432839bdf8a25251ef8c47d51e37355ddd78c64695225de8bc5dc2b50b7bb855956d471c026bb84bd7295688a0960085e7158cbbba803053492568b + "@types/node": "npm:*" + form-data: "npm:^4.0.0" + checksum: c416df8f182ec3826278ea42557fda08f169a48a05e60722d9c8edd4e5b2076ae281c6b6601ad406035b7201f885b0257983b61c26b3f9eb0f41192a807b5de5 languageName: node linkType: hard @@ -4246,8 +4393,8 @@ __metadata: version: 20.11.21 resolution: "@types/node@npm:20.11.21" dependencies: - undici-types: ~5.26.4 - checksum: 6d32edc3ba61236d879ebb6aa8905602310a2dc6469113ed6e282d63c03b4b800bd17a10ae780e4ac2a92f1ad99ad44fe8aa14cac67f86389cb141a2477a2fee + undici-types: "npm:~5.26.4" + checksum: a31ecc6a3c615bca310ffe7dea23613153ff9e1e175c09d14198402b2cef9b1bb1bf3912aff6ffc6cb01b99a025ec6dd6474c797bfb0aaf83daf4edaea063760 languageName: node linkType: hard @@ -4255,8 +4402,8 @@ __metadata: version: 18.19.19 resolution: "@types/node@npm:18.19.19" dependencies: - undici-types: ~5.26.4 - checksum: f9e1e71b86f08ad4823d8c353f53744fa8fed02e2043d5cee71d715097bac31c475d8f6e362ab1047e0b2ddc7758b58f04f797a0e7b581c6f416febfd0e77416 + undici-types: "npm:~5.26.4" + checksum: ea0f6be1f028054d4a3ecb672af7f52856dcc6e37cbe4733e8eb244084fdb50f2953bae730a2ad449c37d449e49d97deb4b17911705bc8adaf2651dc88e233e4 languageName: node linkType: hard @@ -4277,7 +4424,7 @@ __metadata: "@types/prismjs@npm:^1.0.0": version: 1.26.3 resolution: "@types/prismjs@npm:1.26.3" - checksum: c627fa9d9f4277ce413bb8347944152cddfc892702e34ff4b099dc1cf3f00c09514d36349c23529b903b0e57f3b2e0dc91ee66e98af07fbbe1e3fe8346b23370 + checksum: 4bd55230ffc0b2b16f4008be3a7f1d7c6b32dd3bed8006e64d24fb22c44fc7e300dac77b856f732803ccdc9a3472b2c0ee7776cad048843c47d608c41a89b6a6 languageName: node linkType: hard @@ -4306,7 +4453,7 @@ __metadata: version: 5.0.7 resolution: "@types/react-copy-to-clipboard@npm:5.0.7" dependencies: - "@types/react": "*" + "@types/react": "npm:*" checksum: adc2970c8756e648daa06e294c422df3dc076a784344ab2ecb78a17ebd7e8e3dfd7f31e68c24267de4815cdeec573a743d952a308b45b8380f6b7912a9a8b911 languageName: node linkType: hard @@ -4315,8 +4462,8 @@ __metadata: version: 18.2.19 resolution: "@types/react-dom@npm:18.2.19" dependencies: - "@types/react": "*" - checksum: 087a19d8e4c1c0900ec4ac5ddb749a811a38274b25683d233c11755d2895cc6e475e8bf9bea3dee36519769298e078d4c2feab9ab4bd13b26bc2a6170716437e + "@types/react": "npm:*" + checksum: 98eb760ce78f1016d97c70f605f0b1a53873a548d3c2192b40c897f694fd9c8bb12baeada16581a9c7b26f5022c1d2613547be98284d8f1b82d1611b1e3e7df0 languageName: node linkType: hard @@ -4324,8 +4471,8 @@ __metadata: version: 15.5.11 resolution: "@types/react-syntax-highlighter@npm:15.5.11" dependencies: - "@types/react": "*" - checksum: 8363ded0138963407c909f198ddcac58d9c937b118f16a46fb3e97078dd0c6234746f9efa85f6aa660efebe357bab11047c95b57bd9508dd4b09619b1a237087 + "@types/react": "npm:*" + checksum: 9074bc6964d26a9515182b0644536e8ed9482fc04986890c91b4aabf818a6008e121989309a3f5399f05b125180b35fbda1d0f3dd6ce2869d50b536c4c78ac05 languageName: node linkType: hard @@ -4333,10 +4480,10 @@ __metadata: version: 18.2.60 resolution: "@types/react@npm:18.2.60" dependencies: - "@types/prop-types": "*" - "@types/scheduler": "*" - csstype: ^3.0.2 - checksum: 4cd2000c4c5d93aec92bca9fce3a6050a6a20c5827409099a4cc9e68a259a937b2acc77aecbcea555eb99e295373903da17bc833e3f711442eec20e3c6cc5895 + "@types/prop-types": "npm:*" + "@types/scheduler": "npm:*" + csstype: "npm:^3.0.2" + checksum: 5f2f6091623f13375a5bbc7e5c222cd212b5d6366ead737b76c853f6f52b314db24af5ae3f688d2d49814c668c216858a75433f145311839d8989d46bb3cbecf languageName: node linkType: hard @@ -4357,7 +4504,7 @@ __metadata: "@types/semver@npm:^7.3.12, @types/semver@npm:^7.3.4, @types/semver@npm:^7.5.0": version: 7.5.8 resolution: "@types/semver@npm:7.5.8" - checksum: ea6f5276f5b84c55921785a3a27a3cd37afee0111dfe2bcb3e03c31819c197c782598f17f0b150a69d453c9584cd14c4c4d7b9a55d2c5e6cacd4d66fdb3b3663 + checksum: 3496808818ddb36deabfe4974fd343a78101fa242c4690044ccdc3b95dcf8785b494f5d628f2f47f38a702f8db9c53c67f47d7818f2be1b79f2efb09692e1178 languageName: node linkType: hard @@ -4365,9 +4512,9 @@ __metadata: version: 0.17.4 resolution: "@types/send@npm:0.17.4" dependencies: - "@types/mime": ^1 - "@types/node": "*" - checksum: cf4db48251bbb03cd6452b4de6e8e09e2d75390a92fd798eca4a803df06444adc94ed050246c94c7ed46fb97be1f63607f0e1f13c3ce83d71788b3e08640e5e0 + "@types/mime": "npm:^1" + "@types/node": "npm:*" + checksum: 28320a2aa1eb704f7d96a65272a07c0bf3ae7ed5509c2c96ea5e33238980f71deeed51d3631927a77d5250e4091b3e66bce53b42d770873282c6a20bb8b0280d languageName: node linkType: hard @@ -4375,10 +4522,10 @@ __metadata: version: 1.15.5 resolution: "@types/serve-static@npm:1.15.5" dependencies: - "@types/http-errors": "*" - "@types/mime": "*" - "@types/node": "*" - checksum: 0ff4b3703cf20ba89c9f9e345bc38417860a88e85863c8d6fe274a543220ab7f5f647d307c60a71bb57dc9559f0890a661e8dc771a6ec5ef195d91c8afc4a893 + "@types/http-errors": "npm:*" + "@types/mime": "npm:*" + "@types/node": "npm:*" + checksum: 49aa21c367fffe4588fc8c57ea48af0ea7cbadde7418bc53cde85d8bd57fd2a09a293970d9ea86e79f17a87f8adeb3e20da76aab38e1c4d1567931fa15c8af38 languageName: node linkType: hard @@ -4392,7 +4539,14 @@ __metadata: "@types/tough-cookie@npm:*": version: 4.0.5 resolution: "@types/tough-cookie@npm:4.0.5" - checksum: f19409d0190b179331586365912920d192733112a195e870c7f18d20ac8adb7ad0b0ff69dad430dba8bc2be09593453a719cfea92dc3bda19748fd158fe1498d + checksum: 01fd82efc8202670865928629697b62fe9bf0c0dcbc5b1c115831caeb073a2c0abb871ff393d7df1ae94ea41e256cb87d2a5a91fd03cdb1b0b4384e08d4ee482 + languageName: node + linkType: hard + +"@types/triple-beam@npm:^1.3.2": + version: 1.3.5 + resolution: "@types/triple-beam@npm:1.3.5" + checksum: 519b6a1b30d4571965c9706ad5400a200b94e4050feca3e7856e3ea7ac00ec9903e32e9a10e2762d0f7e472d5d03e5f4b29c16c0bd8c1f77c8876c683b2231f1 languageName: node linkType: hard @@ -4420,7 +4574,7 @@ __metadata: "@types/yargs-parser@npm:*": version: 21.0.3 resolution: "@types/yargs-parser@npm:21.0.3" - checksum: ef236c27f9432983e91432d974243e6c4cdae227cb673740320eff32d04d853eed59c92ca6f1142a335cfdc0e17cccafa62e95886a8154ca8891cc2dec4ee6fc + checksum: a794eb750e8ebc6273a51b12a0002de41343ffe46befef460bdbb57262d187fdf608bc6615b7b11c462c63c3ceb70abe2564c8dd8ee0f7628f38a314f74a9b9b languageName: node linkType: hard @@ -4428,8 +4582,8 @@ __metadata: version: 16.0.9 resolution: "@types/yargs@npm:16.0.9" dependencies: - "@types/yargs-parser": "*" - checksum: 00d9276ed4e0f17a78c1ed57f644a8c14061959bd5bfab113d57f082ea4b663ba97f71b89371304a34a2dba5061e9ae4523e357e577ba61834d661f82c223bf8 + "@types/yargs-parser": "npm:*" + checksum: 8f31cbfcd5c3ac67c27e26026d8b9af0c37770fb2421b661939ba06d136f5a4fa61528a5d0f495d5802fbf1d9244b499e664d8d884e3eb3c36d556fb7c278f18 languageName: node linkType: hard @@ -4437,8 +4591,8 @@ __metadata: version: 17.0.32 resolution: "@types/yargs@npm:17.0.32" dependencies: - "@types/yargs-parser": "*" - checksum: 4505bdebe8716ff383640c6e928f855b5d337cb3c68c81f7249fc6b983d0aa48de3eee26062b84f37e0d75a5797bc745e0c6e76f42f81771252a758c638f36ba + "@types/yargs-parser": "npm:*" + checksum: 1e2b2673847011ce43607df690d392f137d95a2d6ea85aa319403eadda2ef4277365efd4982354d8843f2611ef3846c88599660aaeb537fa9ccddae83c2a89de languageName: node linkType: hard @@ -4446,24 +4600,24 @@ __metadata: version: 7.1.0 resolution: "@typescript-eslint/eslint-plugin@npm:7.1.0" dependencies: - "@eslint-community/regexpp": ^4.5.1 - "@typescript-eslint/scope-manager": 7.1.0 - "@typescript-eslint/type-utils": 7.1.0 - "@typescript-eslint/utils": 7.1.0 - "@typescript-eslint/visitor-keys": 7.1.0 - debug: ^4.3.4 - graphemer: ^1.4.0 - ignore: ^5.2.4 - natural-compare: ^1.4.0 - semver: ^7.5.4 - ts-api-utils: ^1.0.1 + "@eslint-community/regexpp": "npm:^4.5.1" + "@typescript-eslint/scope-manager": "npm:7.1.0" + "@typescript-eslint/type-utils": "npm:7.1.0" + "@typescript-eslint/utils": "npm:7.1.0" + "@typescript-eslint/visitor-keys": "npm:7.1.0" + debug: "npm:^4.3.4" + graphemer: "npm:^1.4.0" + ignore: "npm:^5.2.4" + natural-compare: "npm:^1.4.0" + semver: "npm:^7.5.4" + ts-api-utils: "npm:^1.0.1" peerDependencies: "@typescript-eslint/parser": ^7.0.0 eslint: ^8.56.0 peerDependenciesMeta: typescript: optional: true - checksum: 01d56d92560980fa8daaef2cb5b1e9b5231a766d6aa02697a87d079575399c90f3864e5d6032f889672329cece885faecf696683e380ce23a094fc6ef409572d + checksum: f0b6b6e6ae2afee1df8dd2fd0c56588f9bb600468be9f255e033709a53371c6434da687e75dcb673503ef4f0416226f4ca3c94c65272828106e39b56aac87334 languageName: node linkType: hard @@ -4471,17 +4625,17 @@ __metadata: version: 7.1.0 resolution: "@typescript-eslint/parser@npm:7.1.0" dependencies: - "@typescript-eslint/scope-manager": 7.1.0 - "@typescript-eslint/types": 7.1.0 - "@typescript-eslint/typescript-estree": 7.1.0 - "@typescript-eslint/visitor-keys": 7.1.0 - debug: ^4.3.4 + "@typescript-eslint/scope-manager": "npm:7.1.0" + "@typescript-eslint/types": "npm:7.1.0" + "@typescript-eslint/typescript-estree": "npm:7.1.0" + "@typescript-eslint/visitor-keys": "npm:7.1.0" + debug: "npm:^4.3.4" peerDependencies: eslint: ^8.56.0 peerDependenciesMeta: typescript: optional: true - checksum: 3c518414a0ccb7b16c17dfcf9bffe9e6dae1fe19640e265ce1fb2d896ea072fdb7e498c4f12f8b1517a0869f9660e64c33447d0ef7b2ce856a1d0d6d49ce2749 + checksum: 39238d37f5a5f7058371ee3882fb7cd8a4579883fc5f13fda645c151fcf8d15e4c0db3ea7ffa7915a55c82451b544e9340c0228b45b83085158cb97974112f19 languageName: node linkType: hard @@ -4489,9 +4643,9 @@ __metadata: version: 5.62.0 resolution: "@typescript-eslint/scope-manager@npm:5.62.0" dependencies: - "@typescript-eslint/types": 5.62.0 - "@typescript-eslint/visitor-keys": 5.62.0 - checksum: 6062d6b797fe1ce4d275bb0d17204c827494af59b5eaf09d8a78cdd39dadddb31074dded4297aaf5d0f839016d601032857698b0e4516c86a41207de606e9573 + "@typescript-eslint/types": "npm:5.62.0" + "@typescript-eslint/visitor-keys": "npm:5.62.0" + checksum: e827770baa202223bc0387e2fd24f630690809e460435b7dc9af336c77322290a770d62bd5284260fa881c86074d6a9fd6c97b07382520b115f6786b8ed499da languageName: node linkType: hard @@ -4499,9 +4653,9 @@ __metadata: version: 7.1.0 resolution: "@typescript-eslint/scope-manager@npm:7.1.0" dependencies: - "@typescript-eslint/types": 7.1.0 - "@typescript-eslint/visitor-keys": 7.1.0 - checksum: 737c010cb60eedb2824038995150146a2099b09d0194ee0e7a2b730f29603775eba54b5260731a26e1056c4cdcc1847b5ea505228e9c240b6e31e3ed4b7a1d75 + "@typescript-eslint/types": "npm:7.1.0" + "@typescript-eslint/visitor-keys": "npm:7.1.0" + checksum: 3fb18de864331739c1b04fe9e3bb5d926e2fdf0d1fea2871181f68d0fb52325cbc9a5b81da58b7fe7f22d6d58d62b21c83460907146bc2f54ef0720fb3f9037f languageName: node linkType: hard @@ -4509,30 +4663,30 @@ __metadata: version: 7.1.0 resolution: "@typescript-eslint/type-utils@npm:7.1.0" dependencies: - "@typescript-eslint/typescript-estree": 7.1.0 - "@typescript-eslint/utils": 7.1.0 - debug: ^4.3.4 - ts-api-utils: ^1.0.1 + "@typescript-eslint/typescript-estree": "npm:7.1.0" + "@typescript-eslint/utils": "npm:7.1.0" + debug: "npm:^4.3.4" + ts-api-utils: "npm:^1.0.1" peerDependencies: eslint: ^8.56.0 peerDependenciesMeta: typescript: optional: true - checksum: 07c4261da12ac57a7f03064192e20bdc473074839057deb7a2d289ceb5f205f419fb5c753d81a2ed13493ae3cfe60d371348489a326474d9c4cb810c3dd96523 + checksum: 439e6fadab3df3c21adfd651af4e605e1020c86c8c2400b0127c2ee914646bc73945b4add31ca7201cafeead261ad2958362c339ebdfc0798064d56daeb60661 languageName: node linkType: hard "@typescript-eslint/types@npm:5.62.0": version: 5.62.0 resolution: "@typescript-eslint/types@npm:5.62.0" - checksum: 48c87117383d1864766486f24de34086155532b070f6264e09d0e6139449270f8a9559cfef3c56d16e3bcfb52d83d42105d61b36743626399c7c2b5e0ac3b670 + checksum: 24e8443177be84823242d6729d56af2c4b47bfc664dd411a1d730506abf2150d6c31bdefbbc6d97c8f91043e3a50e0c698239dcb145b79bb6b0c34469aaf6c45 languageName: node linkType: hard "@typescript-eslint/types@npm:7.1.0": version: 7.1.0 resolution: "@typescript-eslint/types@npm:7.1.0" - checksum: ad1e95ee83e9af7569c61260e62e4f4a42c8b82c57c33880c24dba44d1ab6792f5063e71ddf5176a1846b97158caba456805271787785250a937bba0e3df06d0 + checksum: 34801a14ea1444a1707de5bd3211f0ea53afc82a3c6c4543092f123267389da607c498d1a7de554ac9f071e6ef488238728a5f279ff2abaa0cbdfaa733899b67 languageName: node linkType: hard @@ -4540,17 +4694,17 @@ __metadata: version: 5.62.0 resolution: "@typescript-eslint/typescript-estree@npm:5.62.0" dependencies: - "@typescript-eslint/types": 5.62.0 - "@typescript-eslint/visitor-keys": 5.62.0 - debug: ^4.3.4 - globby: ^11.1.0 - is-glob: ^4.0.3 - semver: ^7.3.7 - tsutils: ^3.21.0 + "@typescript-eslint/types": "npm:5.62.0" + "@typescript-eslint/visitor-keys": "npm:5.62.0" + debug: "npm:^4.3.4" + globby: "npm:^11.1.0" + is-glob: "npm:^4.0.3" + semver: "npm:^7.3.7" + tsutils: "npm:^3.21.0" peerDependenciesMeta: typescript: optional: true - checksum: 3624520abb5807ed8f57b1197e61c7b1ed770c56dfcaca66372d584ff50175225798bccb701f7ef129d62c5989070e1ee3a0aa2d84e56d9524dcf011a2bb1a52 + checksum: 06c975eb5f44b43bd19fadc2e1023c50cf87038fe4c0dd989d4331c67b3ff509b17fa60a3251896668ab4d7322bdc56162a9926971218d2e1a1874d2bef9a52e languageName: node linkType: hard @@ -4558,18 +4712,18 @@ __metadata: version: 7.1.0 resolution: "@typescript-eslint/typescript-estree@npm:7.1.0" dependencies: - "@typescript-eslint/types": 7.1.0 - "@typescript-eslint/visitor-keys": 7.1.0 - debug: ^4.3.4 - globby: ^11.1.0 - is-glob: ^4.0.3 - minimatch: 9.0.3 - semver: ^7.5.4 - ts-api-utils: ^1.0.1 + "@typescript-eslint/types": "npm:7.1.0" + "@typescript-eslint/visitor-keys": "npm:7.1.0" + debug: "npm:^4.3.4" + globby: "npm:^11.1.0" + is-glob: "npm:^4.0.3" + minimatch: "npm:9.0.3" + semver: "npm:^7.5.4" + ts-api-utils: "npm:^1.0.1" peerDependenciesMeta: typescript: optional: true - checksum: a4db9f2b5094f3fdeaa09ca93ffefe23a7cfab3924c870b7277d36d1f9e3e9e0bd4fb10d9a4bae75d4ce5c0d1a0193888742f080e7f43a9f1b6d105f05f570c0 + checksum: 7dfc6fc70ff00875728ce5d85a3c5d6cb01435082b20ff9301ebe4d8e4a31a0c997282c762c636937bd66a40b4e0154e2ce98f85d888a6c46d433e9a24c46c4c languageName: node linkType: hard @@ -4577,16 +4731,16 @@ __metadata: version: 7.1.0 resolution: "@typescript-eslint/utils@npm:7.1.0" dependencies: - "@eslint-community/eslint-utils": ^4.4.0 - "@types/json-schema": ^7.0.12 - "@types/semver": ^7.5.0 - "@typescript-eslint/scope-manager": 7.1.0 - "@typescript-eslint/types": 7.1.0 - "@typescript-eslint/typescript-estree": 7.1.0 - semver: ^7.5.4 + "@eslint-community/eslint-utils": "npm:^4.4.0" + "@types/json-schema": "npm:^7.0.12" + "@types/semver": "npm:^7.5.0" + "@typescript-eslint/scope-manager": "npm:7.1.0" + "@typescript-eslint/types": "npm:7.1.0" + "@typescript-eslint/typescript-estree": "npm:7.1.0" + semver: "npm:^7.5.4" peerDependencies: eslint: ^8.56.0 - checksum: 9bf1be1fe7fad71412f5150d6ab74085b50da0f495e15a26f02239c9198a84b9376a827cbaa5ac0372ea914a5731168ac2e8a33190f0bbb84114aed27761959b + checksum: 26d64094d8b828ce6cfea660c95cdbd4d0193d338646fc773312093388bc781653fc1ca16977b3be5288579fe43f14c7108fc431da66dd95b6ed680ad44712a0 languageName: node linkType: hard @@ -4594,17 +4748,17 @@ __metadata: version: 5.62.0 resolution: "@typescript-eslint/utils@npm:5.62.0" dependencies: - "@eslint-community/eslint-utils": ^4.2.0 - "@types/json-schema": ^7.0.9 - "@types/semver": ^7.3.12 - "@typescript-eslint/scope-manager": 5.62.0 - "@typescript-eslint/types": 5.62.0 - "@typescript-eslint/typescript-estree": 5.62.0 - eslint-scope: ^5.1.1 - semver: ^7.3.7 + "@eslint-community/eslint-utils": "npm:^4.2.0" + "@types/json-schema": "npm:^7.0.9" + "@types/semver": "npm:^7.3.12" + "@typescript-eslint/scope-manager": "npm:5.62.0" + "@typescript-eslint/types": "npm:5.62.0" + "@typescript-eslint/typescript-estree": "npm:5.62.0" + eslint-scope: "npm:^5.1.1" + semver: "npm:^7.3.7" peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - checksum: ee9398c8c5db6d1da09463ca7bf36ed134361e20131ea354b2da16a5fdb6df9ba70c62a388d19f6eebb421af1786dbbd79ba95ddd6ab287324fc171c3e28d931 + checksum: 15ef13e43998a082b15f85db979f8d3ceb1f9ce4467b8016c267b1738d5e7cdb12aa90faf4b4e6dd6486c236cf9d33c463200465cf25ff997dbc0f12358550a1 languageName: node linkType: hard @@ -4612,9 +4766,9 @@ __metadata: version: 5.62.0 resolution: "@typescript-eslint/visitor-keys@npm:5.62.0" dependencies: - "@typescript-eslint/types": 5.62.0 - eslint-visitor-keys: ^3.3.0 - checksum: 976b05d103fe8335bef5c93ad3f76d781e3ce50329c0243ee0f00c0fcfb186c81df50e64bfdd34970148113f8ade90887f53e3c4938183afba830b4ba8e30a35 + "@typescript-eslint/types": "npm:5.62.0" + eslint-visitor-keys: "npm:^3.3.0" + checksum: dc613ab7569df9bbe0b2ca677635eb91839dfb2ca2c6fa47870a5da4f160db0b436f7ec0764362e756d4164e9445d49d5eb1ff0b87f4c058946ae9d8c92eb388 languageName: node linkType: hard @@ -4622,16 +4776,16 @@ __metadata: version: 7.1.0 resolution: "@typescript-eslint/visitor-keys@npm:7.1.0" dependencies: - "@typescript-eslint/types": 7.1.0 - eslint-visitor-keys: ^3.4.1 - checksum: 7ddac02dde4e16960ca87f0c05e5c5176fef6203bbf39d217ae15f8db498c262677a5799a258960a8d6bbcbc2ffbb799841e32276d2867f1e2f88bd988606092 + "@typescript-eslint/types": "npm:7.1.0" + eslint-visitor-keys: "npm:^3.4.1" + checksum: c3e98ebf166fd1854adb0e9599dc108cdbbd95f6eb099d31deae2fd1d4df8fcd8dc9c24ad4f509b961ad900b474c246f6b4b228b5711cc504106c3e0f751a11c languageName: node linkType: hard "@ungap/structured-clone@npm:^1.2.0": version: 1.2.0 resolution: "@ungap/structured-clone@npm:1.2.0" - checksum: 4f656b7b4672f2ce6e272f2427d8b0824ed11546a601d8d5412b9d7704e83db38a8d9f402ecdf2b9063fc164af842ad0ec4a55819f621ed7e7ea4d1efcc74524 + checksum: c6fe89a505e513a7592e1438280db1c075764793a2397877ff1351721fe8792a966a5359769e30242b3cd023f2efb9e63ca2ca88019d73b564488cc20e3eab12 languageName: node linkType: hard @@ -4639,13 +4793,13 @@ __metadata: version: 4.0.0 resolution: "@vitejs/plugin-react@npm:4.0.0" dependencies: - "@babel/core": ^7.21.4 - "@babel/plugin-transform-react-jsx-self": ^7.21.0 - "@babel/plugin-transform-react-jsx-source": ^7.19.6 - react-refresh: ^0.14.0 + "@babel/core": "npm:^7.21.4" + "@babel/plugin-transform-react-jsx-self": "npm:^7.21.0" + "@babel/plugin-transform-react-jsx-source": "npm:^7.19.6" + react-refresh: "npm:^0.14.0" peerDependencies: vite: ^4.2.0 - checksum: 575298f66517c51348892d49b302490c48e15c9ddb0b2c5f710931804e559dceafca1be1e62cb72d0902cba5f3c98e4b1272970d328e3a62d59ecdf976e68d3d + checksum: 9e7378621cb7e4dacd7277cd83b55382febdd3ff4c8a47793895caa8bfe3ce42c3ebe4e4cc49c29b53846d28c2796cf32c5727a3f9e784f7855f4421a80fcf42 languageName: node linkType: hard @@ -4653,14 +4807,21 @@ __metadata: version: 3.1.0 resolution: "@vitejs/plugin-react@npm:3.1.0" dependencies: - "@babel/core": ^7.20.12 - "@babel/plugin-transform-react-jsx-self": ^7.18.6 - "@babel/plugin-transform-react-jsx-source": ^7.19.6 - magic-string: ^0.27.0 - react-refresh: ^0.14.0 + "@babel/core": "npm:^7.20.12" + "@babel/plugin-transform-react-jsx-self": "npm:^7.18.6" + "@babel/plugin-transform-react-jsx-source": "npm:^7.19.6" + magic-string: "npm:^0.27.0" + react-refresh: "npm:^0.14.0" peerDependencies: vite: ^4.1.0-beta.0 - checksum: 450fac79e67cba9e1581c860f78e687b44108ab4117663ef20db279316e03cd8e87f94fef376e27cc5e200bd52813dcc09b70ea570c7c7cc291fcd47eb260fbc + checksum: 54baf15170faed08c5c050ed6ac3b071e743d703f2c26ae685bf362bbaa2d8a733a98af0639f0662d474d95a6d91d008da9de8f3a51cc3e6660c4e642399cf2c + languageName: node + linkType: hard + +"@xmldom/xmldom@npm:^0.8.10": + version: 0.8.10 + resolution: "@xmldom/xmldom@npm:0.8.10" + checksum: 62400bc5e0e75b90650e33a5ceeb8d94829dd11f9b260962b71a784cd014ddccec3e603fe788af9c1e839fa4648d8c521ebd80d8b752878d3a40edabc9ce7ccf languageName: node linkType: hard @@ -4668,10 +4829,10 @@ __metadata: version: 3.0.0-rc.15 resolution: "@yarnpkg/esbuild-plugin-pnp@npm:3.0.0-rc.15" dependencies: - tslib: ^2.4.0 + tslib: "npm:^2.4.0" peerDependencies: esbuild: ">=0.10.0" - checksum: 04da15355a99773b441742814ba4d0f3453a83df47aa07e215f167e156f109ab8e971489c8b1a4ddf3c79d568d35213f496ad52e97298228597e1aacc22680aa + checksum: 454f521088c1fa24fda51f83ca4a50ba6e3bd147e5dee8c899e6bf24a7196186532c3abb18480e83395708ffb7238c9cac5b82595c3985ce93593b5afbd0a9f0 languageName: node linkType: hard @@ -4679,9 +4840,9 @@ __metadata: version: 2.10.3 resolution: "@yarnpkg/fslib@npm:2.10.3" dependencies: - "@yarnpkg/libzip": ^2.3.0 - tslib: ^1.13.0 - checksum: 0ca693f61d47bcf165411a121ed9123f512b1b5bfa5e1c6c8f280b4ffdbea9bf2a6db418f99ecfc9624587fdc695b2b64eb0fe7b4028e44095914b25ca99655e + "@yarnpkg/libzip": "npm:^2.3.0" + tslib: "npm:^1.13.0" + checksum: 29b38bd2054e3ec14677c16321a20ed69ac41d9d6f2fee7d9d7bc0a5a737e6d94add79cfa5f6ab867b5a98ab6aa2df3b53cb34f81159907cc308576a7bc08c67 languageName: node linkType: hard @@ -4689,23 +4850,23 @@ __metadata: version: 2.3.0 resolution: "@yarnpkg/libzip@npm:2.3.0" dependencies: - "@types/emscripten": ^1.39.6 - tslib: ^1.13.0 - checksum: 533a4883f69bb013f955d80dc19719881697e6849ea5f0cbe6d87ef1d582b05cbae8a453802f92ad0c852f976296cac3ff7834be79a7e415b65cdf213e448110 + "@types/emscripten": "npm:^1.39.6" + tslib: "npm:^1.13.0" + checksum: 0eb147f39eab2830c29120d17e8bfba5aa15dedb940a7378070c67d4de08e9ba8d34068522e15e6b4db94ecaed4ad520e1e517588a36a348d1aa160bc36156ea languageName: node linkType: hard "abab@npm:^2.0.6": version: 2.0.6 resolution: "abab@npm:2.0.6" - checksum: 6ffc1af4ff315066c62600123990d87551ceb0aafa01e6539da77b0f5987ac7019466780bf480f1787576d4385e3690c81ccc37cfda12819bf510b8ab47e5a3e + checksum: ebe95d7278999e605823fc515a3b05d689bc72e7f825536e73c95ebf621636874c6de1b749b3c4bf866b96ccd4b3a2802efa313d0e45ad51a413c8c73247db20 languageName: node linkType: hard "abbrev@npm:^2.0.0": version: 2.0.0 resolution: "abbrev@npm:2.0.0" - checksum: 0e994ad2aa6575f94670d8a2149afe94465de9cedaaaac364e7fb43a40c3691c980ff74899f682f4ca58fa96b4cbd7421a015d3a6defe43a442117d7821a2f36 + checksum: ca0a54e35bea4ece0ecb68a47b312e1a9a6f772408d5bcb9051230aaa94b0460671c5b5c9cb3240eb5b7bc94c52476550eb221f65a0bbd0145bdc9f3113a6707 languageName: node linkType: hard @@ -4713,9 +4874,9 @@ __metadata: version: 1.3.8 resolution: "accepts@npm:1.3.8" dependencies: - mime-types: ~2.1.34 - negotiator: 0.6.3 - checksum: 50c43d32e7b50285ebe84b613ee4a3aa426715a7d131b65b786e2ead0fd76b6b60091b9916d3478a75f11f162628a2139991b6c03ab3f1d9ab7c86075dc8eab4 + mime-types: "npm:~2.1.34" + negotiator: "npm:0.6.3" + checksum: 67eaaa90e2917c58418e7a9b89392002d2b1ccd69bcca4799135d0c632f3b082f23f4ae4ddeedbced5aa59bcc7bdf4699c69ebed4593696c922462b7bc5744d6 languageName: node linkType: hard @@ -4723,8 +4884,8 @@ __metadata: version: 7.0.1 resolution: "acorn-globals@npm:7.0.1" dependencies: - acorn: ^8.1.0 - acorn-walk: ^8.0.2 + acorn: "npm:^8.1.0" + acorn-walk: "npm:^8.0.2" checksum: 2a2998a547af6d0db5f0cdb90acaa7c3cbca6709010e02121fb8b8617c0fbd8bab0b869579903fde358ac78454356a14fadcc1a672ecb97b04b1c2ccba955ce8 languageName: node linkType: hard @@ -4734,21 +4895,21 @@ __metadata: resolution: "acorn-jsx@npm:5.3.2" peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - checksum: c3d3b2a89c9a056b205b69530a37b972b404ee46ec8e5b341666f9513d3163e2a4f214a71f4dfc7370f5a9c07472d2fd1c11c91c3f03d093e37637d95da98950 + checksum: d4371eaef7995530b5b5ca4183ff6f062ca17901a6d3f673c9ac011b01ede37e7a1f7f61f8f5cfe709e88054757bb8f3277dc4061087cdf4f2a1f90ccbcdb977 languageName: node linkType: hard "acorn-walk@npm:^7.2.0": version: 7.2.0 resolution: "acorn-walk@npm:7.2.0" - checksum: 9252158a79b9d92f1bc0dd6acc0fcfb87a67339e84bcc301bb33d6078936d27e35d606b4d35626d2962cd43c256d6f27717e70cbe15c04fff999ab0b2260b21f + checksum: 4d3e186f729474aed3bc3d0df44692f2010c726582655b20a23347bef650867655521c48ada444cb4fda241ee713dcb792da363ec74c6282fa884fb7144171bb languageName: node linkType: hard "acorn-walk@npm:^8.0.2": version: 8.3.2 resolution: "acorn-walk@npm:8.3.2" - checksum: 3626b9d26a37b1b427796feaa5261faf712307a8920392c8dce9a5739fb31077667f4ad2ec71c7ac6aaf9f61f04a9d3d67ff56f459587206fc04aa31c27ef392 + checksum: 57dbe2fd8cf744f562431775741c5c087196cd7a65ce4ccb3f3981cdfad25cd24ad2bad404997b88464ac01e789a0a61e5e355b2a84876f13deef39fb39686ca languageName: node linkType: hard @@ -4757,7 +4918,7 @@ __metadata: resolution: "acorn@npm:7.4.1" bin: acorn: bin/acorn - checksum: 1860f23c2107c910c6177b7b7be71be350db9e1080d814493fae143ae37605189504152d1ba8743ba3178d0b37269ce1ffc42b101547fdc1827078f82671e407 + checksum: 8be2a40714756d713dfb62544128adce3b7102c6eb94bc312af196c2cc4af76e5b93079bd66b05e9ca31b35a9b0ce12171d16bc55f366cafdb794fdab9d753ec languageName: node linkType: hard @@ -4766,7 +4927,7 @@ __metadata: resolution: "acorn@npm:8.11.3" bin: acorn: bin/acorn - checksum: 76d8e7d559512566b43ab4aadc374f11f563f0a9e21626dd59cb2888444e9445923ae9f3699972767f18af61df89cd89f5eaaf772d1327b055b45cb829b4a88c + checksum: b688e7e3c64d9bfb17b596e1b35e4da9d50553713b3b3630cf5690f2b023a84eac90c56851e6912b483fe60e8b4ea28b254c07e92f17ef83d72d78745a8352dd languageName: node linkType: hard @@ -4774,95 +4935,95 @@ __metadata: version: 0.0.0-use.local resolution: "adcm-web2@workspace:." dependencies: - "@babel/core": 7.23.9 - "@babel/preset-env": 7.23.9 - "@babel/preset-react": 7.23.3 - "@babel/preset-typescript": 7.23.3 - "@braintree/browser-detection": ^2.0.0 - "@floating-ui/dom": 1.6.3 - "@floating-ui/react": 0.26.9 - "@reduxjs/toolkit": 1.9.7 - "@storybook/addon-essentials": ^7.6.17 - "@storybook/addon-interactions": ^7.6.17 - "@storybook/addon-links": ^7.6.17 - "@storybook/blocks": ^7.6.17 - "@storybook/builder-vite": 7.6.17 - "@storybook/react": ^7.6.17 - "@storybook/react-vite": ^7.6.17 - "@storybook/testing-library": 0.2.2 - "@testing-library/dom": 9.3.4 - "@testing-library/jest-dom": 6.4.2 - "@testing-library/react": 14.2.1 - "@testing-library/user-event": 14.5.2 - "@types/jest": 29.5.12 - "@types/json-schema": ^7.0.15 - "@types/qs": 6.9.12 - "@types/react": 18.2.60 - "@types/react-copy-to-clipboard": 5.0.7 - "@types/react-dom": 18.2.19 - "@types/react-syntax-highlighter": 15.5.11 - "@typescript-eslint/eslint-plugin": 7.1.0 - "@typescript-eslint/parser": 7.1.0 - "@vitejs/plugin-react": 4.0.0 - ajv: ^8.12.0 - axios: 1.6.7 - classnames: 2.5.1 - date-fns: 3.3.1 - eslint: 8.57.0 - eslint-config-prettier: 9.1.0 - eslint-import-resolver-alias: 1.1.2 - eslint-plugin-import: 2.29.1 - eslint-plugin-jsx-a11y: 6.8.0 - eslint-plugin-prettier: 5.1.3 - eslint-plugin-react: 7.33.2 - eslint-plugin-react-hooks: 4.6.0 - eslint-plugin-react-refresh: 0.4.5 - eslint-plugin-spellcheck: 0.0.20 - eslint-plugin-storybook: 0.8.0 - html-react-parser: 5.1.8 - husky: 9.0.11 - identity-obj-proxy: 3.0.0 - jest: 29.7.0 - jest-environment-jsdom: 29.7.0 - js-base64: 3.7.7 - json-schema: 0.4.0 - prettier: 3.2.5 - prop-types: 15.8.1 - qs: 6.11.2 - react: 18.2.0 - react-collapsed: 4.0.2 - react-copy-to-clipboard: 5.1.0 - react-dom: 18.2.0 - react-json-view-compare: ^2.0.2 - react-merge-refs: 2.0.2 - react-redux: 8.1.1 - react-router-dom: 6.11.2 - react-syntax-highlighter: 15.5.0 - redux: 4.2.1 - refractor: ^4.8.1 - sass: 1.71.1 - storybook: ^7.6.17 - typescript: 5.2.2 - vite: 4.4.1 - vite-plugin-eslint: 1.8.1 - vite-plugin-react-remove-attributes: 1.0.3 - vite-plugin-svg-sprite: 0.3.2 - vite-plugin-svgr: 3.2.0 - vite-tsconfig-paths: 4.2.0 + "@babel/core": "npm:7.23.9" + "@babel/preset-env": "npm:7.23.9" + "@babel/preset-react": "npm:7.23.3" + "@babel/preset-typescript": "npm:7.23.3" + "@braintree/browser-detection": "npm:^2.0.0" + "@floating-ui/dom": "npm:1.6.3" + "@floating-ui/react": "npm:0.26.9" + "@reduxjs/toolkit": "npm:1.9.7" + "@storybook/addon-essentials": "npm:^7.6.17" + "@storybook/addon-interactions": "npm:^7.6.17" + "@storybook/addon-links": "npm:^7.6.17" + "@storybook/blocks": "npm:^7.6.17" + "@storybook/builder-vite": "npm:7.6.17" + "@storybook/react": "npm:^7.6.17" + "@storybook/react-vite": "npm:^7.6.17" + "@storybook/testing-library": "npm:0.2.2" + "@testing-library/dom": "npm:9.3.4" + "@testing-library/jest-dom": "npm:6.4.2" + "@testing-library/react": "npm:14.2.1" + "@testing-library/user-event": "npm:14.5.2" + "@types/jest": "npm:29.5.12" + "@types/json-schema": "npm:^7.0.15" + "@types/qs": "npm:6.9.12" + "@types/react": "npm:18.2.60" + "@types/react-copy-to-clipboard": "npm:5.0.7" + "@types/react-dom": "npm:18.2.19" + "@types/react-syntax-highlighter": "npm:15.5.11" + "@typescript-eslint/eslint-plugin": "npm:7.1.0" + "@typescript-eslint/parser": "npm:7.1.0" + "@vitejs/plugin-react": "npm:4.0.0" + ajv: "npm:^8.12.0" + axios: "npm:1.6.7" + classnames: "npm:2.5.1" + date-fns: "npm:3.3.1" + eslint: "npm:8.57.0" + eslint-config-prettier: "npm:9.1.0" + eslint-import-resolver-alias: "npm:1.1.2" + eslint-plugin-import: "npm:2.29.1" + eslint-plugin-jsx-a11y: "npm:6.8.0" + eslint-plugin-prettier: "npm:5.1.3" + eslint-plugin-react: "npm:7.33.2" + eslint-plugin-react-hooks: "npm:4.6.0" + eslint-plugin-react-refresh: "npm:0.4.5" + eslint-plugin-spellcheck: "npm:0.0.20" + eslint-plugin-storybook: "npm:0.8.0" + html-react-parser: "npm:5.1.8" + husky: "npm:9.0.11" + identity-obj-proxy: "npm:3.0.0" + jest: "npm:29.7.0" + jest-environment-jsdom: "npm:29.7.0" + js-base64: "npm:3.7.7" + json-schema: "npm:0.4.0" + prettier: "npm:3.2.5" + prop-types: "npm:15.8.1" + qs: "npm:6.11.2" + react: "npm:18.2.0" + react-collapsed: "npm:4.0.2" + react-copy-to-clipboard: "npm:5.1.0" + react-dom: "npm:18.2.0" + react-json-view-compare: "npm:^2.0.2" + react-merge-refs: "npm:2.0.2" + react-redux: "npm:8.1.1" + react-router-dom: "npm:6.11.2" + react-syntax-highlighter: "npm:15.5.0" + redux: "npm:4.2.1" + refractor: "npm:^4.8.1" + sass: "npm:1.71.1" + storybook: "npm:^7.6.17" + typescript: "npm:5.2.2" + vite: "npm:4.4.1" + vite-plugin-eslint: "npm:1.8.1" + vite-plugin-react-remove-attributes: "npm:1.0.3" + vite-plugin-svg-spriter: "npm:1.0.0" + vite-plugin-svgr: "npm:3.2.0" + vite-tsconfig-paths: "npm:4.2.0" languageName: unknown linkType: soft "address@npm:^1.0.1": version: 1.2.2 resolution: "address@npm:1.2.2" - checksum: ace439960c1e3564d8f523aff23a841904bf33a2a7c2e064f7f60a064194075758b9690e65bd9785692a4ef698a998c57eb74d145881a1cecab8ba658ddb1607 + checksum: 57d80a0c6ccadc8769ad3aeb130c1599e8aee86a8d25f671216c40df9b8489d6c3ef879bc2752b40d1458aa768f947c2d91e5b2fedfe63cf702c40afdfda9ba9 languageName: node linkType: hard "agent-base@npm:5": version: 5.1.1 resolution: "agent-base@npm:5.1.1" - checksum: 61ae789f3019f1dc10e8cba6d3ae9826949299a4e54aaa1cfa2fa37c95a108e70e95423b963bb987d7891a703fd9a5c383a506f4901819f3ee56f3147c0aa8ab + checksum: 82954db5dccdccccf52c4b7f548394a696accd259d564bfb325fb02586aaaa9df96f5d50bb19134923fe5ff9c21195e7a88871bf4e086cca9014a549a0ba2a5f languageName: node linkType: hard @@ -4870,8 +5031,8 @@ __metadata: version: 6.0.2 resolution: "agent-base@npm:6.0.2" dependencies: - debug: 4 - checksum: f52b6872cc96fd5f622071b71ef200e01c7c4c454ee68bc9accca90c98cfb39f2810e3e9aa330435835eedc8c23f4f8a15267f67c6e245d2b33757575bdac49d + debug: "npm:4" + checksum: 21fb903e0917e5cb16591b4d0ef6a028a54b83ac30cd1fca58dece3d4e0990512a8723f9f83130d88a41e2af8b1f7be1386fda3ea2d181bb1a62155e75e95e23 languageName: node linkType: hard @@ -4879,7 +5040,7 @@ __metadata: version: 7.1.0 resolution: "agent-base@npm:7.1.0" dependencies: - debug: ^4.3.4 + debug: "npm:^4.3.4" checksum: f7828f991470a0cc22cb579c86a18cbae83d8a3cbed39992ab34fc7217c4d126017f1c74d0ab66be87f71455318a8ea3e757d6a37881b8d0f2a2c6aa55e5418f languageName: node linkType: hard @@ -4888,8 +5049,8 @@ __metadata: version: 3.1.0 resolution: "aggregate-error@npm:3.1.0" dependencies: - clean-stack: ^2.0.0 - indent-string: ^4.0.0 + clean-stack: "npm:^2.0.0" + indent-string: "npm:^4.0.0" checksum: 1101a33f21baa27a2fa8e04b698271e64616b886795fd43c31068c07533c7b3facfcaf4e9e0cab3624bd88f729a592f1c901a1a229c9e490eafce411a8644b79 languageName: node linkType: hard @@ -4898,11 +5059,11 @@ __metadata: version: 6.12.6 resolution: "ajv@npm:6.12.6" dependencies: - fast-deep-equal: ^3.1.1 - fast-json-stable-stringify: ^2.0.0 - json-schema-traverse: ^0.4.1 - uri-js: ^4.2.2 - checksum: 874972efe5c4202ab0a68379481fbd3d1b5d0a7bd6d3cc21d40d3536ebff3352a2a1fabb632d4fd2cc7fe4cbdcd5ed6782084c9bbf7f32a1536d18f9da5007d4 + fast-deep-equal: "npm:^3.1.1" + fast-json-stable-stringify: "npm:^2.0.0" + json-schema-traverse: "npm:^0.4.1" + uri-js: "npm:^4.2.2" + checksum: 48d6ad21138d12eb4d16d878d630079a2bda25a04e745c07846a4ad768319533031e28872a9b3c5790fa1ec41aabdf2abed30a56e5a03ebc2cf92184b8ee306c languageName: node linkType: hard @@ -4910,11 +5071,11 @@ __metadata: version: 8.12.0 resolution: "ajv@npm:8.12.0" dependencies: - fast-deep-equal: ^3.1.1 - json-schema-traverse: ^1.0.0 - require-from-string: ^2.0.2 - uri-js: ^4.2.2 - checksum: 4dc13714e316e67537c8b31bc063f99a1d9d9a497eb4bbd55191ac0dcd5e4985bbb71570352ad6f1e76684fb6d790928f96ba3b2d4fd6e10024be9612fe3f001 + fast-deep-equal: "npm:^3.1.1" + json-schema-traverse: "npm:^1.0.0" + require-from-string: "npm:^2.0.2" + uri-js: "npm:^4.2.2" + checksum: b406f3b79b5756ac53bfe2c20852471b08e122bc1ee4cde08ae4d6a800574d9cd78d60c81c69c63ff81e4da7cd0b638fafbb2303ae580d49cf1600b9059efb85 languageName: node linkType: hard @@ -4922,15 +5083,8 @@ __metadata: version: 4.3.2 resolution: "ansi-escapes@npm:4.3.2" dependencies: - type-fest: ^0.21.3 - checksum: 93111c42189c0a6bed9cdb4d7f2829548e943827ee8479c74d6e0b22ee127b2a21d3f8b5ca57723b8ef78ce011fbfc2784350eb2bde3ccfccf2f575fa8489815 - languageName: node - linkType: hard - -"ansi-regex@npm:^2.0.0": - version: 2.1.1 - resolution: "ansi-regex@npm:2.1.1" - checksum: 190abd03e4ff86794f338a31795d262c1dfe8c91f7e01d04f13f646f1dcb16c5800818f886047876f1272f065570ab86b24b99089f8b68a0e11ff19aed4ca8f1 + type-fest: "npm:^0.21.3" + checksum: 8661034456193ffeda0c15c8c564a9636b0c04094b7f78bd01517929c17c504090a60f7a75f949f5af91289c264d3e1001d91492c1bd58efc8e100500ce04de2 languageName: node linkType: hard @@ -4948,18 +5102,11 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^2.2.1": - version: 2.2.1 - resolution: "ansi-styles@npm:2.2.1" - checksum: ebc0e00381f2a29000d1dac8466a640ce11943cef3bda3cd0020dc042e31e1058ab59bf6169cd794a54c3a7338a61ebc404b7c91e004092dd20e028c432c9c2c - languageName: node - linkType: hard - "ansi-styles@npm:^3.2.1": version: 3.2.1 resolution: "ansi-styles@npm:3.2.1" dependencies: - color-convert: ^1.9.0 + color-convert: "npm:^1.9.0" checksum: d85ade01c10e5dd77b6c89f34ed7531da5830d2cb5882c645f330079975b716438cd7ebb81d0d6e6b4f9c577f19ae41ab55f07f19786b02f9dfd9e0377395665 languageName: node linkType: hard @@ -4968,8 +5115,8 @@ __metadata: version: 4.3.0 resolution: "ansi-styles@npm:4.3.0" dependencies: - color-convert: ^2.0.1 - checksum: 513b44c3b2105dd14cc42a19271e80f386466c4be574bccf60b627432f9198571ebf4ab1e4c3ba17347658f4ee1711c163d574248c0c1cdc2d5917a0ad582ec4 + color-convert: "npm:^2.0.1" + checksum: b4494dfbfc7e4591b4711a396bd27e540f8153914123dccb4cdbbcb514015ada63a3809f362b9d8d4f6b17a706f1d7bea3c6f974b15fa5ae76b5b502070889ff languageName: node linkType: hard @@ -4983,7 +5130,7 @@ __metadata: "ansi-styles@npm:^6.1.0": version: 6.2.1 resolution: "ansi-styles@npm:6.2.1" - checksum: ef940f2f0ced1a6347398da88a91da7930c33ecac3c77b72c5905f8b8fe402c52e6fde304ff5347f616e27a742da3f1dc76de98f6866c69251ad0b07a66776d9 + checksum: 70fdf883b704d17a5dfc9cde206e698c16bcd74e7f196ab821511651aee4f9f76c9514bdfa6ca3a27b5e49138b89cb222a28caf3afe4567570139577f991df32 languageName: node linkType: hard @@ -4991,8 +5138,8 @@ __metadata: version: 3.1.3 resolution: "anymatch@npm:3.1.3" dependencies: - normalize-path: ^3.0.0 - picomatch: ^2.0.4 + normalize-path: "npm:^3.0.0" + picomatch: "npm:^2.0.4" checksum: 3e044fd6d1d26545f235a9fe4d7a534e2029d8e59fa7fd9f2a6eb21230f6b5380ea1eaf55136e60cbf8e613544b3b766e7a6fa2102e2a3a117505466e3025dc2 languageName: node linkType: hard @@ -5008,15 +5155,15 @@ __metadata: version: 1.0.10 resolution: "argparse@npm:1.0.10" dependencies: - sprintf-js: ~1.0.2 - checksum: 7ca6e45583a28de7258e39e13d81e925cfa25d7d4aacbf806a382d3c02fcb13403a07fb8aeef949f10a7cfe4a62da0e2e807b348a5980554cc28ee573ef95945 + sprintf-js: "npm:~1.0.2" + checksum: c6a621343a553ff3779390bb5ee9c2263d6643ebcd7843227bdde6cc7adbed796eb5540ca98db19e3fd7b4714e1faa51551f8849b268bb62df27ddb15cbcd91e languageName: node linkType: hard "argparse@npm:^2.0.1": version: 2.0.1 resolution: "argparse@npm:2.0.1" - checksum: 83644b56493e89a254bae05702abf3a1101b4fa4d0ca31df1c9985275a5a5bd47b3c27b7fa0b71098d41114d8ca000e6ed90cad764b306f8a503665e4d517ced + checksum: 18640244e641a417ec75a9bd38b0b2b6b95af5199aa241b131d4b2fb206f334d7ecc600bd194861610a5579084978bfcbb02baa399dbe442d56d0ae5e60dbaef languageName: node linkType: hard @@ -5024,8 +5171,8 @@ __metadata: version: 1.2.3 resolution: "aria-hidden@npm:1.2.3" dependencies: - tslib: ^2.0.0 - checksum: 7d7d211629eef315e94ed3b064c6823d13617e609d3f9afab1c2ed86399bb8e90405f9bdd358a85506802766f3ecb468af985c67c846045a34b973bcc0289db9 + tslib: "npm:^2.0.0" + checksum: cd7f8474f1bef2dadce8fc74ef6d0fa8c9a477ee3c9e49fc3698e5e93a62014140c520266ee28969d63b5ab474144fe48b6182d010feb6a223f7a73928e6660a languageName: node linkType: hard @@ -5033,8 +5180,8 @@ __metadata: version: 5.1.3 resolution: "aria-query@npm:5.1.3" dependencies: - deep-equal: ^2.0.5 - checksum: 929ff95f02857b650fb4cbcd2f41072eee2f46159a6605ea03bf63aa572e35ffdff43d69e815ddc462e16e07de8faba3978afc2813650b4448ee18c9895d982b + deep-equal: "npm:^2.0.5" + checksum: e5da608a7c4954bfece2d879342b6c218b6b207e2d9e5af270b5e38ef8418f02d122afdc948b68e32649b849a38377785252059090d66fa8081da95d1609c0d2 languageName: node linkType: hard @@ -5042,29 +5189,8 @@ __metadata: version: 5.3.0 resolution: "aria-query@npm:5.3.0" dependencies: - dequal: ^2.0.3 - checksum: 305bd73c76756117b59aba121d08f413c7ff5e80fa1b98e217a3443fcddb9a232ee790e24e432b59ae7625aebcf4c47cb01c2cac872994f0b426f5bdfcd96ba9 - languageName: node - linkType: hard - -"arr-diff@npm:^4.0.0": - version: 4.0.0 - resolution: "arr-diff@npm:4.0.0" - checksum: ea7c8834842ad3869297f7915689bef3494fd5b102ac678c13ffccab672d3d1f35802b79e90c4cfec2f424af3392e44112d1ccf65da34562ed75e049597276a0 - languageName: node - linkType: hard - -"arr-flatten@npm:^1.1.0": - version: 1.1.0 - resolution: "arr-flatten@npm:1.1.0" - checksum: 963fe12564fca2f72c055f3f6c206b9e031f7c433a0c66ca9858b484821f248c5b1e5d53c8e4989d80d764cd776cf6d9b160ad05f47bdc63022bfd63b5455e22 - languageName: node - linkType: hard - -"arr-union@npm:^3.1.0": - version: 3.1.0 - resolution: "arr-union@npm:3.1.0" - checksum: b5b0408c6eb7591143c394f3be082fee690ddd21f0fdde0a0a01106799e847f67fcae1b7e56b0a0c173290e29c6aca9562e82b300708a268bc8f88f3d6613cb9 + dequal: "npm:^2.0.3" + checksum: c3e1ed127cc6886fea4732e97dd6d3c3938e64180803acfb9df8955517c4943760746ffaf4020ce8f7ffaa7556a3b5f85c3769a1f5ca74a1288e02d042f9ae4e languageName: node linkType: hard @@ -5072,8 +5198,8 @@ __metadata: version: 1.0.1 resolution: "array-buffer-byte-length@npm:1.0.1" dependencies: - call-bind: ^1.0.5 - is-array-buffer: ^3.0.4 + call-bind: "npm:^1.0.5" + is-array-buffer: "npm:^3.0.4" checksum: 53524e08f40867f6a9f35318fafe467c32e45e9c682ba67b11943e167344d2febc0f6977a17e699b05699e805c3e8f073d876f8bbf1b559ed494ad2cd0fae09e languageName: node linkType: hard @@ -5081,7 +5207,7 @@ __metadata: "array-flatten@npm:1.1.1": version: 1.1.1 resolution: "array-flatten@npm:1.1.1" - checksum: a9925bf3512d9dce202112965de90c222cd59a4fbfce68a0951d25d965cf44642931f40aac72309c41f12df19afa010ecadceb07cfff9ccc1621e99d89ab5f3b + checksum: e13c9d247241be82f8b4ec71d035ed7204baa82fae820d4db6948d30d3c4a9f2b3905eb2eec2b937d4aa3565200bd3a1c500480114cff649fa748747d2a50feb languageName: node linkType: hard @@ -5089,12 +5215,12 @@ __metadata: version: 3.1.7 resolution: "array-includes@npm:3.1.7" dependencies: - call-bind: ^1.0.2 - define-properties: ^1.2.0 - es-abstract: ^1.22.1 - get-intrinsic: ^1.2.1 - is-string: ^1.0.7 - checksum: 06f9e4598fac12a919f7c59a3f04f010ea07f0b7f0585465ed12ef528a60e45f374e79d1bddbb34cdd4338357d00023ddbd0ac18b0be36964f5e726e8965d7fc + call-bind: "npm:^1.0.2" + define-properties: "npm:^1.2.0" + es-abstract: "npm:^1.22.1" + get-intrinsic: "npm:^1.2.1" + is-string: "npm:^1.0.7" + checksum: 856a8be5d118967665936ad33ff3b07adfc50b06753e596e91fb80c3da9b8c022e92e3cc6781156d6ad95db7109b9f603682c7df2d6a529ed01f7f6b39a4a360 languageName: node linkType: hard @@ -5105,23 +5231,16 @@ __metadata: languageName: node linkType: hard -"array-unique@npm:^0.3.2": - version: 0.3.2 - resolution: "array-unique@npm:0.3.2" - checksum: da344b89cfa6b0a5c221f965c21638bfb76b57b45184a01135382186924f55973cd9b171d4dad6bf606c6d9d36b0d721d091afdc9791535ead97ccbe78f8a888 - languageName: node - linkType: hard - "array.prototype.filter@npm:^1.0.3": version: 1.0.3 resolution: "array.prototype.filter@npm:1.0.3" dependencies: - call-bind: ^1.0.2 - define-properties: ^1.2.0 - es-abstract: ^1.22.1 - es-array-method-boxes-properly: ^1.0.0 - is-string: ^1.0.7 - checksum: 5443cde6ad64596649e5751252b1b2f5242b41052980c2fb2506ba485e3ffd7607e8f6f2f1aefa0cb1cfb9b8623b2b2be103579cb367a161a3426400619b6e73 + call-bind: "npm:^1.0.2" + define-properties: "npm:^1.2.0" + es-abstract: "npm:^1.22.1" + es-array-method-boxes-properly: "npm:^1.0.0" + is-string: "npm:^1.0.7" + checksum: 3da2189afb00f95559cc73fc3c50f17a071a65bb705c0b2f2e2a2b2142781215b622442368c8b4387389b6ab251adf09ad347f9a8a4cf29d24404cc5ea1e295c languageName: node linkType: hard @@ -5129,12 +5248,12 @@ __metadata: version: 1.2.4 resolution: "array.prototype.findlastindex@npm:1.2.4" dependencies: - call-bind: ^1.0.5 - define-properties: ^1.2.1 - es-abstract: ^1.22.3 - es-errors: ^1.3.0 - es-shim-unscopables: ^1.0.2 - checksum: cc8dce27a06dddf6d9c40a15d4c573f96ac5ca3583f89f8d8cd7d7ffdb96a71d819890a5bdb211f221bda8fafa0d97d1d8cbb5460a5cbec1fff57ae80b8abc31 + call-bind: "npm:^1.0.5" + define-properties: "npm:^1.2.1" + es-abstract: "npm:^1.22.3" + es-errors: "npm:^1.3.0" + es-shim-unscopables: "npm:^1.0.2" + checksum: 12d7de8da619065b9d4c40550d11c13f2fbbc863c4270ef01d022f49ef16fbe9022441ee9d60b1e952853c661dd4b3e05c21e4348d4631c6d93ddf802a252296 languageName: node linkType: hard @@ -5142,11 +5261,11 @@ __metadata: version: 1.3.2 resolution: "array.prototype.flat@npm:1.3.2" dependencies: - call-bind: ^1.0.2 - define-properties: ^1.2.0 - es-abstract: ^1.22.1 - es-shim-unscopables: ^1.0.0 - checksum: 5d6b4bf102065fb3f43764bfff6feb3295d372ce89591e6005df3d0ce388527a9f03c909af6f2a973969a4d178ab232ffc9236654149173e0e187ec3a1a6b87b + call-bind: "npm:^1.0.2" + define-properties: "npm:^1.2.0" + es-abstract: "npm:^1.22.1" + es-shim-unscopables: "npm:^1.0.0" + checksum: d9d2f6f27584de92ec7995bc931103e6de722cd2498bdbfc4cba814fc3e52f056050a93be883018811f7c0a35875f5056584a0e940603a5e5934f0279896aebe languageName: node linkType: hard @@ -5154,11 +5273,11 @@ __metadata: version: 1.3.2 resolution: "array.prototype.flatmap@npm:1.3.2" dependencies: - call-bind: ^1.0.2 - define-properties: ^1.2.0 - es-abstract: ^1.22.1 - es-shim-unscopables: ^1.0.0 - checksum: ce09fe21dc0bcd4f30271f8144083aa8c13d4639074d6c8dc82054b847c7fc9a0c97f857491f4da19d4003e507172a78f4bcd12903098adac8b9cd374f734be3 + call-bind: "npm:^1.0.2" + define-properties: "npm:^1.2.0" + es-abstract: "npm:^1.22.1" + es-shim-unscopables: "npm:^1.0.0" + checksum: 33f20006686e0cbe844fde7fd290971e8366c6c5e3380681c2df15738b1df766dd02c7784034aeeb3b037f65c496ee54de665388288edb323a2008bb550f77ea languageName: node linkType: hard @@ -5166,12 +5285,12 @@ __metadata: version: 1.1.3 resolution: "array.prototype.tosorted@npm:1.1.3" dependencies: - call-bind: ^1.0.5 - define-properties: ^1.2.1 - es-abstract: ^1.22.3 - es-errors: ^1.1.0 - es-shim-unscopables: ^1.0.2 - checksum: 555e8808086bbde9e634c5dc5a8c0a2f1773075447b43b2fa76ab4f94f4e90f416d2a4f881024e1ce1a2931614caf76cd6b408af901c9d7cd13061d0d268f5af + call-bind: "npm:^1.0.5" + define-properties: "npm:^1.2.1" + es-abstract: "npm:^1.22.3" + es-errors: "npm:^1.1.0" + es-shim-unscopables: "npm:^1.0.2" + checksum: 9a5b7909a9ddd02a5f5489911766c314a11fb40f8f5106bdbedf6c21898763faeb78ba3af53f7038f288de9161d2605ad10d8b720e07f71a7ed1de49f39c0897 languageName: node linkType: hard @@ -5179,15 +5298,15 @@ __metadata: version: 1.0.3 resolution: "arraybuffer.prototype.slice@npm:1.0.3" dependencies: - array-buffer-byte-length: ^1.0.1 - call-bind: ^1.0.5 - define-properties: ^1.2.1 - es-abstract: ^1.22.3 - es-errors: ^1.2.1 - get-intrinsic: ^1.2.3 - is-array-buffer: ^3.0.4 - is-shared-array-buffer: ^1.0.2 - checksum: 352259cba534dcdd969c92ab002efd2ba5025b2e3b9bead3973150edbdf0696c629d7f4b3f061c5931511e8207bdc2306da614703c820b45dabce39e3daf7e3e + array-buffer-byte-length: "npm:^1.0.1" + call-bind: "npm:^1.0.5" + define-properties: "npm:^1.2.1" + es-abstract: "npm:^1.22.3" + es-errors: "npm:^1.2.1" + get-intrinsic: "npm:^1.2.3" + is-array-buffer: "npm:^3.0.4" + is-shared-array-buffer: "npm:^1.0.2" + checksum: 0221f16c1e3ec7b67da870ee0e1f12b825b5f9189835392b59a22990f715827561a4f4cd5330dc7507de272d8df821be6cd4b0cb569babf5ea4be70e365a2f3d languageName: node linkType: hard @@ -5195,26 +5314,19 @@ __metadata: version: 2.1.0 resolution: "assert@npm:2.1.0" dependencies: - call-bind: ^1.0.2 - is-nan: ^1.3.2 - object-is: ^1.1.5 - object.assign: ^4.1.4 - util: ^0.12.5 - checksum: 1ed1cabba9abe55f4109b3f7292b4e4f3cf2953aad8dc148c0b3c3bd676675c31b1abb32ef563b7d5a19d1715bf90d1e5f09fad2a4ee655199468902da80f7c2 - languageName: node - linkType: hard - -"assign-symbols@npm:^1.0.0": - version: 1.0.0 - resolution: "assign-symbols@npm:1.0.0" - checksum: c0eb895911d05b6b2d245154f70461c5e42c107457972e5ebba38d48967870dee53bcdf6c7047990586daa80fab8dab3cc6300800fbd47b454247fdedd859a2c + call-bind: "npm:^1.0.2" + is-nan: "npm:^1.3.2" + object-is: "npm:^1.1.5" + object.assign: "npm:^4.1.4" + util: "npm:^0.12.5" + checksum: 6b9d813c8eef1c0ac13feac5553972e4bd180ae16000d4eb5c0ded2489188737c75a5aacefc97a985008b37502f62fe1bad34da1a7481a54bbfabec3964c8aa7 languageName: node linkType: hard "ast-types-flow@npm:^0.0.8": version: 0.0.8 resolution: "ast-types-flow@npm:0.0.8" - checksum: 0a64706609a179233aac23817837abab614f3548c252a2d3d79ea1e10c74aa28a0846e11f466cf72771b6ed8713abc094dcf8c40c3ec4207da163efa525a94a8 + checksum: 85a1c24af4707871c27cfe456bd2ff7fcbe678f3d1c878ac968c9557735a171a17bdcc8c8f903ceab3fc3c49d5b3da2194e6ab0a6be7fec0e133fa028f21ba1b languageName: node linkType: hard @@ -5222,8 +5334,8 @@ __metadata: version: 0.16.1 resolution: "ast-types@npm:0.16.1" dependencies: - tslib: ^2.0.1 - checksum: 21c186da9fdb1d8087b1b7dabbc4059f91aa5a1e593a9776b4393cc1eaa857e741b2dda678d20e34b16727b78fef3ab59cf8f0c75ed1ba649c78fe194e5c114b + tslib: "npm:^2.0.1" + checksum: f569b475eb1c8cb93888cb6e7b7e36dc43fa19a77e4eb132cbff6e3eb1598ca60f850db6e60b070e5a0ee8c1559fca921dac0916e576f2f104e198793b0bdd8d languageName: node linkType: hard @@ -5234,10 +5346,10 @@ __metadata: languageName: node linkType: hard -"async@npm:^3.2.3": +"async@npm:^3.2.3, async@npm:^3.2.5": version: 3.2.5 resolution: "async@npm:3.2.5" - checksum: 5ec77f1312301dee02d62140a6b1f7ee0edd2a0f983b6fd2b0849b969f245225b990b47b8243e7b9ad16451a53e7f68e753700385b706198ced888beedba3af4 + checksum: 323c3615c3f0ab1ac25a6f953296bc0ac3213d5e0f1c0debdb12964e55963af288d570293c11e44f7967af58c06d2a88d0ea588c86ec0fbf62fa98037f604a0f languageName: node linkType: hard @@ -5245,7 +5357,7 @@ __metadata: version: 1.0.0 resolution: "asynciterator.prototype@npm:1.0.0" dependencies: - has-symbols: ^1.0.3 + has-symbols: "npm:^1.0.3" checksum: e8ebfd9493ac651cf9b4165e9d64030b3da1d17181bb1963627b59e240cdaf021d9b59d44b827dc1dde4e22387ec04c2d0f8720cf58a1c282e34e40cc12721b3 languageName: node linkType: hard @@ -5253,16 +5365,7 @@ __metadata: "asynckit@npm:^0.4.0": version: 0.4.0 resolution: "asynckit@npm:0.4.0" - checksum: 7b78c451df768adba04e2d02e63e2d0bf3b07adcd6e42b4cf665cb7ce899bedd344c69a1dcbce355b5f972d597b25aaa1c1742b52cffd9caccb22f348114f6be - languageName: node - linkType: hard - -"atob@npm:^2.1.2": - version: 2.1.2 - resolution: "atob@npm:2.1.2" - bin: - atob: bin/atob.js - checksum: dfeeeb70090c5ebea7be4b9f787f866686c645d9f39a0d184c817252d0cf08455ed25267d79c03254d3be1f03ac399992a792edcd5ffb9c91e097ab5ef42833a + checksum: 3ce727cbc78f69d6a4722517a58ee926c8c21083633b1d3fdf66fd688f6c127a53a592141bd4866f9b63240a86e9d8e974b13919450bd17fa33c2d22c4558ad8 languageName: node linkType: hard @@ -5270,15 +5373,15 @@ __metadata: version: 1.0.7 resolution: "available-typed-arrays@npm:1.0.7" dependencies: - possible-typed-array-names: ^1.0.0 - checksum: 1aa3ffbfe6578276996de660848b6e95669d9a95ad149e3dd0c0cda77db6ee1dbd9d1dd723b65b6d277b882dd0c4b91a654ae9d3cf9e1254b7e93e4908d78fd3 + possible-typed-array-names: "npm:^1.0.0" + checksum: 6c9da3a66caddd83c875010a1ca8ef11eac02ba15fb592dc9418b2b5e7b77b645fa7729380a92d9835c2f05f2ca1b6251f39b993e0feb3f1517c74fa1af02cab languageName: node linkType: hard "axe-core@npm:=4.7.0": version: 4.7.0 resolution: "axe-core@npm:4.7.0" - checksum: f086bcab42be1761ba2b0b127dec350087f4c3a853bba8dd58f69d898cefaac31a1561da23146f6f3c07954c76171d1f2ce460e555e052d2b02cd79af628fa4a + checksum: 615c0f7722c3c9fcf353dbd70b00e2ceae234d4c17cbc839dd85c01d16797c4e4da45f8d27c6118e9e6b033fb06efd196106e13651a1b2f3a10e0f11c7b2f660 languageName: node linkType: hard @@ -5286,10 +5389,10 @@ __metadata: version: 1.6.7 resolution: "axios@npm:1.6.7" dependencies: - follow-redirects: ^1.15.4 - form-data: ^4.0.0 - proxy-from-env: ^1.1.0 - checksum: 87d4d429927d09942771f3b3a6c13580c183e31d7be0ee12f09be6d5655304996bb033d85e54be81606f4e89684df43be7bf52d14becb73a12727bf33298a082 + follow-redirects: "npm:^1.15.4" + form-data: "npm:^4.0.0" + proxy-from-env: "npm:^1.1.0" + checksum: a1932b089ece759cd261f175d9ebf4d41c8994cf0c0767cda86055c7a19bcfdade8ae3464bf4cec4c8b142f4a657dc664fb77a41855e8376cf38b86d7a86518f languageName: node linkType: hard @@ -5297,8 +5400,8 @@ __metadata: version: 3.2.1 resolution: "axobject-query@npm:3.2.1" dependencies: - dequal: ^2.0.3 - checksum: a94047e702b57c91680e6a952ec4a1aaa2cfd0d80ead76bc8c954202980d8c51968a6ea18b4d8010e8e2cf95676533d8022a8ebba9abc1dfe25686721df26fd2 + dequal: "npm:^2.0.3" + checksum: 675af2548ed4ece75ad6d50cc0473cfdec7579eac77ec9861e7088d03ffb171aa697b70d2877423bee2ce16460ef62c698c6442a105612cc015719e8ea06b0bd languageName: node linkType: hard @@ -5315,16 +5418,16 @@ __metadata: version: 29.7.0 resolution: "babel-jest@npm:29.7.0" dependencies: - "@jest/transform": ^29.7.0 - "@types/babel__core": ^7.1.14 - babel-plugin-istanbul: ^6.1.1 - babel-preset-jest: ^29.6.3 - chalk: ^4.0.0 - graceful-fs: ^4.2.9 - slash: ^3.0.0 + "@jest/transform": "npm:^29.7.0" + "@types/babel__core": "npm:^7.1.14" + babel-plugin-istanbul: "npm:^6.1.1" + babel-preset-jest: "npm:^29.6.3" + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.9" + slash: "npm:^3.0.0" peerDependencies: "@babel/core": ^7.8.0 - checksum: ee6f8e0495afee07cac5e4ee167be705c711a8cc8a737e05a587a131fdae2b3c8f9aa55dfd4d9c03009ac2d27f2de63d8ba96d3e8460da4d00e8af19ef9a83f7 + checksum: 8a0953bd813b3a8926008f7351611055548869e9a53dd36d6e7e96679001f71e65fd7dbfe253265c3ba6a4e630dc7c845cf3e78b17d758ef1880313ce8fba258 languageName: node linkType: hard @@ -5332,12 +5435,12 @@ __metadata: version: 6.1.1 resolution: "babel-plugin-istanbul@npm:6.1.1" dependencies: - "@babel/helper-plugin-utils": ^7.0.0 - "@istanbuljs/load-nyc-config": ^1.0.0 - "@istanbuljs/schema": ^0.1.2 - istanbul-lib-instrument: ^5.0.4 - test-exclude: ^6.0.0 - checksum: cb4fd95738219f232f0aece1116628cccff16db891713c4ccb501cddbbf9272951a5df81f2f2658dfdf4b3e7b236a9d5cbcf04d5d8c07dd5077297339598061a + "@babel/helper-plugin-utils": "npm:^7.0.0" + "@istanbuljs/load-nyc-config": "npm:^1.0.0" + "@istanbuljs/schema": "npm:^0.1.2" + istanbul-lib-instrument: "npm:^5.0.4" + test-exclude: "npm:^6.0.0" + checksum: ffd436bb2a77bbe1942a33245d770506ab2262d9c1b3c1f1da7f0592f78ee7445a95bc2efafe619dd9c1b6ee52c10033d6c7d29ddefe6f5383568e60f31dfe8d languageName: node linkType: hard @@ -5345,11 +5448,11 @@ __metadata: version: 29.6.3 resolution: "babel-plugin-jest-hoist@npm:29.6.3" dependencies: - "@babel/template": ^7.3.3 - "@babel/types": ^7.3.3 - "@types/babel__core": ^7.1.14 - "@types/babel__traverse": ^7.0.6 - checksum: 51250f22815a7318f17214a9d44650ba89551e6d4f47a2dc259128428324b52f5a73979d010cefd921fd5a720d8c1d55ad74ff601cd94c7bd44d5f6292fde2d1 + "@babel/template": "npm:^7.3.3" + "@babel/types": "npm:^7.3.3" + "@types/babel__core": "npm:^7.1.14" + "@types/babel__traverse": "npm:^7.0.6" + checksum: 9bfa86ec4170bd805ab8ca5001ae50d8afcb30554d236ba4a7ffc156c1a92452e220e4acbd98daefc12bf0216fccd092d0a2efed49e7e384ec59e0597a926d65 languageName: node linkType: hard @@ -5357,12 +5460,12 @@ __metadata: version: 0.4.8 resolution: "babel-plugin-polyfill-corejs2@npm:0.4.8" dependencies: - "@babel/compat-data": ^7.22.6 - "@babel/helper-define-polyfill-provider": ^0.5.0 - semver: ^6.3.1 + "@babel/compat-data": "npm:^7.22.6" + "@babel/helper-define-polyfill-provider": "npm:^0.5.0" + semver: "npm:^6.3.1" peerDependencies: "@babel/core": ^7.4.0 || ^8.0.0-0 <8.0.0 - checksum: 22857b87268b354e095452199464accba5fd8f690558a2f24b0954807ca2494b96da8d5c13507955802427582015160bce26a66893acf6da5dafbed8b336cf79 + checksum: 6b5a79bdc1c43edf857fd3a82966b3c7ff4a90eee00ca8d663e0a98304d6e285a05759d64a4dbc16e04a2a5ea1f248673d8bf789711be5e694e368f19884887c languageName: node linkType: hard @@ -5370,11 +5473,11 @@ __metadata: version: 0.9.0 resolution: "babel-plugin-polyfill-corejs3@npm:0.9.0" dependencies: - "@babel/helper-define-polyfill-provider": ^0.5.0 - core-js-compat: ^3.34.0 + "@babel/helper-define-polyfill-provider": "npm:^0.5.0" + core-js-compat: "npm:^3.34.0" peerDependencies: "@babel/core": ^7.4.0 || ^8.0.0-0 <8.0.0 - checksum: 65bbf59fc0145c7a264822777403632008dce00015b4b5c7ec359125ef4faf9e8f494ae5123d2992104feb6f19a3cff85631992862e48b6d7bd64eb7e755ee1f + checksum: efdf9ba82e7848a2c66e0522adf10ac1646b16f271a9006b61a22f976b849de22a07c54c8826887114842ccd20cc9a4617b61e8e0789227a74378ab508e715cd languageName: node linkType: hard @@ -5382,7 +5485,7 @@ __metadata: version: 0.5.5 resolution: "babel-plugin-polyfill-regenerator@npm:0.5.5" dependencies: - "@babel/helper-define-polyfill-provider": ^0.5.0 + "@babel/helper-define-polyfill-provider": "npm:^0.5.0" peerDependencies: "@babel/core": ^7.4.0 || ^8.0.0-0 <8.0.0 checksum: 3a9b4828673b23cd648dcfb571eadcd9d3fadfca0361d0a7c6feeb5a30474e92faaa49f067a6e1c05e49b6a09812879992028ff3ef3446229ff132d6e1de7eb6 @@ -5393,21 +5496,21 @@ __metadata: version: 1.0.1 resolution: "babel-preset-current-node-syntax@npm:1.0.1" dependencies: - "@babel/plugin-syntax-async-generators": ^7.8.4 - "@babel/plugin-syntax-bigint": ^7.8.3 - "@babel/plugin-syntax-class-properties": ^7.8.3 - "@babel/plugin-syntax-import-meta": ^7.8.3 - "@babel/plugin-syntax-json-strings": ^7.8.3 - "@babel/plugin-syntax-logical-assignment-operators": ^7.8.3 - "@babel/plugin-syntax-nullish-coalescing-operator": ^7.8.3 - "@babel/plugin-syntax-numeric-separator": ^7.8.3 - "@babel/plugin-syntax-object-rest-spread": ^7.8.3 - "@babel/plugin-syntax-optional-catch-binding": ^7.8.3 - "@babel/plugin-syntax-optional-chaining": ^7.8.3 - "@babel/plugin-syntax-top-level-await": ^7.8.3 + "@babel/plugin-syntax-async-generators": "npm:^7.8.4" + "@babel/plugin-syntax-bigint": "npm:^7.8.3" + "@babel/plugin-syntax-class-properties": "npm:^7.8.3" + "@babel/plugin-syntax-import-meta": "npm:^7.8.3" + "@babel/plugin-syntax-json-strings": "npm:^7.8.3" + "@babel/plugin-syntax-logical-assignment-operators": "npm:^7.8.3" + "@babel/plugin-syntax-nullish-coalescing-operator": "npm:^7.8.3" + "@babel/plugin-syntax-numeric-separator": "npm:^7.8.3" + "@babel/plugin-syntax-object-rest-spread": "npm:^7.8.3" + "@babel/plugin-syntax-optional-catch-binding": "npm:^7.8.3" + "@babel/plugin-syntax-optional-chaining": "npm:^7.8.3" + "@babel/plugin-syntax-top-level-await": "npm:^7.8.3" peerDependencies: "@babel/core": ^7.0.0 - checksum: d118c2742498c5492c095bc8541f4076b253e705b5f1ad9a2e7d302d81a84866f0070346662355c8e25fc02caa28dc2da8d69bcd67794a0d60c4d6fab6913cc8 + checksum: 94561959cb12bfa80867c9eeeace7c3d48d61707d33e55b4c3fdbe82fc745913eb2dbfafca62aef297421b38aadcb58550e5943f50fbcebbeefd70ce2bed4b74 languageName: node linkType: hard @@ -5415,8 +5518,8 @@ __metadata: version: 29.6.3 resolution: "babel-preset-jest@npm:29.6.3" dependencies: - babel-plugin-jest-hoist: ^29.6.3 - babel-preset-current-node-syntax: ^1.0.0 + babel-plugin-jest-hoist: "npm:^29.6.3" + babel-preset-current-node-syntax: "npm:^1.0.0" peerDependencies: "@babel/core": ^7.0.0 checksum: aa4ff2a8a728d9d698ed521e3461a109a1e66202b13d3494e41eea30729a5e7cc03b3a2d56c594423a135429c37bf63a9fa8b0b9ce275298be3095a88c69f6fb @@ -5437,41 +5540,19 @@ __metadata: languageName: node linkType: hard -"base@npm:^0.11.1": - version: 0.11.2 - resolution: "base@npm:0.11.2" - dependencies: - cache-base: ^1.0.1 - class-utils: ^0.3.5 - component-emitter: ^1.2.1 - define-property: ^1.0.0 - isobject: ^3.0.1 - mixin-deep: ^1.2.0 - pascalcase: ^0.1.1 - checksum: a4a146b912e27eea8f66d09cb0c9eab666f32ce27859a7dfd50f38cd069a2557b39f16dba1bc2aecb3b44bf096738dd207b7970d99b0318423285ab1b1994edd - languageName: node - linkType: hard - "better-opn@npm:^3.0.2": version: 3.0.2 resolution: "better-opn@npm:3.0.2" dependencies: - open: ^8.0.4 - checksum: 1471552fa7f733561e7f49e812be074b421153006ca744de985fb6d38939807959fc5fe9cb819cf09f864782e294704fd3b31711ea14c115baf3330a2f1135de + open: "npm:^8.0.4" + checksum: 24668e5a837d0d2c0edf17ad5ebcfeb00a8a5578a5eb09f7a409e1a60617cdfea40b8ebfc95e5f12d9568157930d033e6805788fcf0780413ac982c95d3745d1 languageName: node linkType: hard "big-integer@npm:^1.6.44": version: 1.6.52 resolution: "big-integer@npm:1.6.52" - checksum: 6e86885787a20fed96521958ae9086960e4e4b5e74d04f3ef7513d4d0ad631a9f3bde2730fc8aaa4b00419fc865f6ec573e5320234531ef37505da7da192c40b - languageName: node - linkType: hard - -"big.js@npm:^5.2.2": - version: 5.2.2 - resolution: "big.js@npm:5.2.2" - checksum: b89b6e8419b097a8fb4ed2399a1931a68c612bce3cfd5ca8c214b2d017531191070f990598de2fc6f3f993d91c0f08aa82697717f6b3b8732c9731866d233c9e + checksum: 4bc6ae152a96edc9f95020f5fc66b13d26a9ad9a021225a9f0213f7e3dc44269f423aa8c42e19d6ac4a63bb2b22140b95d10be8f9ca7a6d9aa1b22b330d1f514 languageName: node linkType: hard @@ -5486,17 +5567,10 @@ __metadata: version: 4.1.0 resolution: "bl@npm:4.1.0" dependencies: - buffer: ^5.5.0 - inherits: ^2.0.4 - readable-stream: ^3.4.0 - checksum: 9e8521fa7e83aa9427c6f8ccdcba6e8167ef30cc9a22df26effcc5ab682ef91d2cbc23a239f945d099289e4bbcfae7a192e9c28c84c6202e710a0dfec3722662 - languageName: node - linkType: hard - -"bluebird@npm:^3.5.0": - version: 3.7.2 - resolution: "bluebird@npm:3.7.2" - checksum: 869417503c722e7dc54ca46715f70e15f4d9c602a423a02c825570862d12935be59ed9c7ba34a9b31f186c017c23cac6b54e35446f8353059c101da73eac22ef + buffer: "npm:^5.5.0" + inherits: "npm:^2.0.4" + readable-stream: "npm:^3.4.0" + checksum: b7904e66ed0bdfc813c06ea6c3e35eafecb104369dbf5356d0f416af90c1546de3b74e5b63506f0629acf5e16a6f87c3798f16233dcff086e9129383aa02ab55 languageName: node linkType: hard @@ -5504,19 +5578,19 @@ __metadata: version: 1.20.1 resolution: "body-parser@npm:1.20.1" dependencies: - bytes: 3.1.2 - content-type: ~1.0.4 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.11.0 - raw-body: 2.5.1 - type-is: ~1.6.18 - unpipe: 1.0.0 - checksum: f1050dbac3bede6a78f0b87947a8d548ce43f91ccc718a50dd774f3c81f2d8b04693e52acf62659fad23101827dd318da1fb1363444ff9a8482b886a3e4a5266 + bytes: "npm:3.1.2" + content-type: "npm:~1.0.4" + debug: "npm:2.6.9" + depd: "npm:2.0.0" + destroy: "npm:1.2.0" + http-errors: "npm:2.0.0" + iconv-lite: "npm:0.4.24" + on-finished: "npm:2.4.1" + qs: "npm:6.11.0" + raw-body: "npm:2.5.1" + type-is: "npm:~1.6.18" + unpipe: "npm:1.0.0" + checksum: 5f8d128022a2fb8b6e7990d30878a0182f300b70e46b3f9d358a9433ad6275f0de46add6d63206da3637c01c3b38b6111a7480f7e7ac2e9f7b989f6133fe5510 languageName: node linkType: hard @@ -5531,8 +5605,8 @@ __metadata: version: 0.2.0 resolution: "bplist-parser@npm:0.2.0" dependencies: - big-integer: ^1.6.44 - checksum: d5339dd16afc51de6c88f88f58a45b72ed6a06aa31f5557d09877575f220b7c1d3fbe375da0b62e6a10d4b8ed80523567e351f24014f5bc886ad523758142cdd + big-integer: "npm:^1.6.44" + checksum: 15d31c1b0c7e0fb384e96349453879a33609d92d91b55a9ccee04b4be4b0645f1c823253d73326a1a23104521fbc45c2dd97fb05adf61863841b68cbb2ca7a3d languageName: node linkType: hard @@ -5540,8 +5614,8 @@ __metadata: version: 1.1.11 resolution: "brace-expansion@npm:1.1.11" dependencies: - balanced-match: ^1.0.0 - concat-map: 0.0.1 + balanced-match: "npm:^1.0.0" + concat-map: "npm:0.0.1" checksum: faf34a7bb0c3fcf4b59c7808bc5d2a96a40988addf2e7e09dfbb67a2251800e0d14cd2bfc1aa79174f2f5095c54ff27f46fb1289fe2d77dac755b5eb3434cc07 languageName: node linkType: hard @@ -5550,35 +5624,17 @@ __metadata: version: 2.0.1 resolution: "brace-expansion@npm:2.0.1" dependencies: - balanced-match: ^1.0.0 + balanced-match: "npm:^1.0.0" checksum: a61e7cd2e8a8505e9f0036b3b6108ba5e926b4b55089eeb5550cd04a471fe216c96d4fe7e4c7f995c728c554ae20ddfc4244cad10aef255e72b62930afd233d1 languageName: node linkType: hard -"braces@npm:^2.2.2": - version: 2.3.2 - resolution: "braces@npm:2.3.2" - dependencies: - arr-flatten: ^1.1.0 - array-unique: ^0.3.2 - extend-shallow: ^2.0.1 - fill-range: ^4.0.0 - isobject: ^3.0.1 - repeat-element: ^1.1.2 - snapdragon: ^0.8.1 - snapdragon-node: ^2.0.1 - split-string: ^3.0.2 - to-regex: ^3.0.1 - checksum: e30dcb6aaf4a31c8df17d848aa283a65699782f75ad61ae93ec25c9729c66cf58e66f0000a9fec84e4add1135bb7da40f7cb9601b36bebcfa9ca58e8d5c07de0 - languageName: node - linkType: hard - "braces@npm:^3.0.2, braces@npm:~3.0.2": version: 3.0.2 resolution: "braces@npm:3.0.2" dependencies: - fill-range: ^7.0.1 - checksum: e2a8e769a863f3d4ee887b5fe21f63193a891c68b612ddb4b68d82d1b5f3ff9073af066c343e9867a393fe4c2555dcb33e89b937195feb9c1613d259edfcd459 + fill-range: "npm:^7.0.1" + checksum: 966b1fb48d193b9d155f810e5efd1790962f2c4e0829f8440b8ad236ba009222c501f70185ef732fef17a4c490bb33a03b90dab0631feafbdf447da91e8165b1 languageName: node linkType: hard @@ -5593,8 +5649,8 @@ __metadata: version: 0.1.4 resolution: "browserify-zlib@npm:0.1.4" dependencies: - pako: ~0.2.0 - checksum: abee4cb4349e8a21391fd874564f41b113fe691372913980e6fa06a777e4ea2aad4e942af14ab99bce190d5ac8f5328201432f4ef0eae48c6d02208bc212976f + pako: "npm:~0.2.0" + checksum: cd506a1ef9c3280f6537a17ed1352ef7738b66fef0a15a655dc3a43edc34be6ee78c5838427146ae1fcd4801fc06d2ab203614d0f8c4df8b5a091cf0134b9a80 languageName: node linkType: hard @@ -5602,13 +5658,13 @@ __metadata: version: 4.23.0 resolution: "browserslist@npm:4.23.0" dependencies: - caniuse-lite: ^1.0.30001587 - electron-to-chromium: ^1.4.668 - node-releases: ^2.0.14 - update-browserslist-db: ^1.0.13 + caniuse-lite: "npm:^1.0.30001587" + electron-to-chromium: "npm:^1.4.668" + node-releases: "npm:^2.0.14" + update-browserslist-db: "npm:^1.0.13" bin: browserslist: cli.js - checksum: 436f49e796782ca751ebab7edc010cfc9c29f68536f387666cd70ea22f7105563f04dd62c6ff89cb24cc3254d17cba385f979eeeb3484d43e012412ff7e75def + checksum: 496c3862df74565dd942b4ae65f502c575cbeba1fa4a3894dad7aa3b16130dc3033bc502d8848147f7b625154a284708253d9598bcdbef5a1e34cf11dc7bad8e languageName: node linkType: hard @@ -5616,8 +5672,8 @@ __metadata: version: 2.1.1 resolution: "bser@npm:2.1.1" dependencies: - node-int64: ^0.4.0 - checksum: 9ba4dc58ce86300c862bffc3ae91f00b2a03b01ee07f3564beeeaf82aa243b8b03ba53f123b0b842c190d4399b94697970c8e7cf7b1ea44b61aa28c3526a4449 + node-int64: "npm:^0.4.0" + checksum: edba1b65bae682450be4117b695997972bd9a3c4dfee029cab5bcb72ae5393a79a8f909b8bc77957eb0deec1c7168670f18f4d5c556f46cdd3bca5f3b3a8d020 languageName: node linkType: hard @@ -5639,9 +5695,9 @@ __metadata: version: 5.7.1 resolution: "buffer@npm:5.7.1" dependencies: - base64-js: ^1.3.1 - ieee754: ^1.1.13 - checksum: e2cf8429e1c4c7b8cbd30834ac09bd61da46ce35f5c22a78e6c2f04497d6d25541b16881e30a019c6fd3154150650ccee27a308eff3e26229d788bbdeb08ab84 + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.1.13" + checksum: 997434d3c6e3b39e0be479a80288875f71cd1c07d75a3855e6f08ef848a3c966023f79534e22e415ff3a5112708ce06127277ab20e527146d55c84566405c7c6 languageName: node linkType: hard @@ -5655,7 +5711,7 @@ __metadata: "bytes@npm:3.1.2": version: 3.1.2 resolution: "bytes@npm:3.1.2" - checksum: e4bcd3948d289c5127591fbedf10c0b639ccbf00243504e4e127374a15c3bc8eed0d28d4aaab08ff6f1cf2abc0cce6ba3085ed32f4f90e82a5683ce0014e1b6e + checksum: a10abf2ba70c784471d6b4f58778c0beeb2b5d405148e66affa91f23a9f13d07603d0a0354667310ae1d6dc141474ffd44e2a074be0f6e2254edb8fc21445388 languageName: node linkType: hard @@ -5663,36 +5719,19 @@ __metadata: version: 18.0.2 resolution: "cacache@npm:18.0.2" dependencies: - "@npmcli/fs": ^3.1.0 - fs-minipass: ^3.0.0 - glob: ^10.2.2 - lru-cache: ^10.0.1 - minipass: ^7.0.3 - minipass-collect: ^2.0.1 - minipass-flush: ^1.0.5 - minipass-pipeline: ^1.2.4 - p-map: ^4.0.0 - ssri: ^10.0.0 - tar: ^6.1.11 - unique-filename: ^3.0.0 - checksum: 0250df80e1ad0c828c956744850c5f742c24244e9deb5b7dc81bca90f8c10e011e132ecc58b64497cc1cad9a98968676147fb6575f4f94722f7619757b17a11b - languageName: node - linkType: hard - -"cache-base@npm:^1.0.1": - version: 1.0.1 - resolution: "cache-base@npm:1.0.1" - dependencies: - collection-visit: ^1.0.0 - component-emitter: ^1.2.1 - get-value: ^2.0.6 - has-value: ^1.0.0 - isobject: ^3.0.1 - set-value: ^2.0.0 - to-object-path: ^0.3.0 - union-value: ^1.0.0 - unset-value: ^1.0.0 - checksum: 9114b8654fe2366eedc390bad0bcf534e2f01b239a888894e2928cb58cdc1e6ea23a73c6f3450dcfd2058aa73a8a981e723cd1e7c670c047bf11afdc65880107 + "@npmcli/fs": "npm:^3.1.0" + fs-minipass: "npm:^3.0.0" + glob: "npm:^10.2.2" + lru-cache: "npm:^10.0.1" + minipass: "npm:^7.0.3" + minipass-collect: "npm:^2.0.1" + minipass-flush: "npm:^1.0.5" + minipass-pipeline: "npm:^1.2.4" + p-map: "npm:^4.0.0" + ssri: "npm:^10.0.0" + tar: "npm:^6.1.11" + unique-filename: "npm:^3.0.0" + checksum: 5ca58464f785d4d64ac2019fcad95451c8c89bea25949f63acd8987fcc3493eaef1beccc0fa39e673506d879d3fc1ab420760f8a14f8ddf46ea2d121805a5e96 languageName: node linkType: hard @@ -5700,12 +5739,12 @@ __metadata: version: 1.0.7 resolution: "call-bind@npm:1.0.7" dependencies: - es-define-property: ^1.0.0 - es-errors: ^1.3.0 - function-bind: ^1.1.2 - get-intrinsic: ^1.2.4 - set-function-length: ^1.2.1 - checksum: 295c0c62b90dd6522e6db3b0ab1ce26bdf9e7404215bda13cfee25b626b5ff1a7761324d58d38b1ef1607fc65aca2d06e44d2e18d0dfc6c14b465b00d8660029 + es-define-property: "npm:^1.0.0" + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + get-intrinsic: "npm:^1.2.4" + set-function-length: "npm:^1.2.1" + checksum: cd6fe658e007af80985da5185bff7b55e12ef4c2b6f41829a26ed1eef254b1f1c12e3dfd5b2b068c6ba8b86aba62390842d81752e67dcbaec4f6f76e7113b6b7 languageName: node linkType: hard @@ -5733,20 +5772,7 @@ __metadata: "caniuse-lite@npm:^1.0.30001587": version: 1.0.30001591 resolution: "caniuse-lite@npm:1.0.30001591" - checksum: e48f924cdefff86d29d38ee1bffe2cdb1ef55e179d08ae2f1f5546d9d563e030f13755a0096ea87a09498daffd18666d1fe0b2759aea8421bbf4c214b47d410d - languageName: node - linkType: hard - -"chalk@npm:^1.1.3": - version: 1.1.3 - resolution: "chalk@npm:1.1.3" - dependencies: - ansi-styles: ^2.2.1 - escape-string-regexp: ^1.0.2 - has-ansi: ^2.0.0 - strip-ansi: ^3.0.0 - supports-color: ^2.0.0 - checksum: 9d2ea6b98fc2b7878829eec223abcf404622db6c48396a9b9257f6d0ead2acf18231ae368d6a664a83f272b0679158da12e97b5229f794939e555cc574478acd + checksum: 3891fad30a99b984a3a20570c0440d35dda933c79ea190cdb78a1f1743866506a4b41b4389b53a7c0351f2228125f9dc49308463f57e61503e5689b444add1a8 languageName: node linkType: hard @@ -5754,10 +5780,10 @@ __metadata: version: 2.4.2 resolution: "chalk@npm:2.4.2" dependencies: - ansi-styles: ^3.2.1 - escape-string-regexp: ^1.0.5 - supports-color: ^5.3.0 - checksum: ec3661d38fe77f681200f878edbd9448821924e0f93a9cefc0e26a33b145f1027a2084bf19967160d11e1f03bfe4eaffcabf5493b89098b2782c3fe0b03d80c2 + ansi-styles: "npm:^3.2.1" + escape-string-regexp: "npm:^1.0.5" + supports-color: "npm:^5.3.0" + checksum: 3d1d103433166f6bfe82ac75724951b33769675252d8417317363ef9d54699b7c3b2d46671b772b893a8e50c3ece70c4b933c73c01e81bc60ea4df9b55afa303 languageName: node linkType: hard @@ -5765,9 +5791,9 @@ __metadata: version: 3.0.0 resolution: "chalk@npm:3.0.0" dependencies: - ansi-styles: ^4.1.0 - supports-color: ^7.1.0 - checksum: 8e3ddf3981c4da405ddbd7d9c8d91944ddf6e33d6837756979f7840a29272a69a5189ecae0ff84006750d6d1e92368d413335eab4db5476db6e6703a1d1e0505 + ansi-styles: "npm:^4.1.0" + supports-color: "npm:^7.1.0" + checksum: 37f90b31fd655fb49c2bd8e2a68aebefddd64522655d001ef417e6f955def0ed9110a867ffc878a533f2dafea5f2032433a37c8a7614969baa7f8a1cd424ddfc languageName: node linkType: hard @@ -5775,16 +5801,16 @@ __metadata: version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: - ansi-styles: ^4.1.0 - supports-color: ^7.1.0 - checksum: fe75c9d5c76a7a98d45495b91b2172fa3b7a09e0cc9370e5c8feb1c567b85c4288e2b3fded7cfdd7359ac28d6b3844feb8b82b8686842e93d23c827c417e83fc + ansi-styles: "npm:^4.1.0" + supports-color: "npm:^7.1.0" + checksum: cb3f3e594913d63b1814d7ca7c9bafbf895f75fbf93b92991980610dfd7b48500af4e3a5d4e3a8f337990a96b168d7eb84ee55efdce965e2ee8efc20f8c8f139 languageName: node linkType: hard "char-regex@npm:^1.0.2": version: 1.0.2 resolution: "char-regex@npm:1.0.2" - checksum: b563e4b6039b15213114626621e7a3d12f31008bdce20f9c741d69987f62aeaace7ec30f6018890ad77b2e9b4d95324c9f5acfca58a9441e3b1dcdd1e2525d17 + checksum: 1ec5c2906adb9f84e7f6732a40baef05d7c85401b82ffcbc44b85fbd0f7a2b0c2a96f2eb9cf55cae3235dc12d4023003b88f09bcae8be9ae894f52ed746f4d48 languageName: node linkType: hard @@ -5805,21 +5831,21 @@ __metadata: "character-entities@npm:^1.0.0": version: 1.2.4 resolution: "character-entities@npm:1.2.4" - checksum: e1545716571ead57beac008433c1ff69517cd8ca5b336889321c5b8ff4a99c29b65589a701e9c086cda8a5e346a67295e2684f6c7ea96819fe85cbf49bf8686d + checksum: 7c11641c48d1891aaba7bc800d4500804d91a28f46d64e88c001c38e6ab2e7eae28873a77ae16e6c55d24cac35ddfbb15efe56c3012b86684a3c4e95c70216b7 languageName: node linkType: hard "character-entities@npm:^2.0.0": version: 2.0.2 resolution: "character-entities@npm:2.0.2" - checksum: cf1643814023697f725e47328fcec17923b8f1799102a8a79c1514e894815651794a2bffd84bb1b3a4b124b050154e4529ed6e81f7c8068a734aecf07a6d3def + checksum: c8dd1f4bf1a92fccf7d2fad9673660a88b37854557d30f6076c32fedfb92d1420208298829ff1d3b6b4fa1c7012e8326c45e7f5c3ed1e9a09ec177593c521b2f languageName: node linkType: hard "character-reference-invalid@npm:^1.0.0": version: 1.1.4 resolution: "character-reference-invalid@npm:1.1.4" - checksum: 20274574c70e05e2f81135f3b93285536bc8ff70f37f0809b0d17791a832838f1e49938382899ed4cb444e5bbd4314ca1415231344ba29f4222ce2ccf24fea0b + checksum: 812ebc5e6e8d08fd2fa5245ae78c1e1a4bea4692e93749d256a135c4a442daf931ca18e067cc61ff4a58a419eae52677126a0bc4f05a511290427d60d3057805 languageName: node linkType: hard @@ -5834,18 +5860,18 @@ __metadata: version: 3.6.0 resolution: "chokidar@npm:3.6.0" dependencies: - anymatch: ~3.1.2 - braces: ~3.0.2 - fsevents: ~2.3.2 - glob-parent: ~5.1.2 - is-binary-path: ~2.1.0 - is-glob: ~4.0.1 - normalize-path: ~3.0.0 - readdirp: ~3.6.0 + anymatch: "npm:~3.1.2" + braces: "npm:~3.0.2" + fsevents: "npm:~2.3.2" + glob-parent: "npm:~5.1.2" + is-binary-path: "npm:~2.1.0" + is-glob: "npm:~4.0.1" + normalize-path: "npm:~3.0.0" + readdirp: "npm:~3.6.0" dependenciesMeta: fsevents: optional: true - checksum: d2f29f499705dcd4f6f3bbed79a9ce2388cf530460122eed3b9c48efeab7a4e28739c6551fd15bec9245c6b9eeca7a32baa64694d64d9b6faeb74ddb8c4a413d + checksum: c327fb07704443f8d15f7b4a7ce93b2f0bc0e6cea07ec28a7570aa22cd51fcf0379df589403976ea956c369f25aa82d84561947e227cd925902e1751371658df languageName: node linkType: hard @@ -5866,7 +5892,7 @@ __metadata: "ci-info@npm:^3.2.0": version: 3.9.0 resolution: "ci-info@npm:3.9.0" - checksum: 6b19dc9b2966d1f8c2041a838217299718f15d6c4b63ae36e4674edd2bee48f780e94761286a56aa59eb305a85fbea4ddffb7630ec063e7ec7e7e5ad42549a87 + checksum: 75bc67902b4d1c7b435497adeb91598f6d52a3389398e44294f6601b20cfef32cf2176f7be0eb961d9e085bb333a8a5cae121cb22f81cf238ae7f58eb80e9397 languageName: node linkType: hard @@ -5874,34 +5900,22 @@ __metadata: version: 0.1.6 resolution: "citty@npm:0.1.6" dependencies: - consola: ^3.2.3 - checksum: 3fbcaaea92d328deddb5aba7d629d9076d4f1aa0338f59db7ea647a8f51eedc14b7f6218c87ad03c9e3c126213ba87d13d7774f9c30d64209f4b074aa83bd6ab + consola: "npm:^3.2.3" + checksum: 3208947e73abb699a12578ee2bfee254bf8dd1ce0d5698e8a298411cabf16bd3620d63433aef5bd88cdb2b9da71aef18adefa3b4ffd18273bb62dd1d28c344f5 languageName: node linkType: hard "cjs-module-lexer@npm:^1.0.0": version: 1.2.3 resolution: "cjs-module-lexer@npm:1.2.3" - checksum: 5ea3cb867a9bb609b6d476cd86590d105f3cfd6514db38ff71f63992ab40939c2feb68967faa15a6d2b1f90daa6416b79ea2de486e9e2485a6f8b66a21b4fb0a - languageName: node - linkType: hard - -"class-utils@npm:^0.3.5": - version: 0.3.6 - resolution: "class-utils@npm:0.3.6" - dependencies: - arr-union: ^3.1.0 - define-property: ^0.2.5 - isobject: ^3.0.0 - static-extend: ^0.1.1 - checksum: be108900801e639e50f96a7e4bfa8867c753a7750a7603879f3981f8b0a89cba657497a2d5f40cd4ea557ff15d535a100818bb486baf6e26fe5d7872e75f1078 + checksum: f96a5118b0a012627a2b1c13bd2fcb92509778422aaa825c5da72300d6dcadfb47134dd2e9d97dfa31acd674891dd91642742772d19a09a8adc3e56bd2f5928c languageName: node linkType: hard "classnames@npm:2.5.1": version: 2.5.1 resolution: "classnames@npm:2.5.1" - checksum: da424a8a6f3a96a2e87d01a432ba19315503294ac7e025f9fece656db6b6a0f7b5003bb1fbb51cbb0d9624d964f1b9bb35a51c73af9b2434c7b292c42231c1e5 + checksum: 58eb394e8817021b153bb6e7d782cfb667e4ab390cb2e9dac2fc7c6b979d1cc2b2a733093955fc5c94aa79ef5c8c89f11ab77780894509be6afbb91dddd79d15 languageName: node linkType: hard @@ -5916,7 +5930,7 @@ __metadata: version: 3.1.0 resolution: "cli-cursor@npm:3.1.0" dependencies: - restore-cursor: ^3.1.0 + restore-cursor: "npm:^3.1.0" checksum: 2692784c6cd2fd85cfdbd11f53aea73a463a6d64a77c3e098b2b4697a20443f430c220629e1ca3b195ea5ac4a97a74c2ee411f3807abf6df2b66211fec0c0a29 languageName: node linkType: hard @@ -5924,7 +5938,7 @@ __metadata: "cli-spinners@npm:^2.5.0": version: 2.9.2 resolution: "cli-spinners@npm:2.9.2" - checksum: 1bd588289b28432e4676cb5d40505cfe3e53f2e4e10fbe05c8a710a154d6fe0ce7836844b00d6858f740f2ffe67cdc36e0fce9c7b6a8430e80e6388d5aa4956c + checksum: a0a863f442df35ed7294424f5491fa1756bd8d2e4ff0c8736531d886cec0ece4d85e8663b77a5afaf1d296e3cbbebff92e2e99f52bbea89b667cbe789b994794 languageName: node linkType: hard @@ -5932,12 +5946,12 @@ __metadata: version: 0.6.3 resolution: "cli-table3@npm:0.6.3" dependencies: - "@colors/colors": 1.5.0 - string-width: ^4.2.0 + "@colors/colors": "npm:1.5.0" + string-width: "npm:^4.2.0" dependenciesMeta: "@colors/colors": optional: true - checksum: 09897f68467973f827c04e7eaadf13b55f8aec49ecd6647cc276386ea660059322e2dd8020a8b6b84d422dbdd619597046fa89cbbbdc95b2cea149a2df7c096c + checksum: 8d82b75be7edc7febb1283dc49582a521536527cba80af62a2e4522a0ee39c252886a1a2f02d05ae9d753204dbcffeb3a40d1358ee10dccd7fe8d935cfad3f85 languageName: node linkType: hard @@ -5945,10 +5959,17 @@ __metadata: version: 8.0.1 resolution: "cliui@npm:8.0.1" dependencies: - string-width: ^4.2.0 - strip-ansi: ^6.0.1 - wrap-ansi: ^7.0.0 - checksum: 79648b3b0045f2e285b76fb2e24e207c6db44323581e421c3acbd0e86454cba1b37aea976ab50195a49e7384b871e6dfb2247ad7dec53c02454ac6497394cb56 + string-width: "npm:^4.2.0" + strip-ansi: "npm:^6.0.1" + wrap-ansi: "npm:^7.0.0" + checksum: eaa5561aeb3135c2cddf7a3b3f562fc4238ff3b3fc666869ef2adf264be0f372136702f16add9299087fb1907c2e4ec5dbfe83bd24bce815c70a80c6c1a2e950 + languageName: node + linkType: hard + +"clone-buffer@npm:^1.0.0": + version: 1.0.0 + resolution: "clone-buffer@npm:1.0.0" + checksum: a39a35e7fd081e0f362ba8195bd15cbc8205df1fbe4598bb4e09c1f9a13c0320a47ab8a61a8aa83561e4ed34dc07666d73254ee952ddd3985e4286b082fe63b9 languageName: node linkType: hard @@ -5956,13 +5977,20 @@ __metadata: version: 4.0.1 resolution: "clone-deep@npm:4.0.1" dependencies: - is-plain-object: ^2.0.4 - kind-of: ^6.0.2 - shallow-clone: ^3.0.0 + is-plain-object: "npm:^2.0.4" + kind-of: "npm:^6.0.2" + shallow-clone: "npm:^3.0.0" checksum: 770f912fe4e6f21873c8e8fbb1e99134db3b93da32df271d00589ea4a29dbe83a9808a322c93f3bcaf8584b8b4fa6fc269fc8032efbaa6728e0c9886c74467d2 languageName: node linkType: hard +"clone-stats@npm:^1.0.0": + version: 1.0.0 + resolution: "clone-stats@npm:1.0.0" + checksum: 654c0425afc5c5c55a4d95b2e0c6eccdd55b5247e7a1e7cca9000b13688b96b0a157950c72c5307f9fd61f17333ad796d3cd654778f2d605438012391cc4ada5 + languageName: node + linkType: hard + "clone@npm:^1.0.2": version: 1.0.4 resolution: "clone@npm:1.0.4" @@ -5973,40 +6001,41 @@ __metadata: "clone@npm:^2.1.1": version: 2.1.2 resolution: "clone@npm:2.1.2" - checksum: aaf106e9bc025b21333e2f4c12da539b568db4925c0501a1bf4070836c9e848c892fa22c35548ce0d1132b08bbbfa17a00144fe58fccdab6fa900fec4250f67d + checksum: d9c79efba655f0bf601ab299c57eb54cbaa9860fb011aee9d89ed5ac0d12df1660ab7642fddaabb9a26b7eff0e117d4520512cb70798319ff5d30a111b5310c2 + languageName: node + linkType: hard + +"cloneable-readable@npm:^1.0.0": + version: 1.1.3 + resolution: "cloneable-readable@npm:1.1.3" + dependencies: + inherits: "npm:^2.0.1" + process-nextick-args: "npm:^2.0.0" + readable-stream: "npm:^2.3.5" + checksum: 81e17fe4b2901e2d9899717e1d4ed88bd1ede700b819b77c61f7402b9ca97c4769692d85bd74710be806f31caf33c62acdea49d5bbe8794a66ade01c9c2d5a6d languageName: node linkType: hard "co@npm:^4.6.0": version: 4.6.0 resolution: "co@npm:4.6.0" - checksum: 5210d9223010eb95b29df06a91116f2cf7c8e0748a9013ed853b53f362ea0e822f1e5bb054fb3cefc645239a4cf966af1f6133a3b43f40d591f3b68ed6cf0510 + checksum: a5d9f37091c70398a269e625cedff5622f200ed0aa0cff22ee7b55ed74a123834b58711776eb0f1dc58eb6ebbc1185aa7567b57bd5979a948c6e4f85073e2c05 languageName: node linkType: hard "collect-v8-coverage@npm:^1.0.0": version: 1.0.2 resolution: "collect-v8-coverage@npm:1.0.2" - checksum: c10f41c39ab84629d16f9f6137bc8a63d332244383fc368caf2d2052b5e04c20cd1fd70f66fcf4e2422b84c8226598b776d39d5f2d2a51867cc1ed5d1982b4da - languageName: node - linkType: hard - -"collection-visit@npm:^1.0.0": - version: 1.0.0 - resolution: "collection-visit@npm:1.0.0" - dependencies: - map-visit: ^1.0.0 - object-visit: ^1.0.0 - checksum: 15d9658fe6eb23594728346adad5433b86bb7a04fd51bbab337755158722f9313a5376ef479de5b35fbc54140764d0d39de89c339f5d25b959ed221466981da9 + checksum: 30ea7d5c9ee51f2fdba4901d4186c5b7114a088ef98fd53eda3979da77eed96758a2cae81cc6d97e239aaea6065868cf908b24980663f7b7e96aa291b3e12fa4 languageName: node linkType: hard -"color-convert@npm:^1.9.0": +"color-convert@npm:^1.9.0, color-convert@npm:^1.9.3": version: 1.9.3 resolution: "color-convert@npm:1.9.3" dependencies: - color-name: 1.1.3 - checksum: fd7a64a17cde98fb923b1dd05c5f2e6f7aefda1b60d67e8d449f9328b4e53b228a428fd38bfeaeb2db2ff6b6503a776a996150b80cdf224062af08a5c8a3a203 + color-name: "npm:1.1.3" + checksum: ffa319025045f2973919d155f25e7c00d08836b6b33ea2d205418c59bd63a665d713c52d9737a9e0fe467fb194b40fbef1d849bae80d674568ee220a31ef3d10 languageName: node linkType: hard @@ -6014,8 +6043,8 @@ __metadata: version: 2.0.1 resolution: "color-convert@npm:2.0.1" dependencies: - color-name: ~1.1.4 - checksum: 79e6bdb9fd479a205c71d89574fccfb22bd9053bd98c6c4d870d65c132e5e904e6034978e55b43d69fcaa7433af2016ee203ce76eeba9cfa554b373e7f7db336 + color-name: "npm:~1.1.4" + checksum: fa00c91b4332b294de06b443923246bccebe9fab1b253f7fe1772d37b06a2269b4039a85e309abe1fe11b267b11c08d1d0473fda3badd6167f57313af2887a64 languageName: node linkType: hard @@ -6026,19 +6055,49 @@ __metadata: languageName: node linkType: hard -"color-name@npm:~1.1.4": +"color-name@npm:^1.0.0, color-name@npm:~1.1.4": version: 1.1.4 resolution: "color-name@npm:1.1.4" checksum: b0445859521eb4021cd0fb0cc1a75cecf67fceecae89b63f62b201cca8d345baf8b952c966862a9d9a2632987d4f6581f0ec8d957dfacece86f0a7919316f610 languageName: node linkType: hard +"color-string@npm:^1.6.0": + version: 1.9.1 + resolution: "color-string@npm:1.9.1" + dependencies: + color-name: "npm:^1.0.0" + simple-swizzle: "npm:^0.2.2" + checksum: 72aa0b81ee71b3f4fb1ac9cd839cdbd7a011a7d318ef58e6cb13b3708dca75c7e45029697260488709f1b1c7ac4e35489a87e528156c1e365917d1c4ccb9b9cd + languageName: node + linkType: hard + +"color@npm:^3.1.3": + version: 3.2.1 + resolution: "color@npm:3.2.1" + dependencies: + color-convert: "npm:^1.9.3" + color-string: "npm:^1.6.0" + checksum: bf70438e0192f4f62f4bfbb303e7231289e8cc0d15ff6b6cbdb722d51f680049f38d4fdfc057a99cb641895cf5e350478c61d98586400b060043afc44285e7ae + languageName: node + linkType: hard + +"colorspace@npm:1.1.x": + version: 1.1.4 + resolution: "colorspace@npm:1.1.4" + dependencies: + color: "npm:^3.1.3" + text-hex: "npm:1.0.x" + checksum: bb3934ef3c417e961e6d03d7ca60ea6e175947029bfadfcdb65109b01881a1c0ecf9c2b0b59abcd0ee4a0d7c1eae93beed01b0e65848936472270a0b341ebce8 + languageName: node + linkType: hard + "combined-stream@npm:^1.0.8": version: 1.0.8 resolution: "combined-stream@npm:1.0.8" dependencies: - delayed-stream: ~1.0.0 - checksum: 49fa4aeb4916567e33ea81d088f6584749fc90c7abec76fd516bf1c5aa5c79f3584b5ba3de6b86d26ddd64bae5329c4c7479343250cfe71c75bb366eae53bb7c + delayed-stream: "npm:~1.0.0" + checksum: 2e969e637d05d09fa50b02d74c83a1186f6914aae89e6653b62595cc75a221464f884f55f231b8f4df7a49537fba60bdc0427acd2bf324c09a1dbb84837e36e4 languageName: node linkType: hard @@ -6059,28 +6118,21 @@ __metadata: "commander@npm:^6.2.1": version: 6.2.1 resolution: "commander@npm:6.2.1" - checksum: d7090410c0de6bc5c67d3ca41c41760d6d268f3c799e530aafb73b7437d1826bbf0d2a3edac33f8b57cc9887b4a986dce307fa5557e109be40eadb7c43b21742 + checksum: 25b88c2efd0380c84f7844b39cf18510da7bfc5013692d68cdc65f764a1c34e6c8a36ea6d72b6620e3710a930cf8fab2695bdec2bf7107a0f4fa30a3ef3b7d0e languageName: node linkType: hard "commander@npm:^7.2.0": version: 7.2.0 resolution: "commander@npm:7.2.0" - checksum: 53501cbeee61d5157546c0bef0fedb6cdfc763a882136284bed9a07225f09a14b82d2a84e7637edfd1a679fb35ed9502fd58ef1d091e6287f60d790147f68ddc + checksum: 9973af10727ad4b44f26703bf3e9fdc323528660a7590efe3aa9ad5042b4584c0deed84ba443f61c9d6f02dade54a5a5d3c95e306a1e1630f8374ae6db16c06d languageName: node linkType: hard "commondir@npm:^1.0.1": version: 1.0.1 resolution: "commondir@npm:1.0.1" - checksum: 59715f2fc456a73f68826285718503340b9f0dd89bfffc42749906c5cf3d4277ef11ef1cca0350d0e79204f00f1f6d83851ececc9095dc88512a697ac0b9bdcb - languageName: node - linkType: hard - -"component-emitter@npm:^1.2.1": - version: 1.3.1 - resolution: "component-emitter@npm:1.3.1" - checksum: 94550aa462c7bd5a61c1bc480e28554aa306066930152d1b1844a0dd3845d4e5db7e261ddec62ae184913b3e59b55a2ad84093b9d3596a8f17c341514d6c483d + checksum: 4620bc4936a4ef12ce7dfcd272bb23a99f2ad68889a4e4ad766c9f8ad21af982511934d6f7050d4a8bde90011b1c15d56e61a1b4576d9913efbf697a20172d6c languageName: node linkType: hard @@ -6088,7 +6140,7 @@ __metadata: version: 2.0.18 resolution: "compressible@npm:2.0.18" dependencies: - mime-db: ">= 1.43.0 < 2" + mime-db: "npm:>= 1.43.0 < 2" checksum: 58321a85b375d39230405654721353f709d0c1442129e9a17081771b816302a012471a9b8f4864c7dbe02eef7f2aaac3c614795197092262e94b409c9be108f0 languageName: node linkType: hard @@ -6097,21 +6149,21 @@ __metadata: version: 1.7.4 resolution: "compression@npm:1.7.4" dependencies: - accepts: ~1.3.5 - bytes: 3.0.0 - compressible: ~2.0.16 - debug: 2.6.9 - on-headers: ~1.0.2 - safe-buffer: 5.1.2 - vary: ~1.1.2 - checksum: 35c0f2eb1f28418978615dc1bc02075b34b1568f7f56c62d60f4214d4b7cc00d0f6d282b5f8a954f59872396bd770b6b15ffd8aa94c67d4bce9b8887b906999b + accepts: "npm:~1.3.5" + bytes: "npm:3.0.0" + compressible: "npm:~2.0.16" + debug: "npm:2.6.9" + on-headers: "npm:~1.0.2" + safe-buffer: "npm:5.1.2" + vary: "npm:~1.1.2" + checksum: 469cd097908fe1d3ff146596d4c24216ad25eabb565c5456660bdcb3a14c82ebc45c23ce56e19fc642746cf407093b55ab9aa1ac30b06883b27c6c736e6383c2 languageName: node linkType: hard "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" - checksum: 902a9f5d8967a3e2faf138d5cb784b9979bad2e6db5357c5b21c568df4ebe62bcb15108af1b2253744844eb964fc023fbd9afbbbb6ddd0bcc204c6fb5b7bf3af + checksum: 9680699c8e2b3af0ae22592cb764acaf973f292a7b71b8a06720233011853a58e256c89216a10cbe889727532fd77f8bcd49a760cedfde271b8e006c20e079f2 languageName: node linkType: hard @@ -6119,18 +6171,18 @@ __metadata: version: 1.6.2 resolution: "concat-stream@npm:1.6.2" dependencies: - buffer-from: ^1.0.0 - inherits: ^2.0.3 - readable-stream: ^2.2.2 - typedarray: ^0.0.6 - checksum: 1ef77032cb4459dcd5187bd710d6fc962b067b64ec6a505810de3d2b8cc0605638551b42f8ec91edf6fcd26141b32ef19ad749239b58fae3aba99187adc32285 + buffer-from: "npm:^1.0.0" + inherits: "npm:^2.0.3" + readable-stream: "npm:^2.2.2" + typedarray: "npm:^0.0.6" + checksum: 71db903c84fc073ca35a274074e8d26c4330713d299f8623e993c448c1f6bf8b967806dd1d1a7b0f8add6f15ab1af7435df21fe79b4fe7efd78420c89e054e28 languageName: node linkType: hard "consola@npm:^3.2.3": version: 3.2.3 resolution: "consola@npm:3.2.3" - checksum: 32ec70e177dd2385c42e38078958cc7397be91db21af90c6f9faa0b16168b49b1c61d689338604bbb2d64370b9347a35f42a9197663a913d3a405bb0ce728499 + checksum: 02972dcb048c337357a3628438e5976b8e45bcec22fdcfbe9cd17622992953c4d695d5152f141464a02deac769b1d23028e8ac87f56483838df7a6bbf8e0f5a2 languageName: node linkType: hard @@ -6138,22 +6190,22 @@ __metadata: version: 0.5.4 resolution: "content-disposition@npm:0.5.4" dependencies: - safe-buffer: 5.2.1 - checksum: afb9d545e296a5171d7574fcad634b2fdf698875f4006a9dd04a3e1333880c5c0c98d47b560d01216fb6505a54a2ba6a843ee3a02ec86d7e911e8315255f56c3 + safe-buffer: "npm:5.2.1" + checksum: b7f4ce176e324f19324be69b05bf6f6e411160ac94bc523b782248129eb1ef3be006f6cff431aaea5e337fe5d176ce8830b8c2a1b721626ead8933f0cbe78720 languageName: node linkType: hard "content-type@npm:~1.0.4": version: 1.0.5 resolution: "content-type@npm:1.0.5" - checksum: 566271e0a251642254cde0f845f9dd4f9856e52d988f4eb0d0dcffbb7a1f8ec98de7a5215fc628f3bce30fe2fb6fd2bc064b562d721658c59b544e2d34ea2766 + checksum: 585847d98dc7fb8035c02ae2cb76c7a9bd7b25f84c447e5ed55c45c2175e83617c8813871b4ee22f368126af6b2b167df655829007b21aa10302873ea9c62662 languageName: node linkType: hard "convert-source-map@npm:^2.0.0": version: 2.0.0 resolution: "convert-source-map@npm:2.0.0" - checksum: 63ae9933be5a2b8d4509daca5124e20c14d023c820258e484e32dc324d34c2754e71297c94a05784064ad27615037ef677e3f0c00469fb55f409d2bb21261035 + checksum: c987be3ec061348cdb3c2bfb924bec86dea1eacad10550a85ca23edb0fe3556c3a61c7399114f3331ccb3499d7fd0285ab24566e5745929412983494c3926e15 languageName: node linkType: hard @@ -6167,14 +6219,7 @@ __metadata: "cookie@npm:0.5.0": version: 0.5.0 resolution: "cookie@npm:0.5.0" - checksum: 1f4bd2ca5765f8c9689a7e8954183f5332139eb72b6ff783d8947032ec1fdf43109852c178e21a953a30c0dd42257828185be01b49d1eb1a67fd054ca588a180 - languageName: node - linkType: hard - -"copy-descriptor@npm:^0.1.0": - version: 0.1.1 - resolution: "copy-descriptor@npm:0.1.1" - checksum: d4b7b57b14f1d256bb9aa0b479241048afd7f5bcf22035fc7b94e8af757adeae247ea23c1a774fe44869fd5694efba4a969b88d966766c5245fdee59837fe45b + checksum: aae7911ddc5f444a9025fbd979ad1b5d60191011339bce48e555cb83343d0f98b865ff5c4d71fecdfb8555a5cafdc65632f6fce172f32aaf6936830a883a0380 languageName: node linkType: hard @@ -6182,7 +6227,7 @@ __metadata: version: 3.3.3 resolution: "copy-to-clipboard@npm:3.3.3" dependencies: - toggle-selection: ^1.0.6 + toggle-selection: "npm:^1.0.6" checksum: e0a325e39b7615108e6c1c8ac110ae7b829cdc4ee3278b1df6a0e4228c490442cc86444cd643e2da344fbc424b3aab8909e2fec82f8bc75e7e5b190b7c24eecf languageName: node linkType: hard @@ -6191,8 +6236,8 @@ __metadata: version: 3.36.0 resolution: "core-js-compat@npm:3.36.0" dependencies: - browserslist: ^4.22.3 - checksum: 89d9bdc91cc4085e81c7774427a02b42b494d569f62972658bf8b6ace1931ee60620691fbcd646fcb6a7ead3d874a46990491f345fc29e0d084ed2fcce335aa5 + browserslist: "npm:^4.22.3" + checksum: 633c49a254fe48981057e33651e5a74a0a14f14731aa5afed5d2e61fbe3c5cbc116ffd4feaa158c683c40d6dc4fd2e6aa0ebe12c45d157cfa571309d08400c98 languageName: node linkType: hard @@ -6207,16 +6252,16 @@ __metadata: version: 8.3.6 resolution: "cosmiconfig@npm:8.3.6" dependencies: - import-fresh: ^3.3.0 - js-yaml: ^4.1.0 - parse-json: ^5.2.0 - path-type: ^4.0.0 + import-fresh: "npm:^3.3.0" + js-yaml: "npm:^4.1.0" + parse-json: "npm:^5.2.0" + path-type: "npm:^4.0.0" peerDependencies: typescript: ">=4.9.5" peerDependenciesMeta: typescript: optional: true - checksum: dc339ebea427898c9e03bf01b56ba7afbac07fc7d2a2d5a15d6e9c14de98275a9565da949375aee1809591c152c0a3877bb86dbeaf74d5bd5aaa79955ad9e7a0 + checksum: 91d082baca0f33b1c085bf010f9ded4af43cbedacba8821da0fb5667184d0a848addc52c31fadd080007f904a555319c238cf5f4c03e6d58ece2e4876b2e73d6 languageName: node linkType: hard @@ -6224,16 +6269,16 @@ __metadata: version: 29.7.0 resolution: "create-jest@npm:29.7.0" dependencies: - "@jest/types": ^29.6.3 - chalk: ^4.0.0 - exit: ^0.1.2 - graceful-fs: ^4.2.9 - jest-config: ^29.7.0 - jest-util: ^29.7.0 - prompts: ^2.0.1 + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + exit: "npm:^0.1.2" + graceful-fs: "npm:^4.2.9" + jest-config: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + prompts: "npm:^2.0.1" bin: create-jest: bin/create-jest.js - checksum: 1427d49458adcd88547ef6fa39041e1fe9033a661293aa8d2c3aa1b4967cb5bf4f0c00436c7a61816558f28ba2ba81a94d5c962e8022ea9a883978fc8e1f2945 + checksum: 847b4764451672b4174be4d5c6d7d63442ec3aa5f3de52af924e4d996d87d7801c18e125504f25232fc75840f6625b3ac85860fac6ce799b5efae7bdcaf4a2b7 languageName: node linkType: hard @@ -6241,10 +6286,10 @@ __metadata: version: 7.0.3 resolution: "cross-spawn@npm:7.0.3" dependencies: - path-key: ^3.1.0 - shebang-command: ^2.0.0 - which: ^2.0.1 - checksum: 671cc7c7288c3a8406f3c69a3ae2fc85555c04169e9d611def9a675635472614f1c0ed0ef80955d5b6d4e724f6ced67f0ad1bb006c2ea643488fcfef994d7f52 + path-key: "npm:^3.1.0" + shebang-command: "npm:^2.0.0" + which: "npm:^2.0.1" + checksum: e1a13869d2f57d974de0d9ef7acbf69dc6937db20b918525a01dacb5032129bd552d290d886d981e99f1b624cb03657084cc87bd40f115c07ecf376821c729ce languageName: node linkType: hard @@ -6259,12 +6304,19 @@ __metadata: version: 4.3.0 resolution: "css-select@npm:4.3.0" dependencies: - boolbase: ^1.0.0 - css-what: ^6.0.1 - domhandler: ^4.3.1 - domutils: ^2.8.0 - nth-check: ^2.0.1 - checksum: d6202736839194dd7f910320032e7cfc40372f025e4bf21ca5bf6eb0a33264f322f50ba9c0adc35dadd342d3d6fae5ca244779a4873afbfa76561e343f2058e0 + boolbase: "npm:^1.0.0" + css-what: "npm:^6.0.1" + domhandler: "npm:^4.3.1" + domutils: "npm:^2.8.0" + nth-check: "npm:^2.0.1" + checksum: 8f7310c9af30ccaba8f72cb4a54d32232c53bf9ba05d019b693e16bfd7ba5df0affc1f4d74b1ee55923643d23b80a837eedcf60938c53356e479b04049ff9994 + languageName: node + linkType: hard + +"css-selector-parser@npm:^1.4.1": + version: 1.4.1 + resolution: "css-selector-parser@npm:1.4.1" + checksum: 81ae797956a6c03e9668fcc11446b2e8ffb13fe87fccc5c80b852cace4136668bab8b171192f395edde94932b8e43b67c0975d8c187721e2da186684d6a17e83 languageName: node linkType: hard @@ -6272,16 +6324,16 @@ __metadata: version: 1.1.3 resolution: "css-tree@npm:1.1.3" dependencies: - mdn-data: 2.0.14 - source-map: ^0.6.1 - checksum: 79f9b81803991b6977b7fcb1588799270438274d89066ce08f117f5cdb5e20019b446d766c61506dd772c839df84caa16042d6076f20c97187f5abe3b50e7d1f + mdn-data: "npm:2.0.14" + source-map: "npm:^0.6.1" + checksum: 29710728cc4b136f1e9b23ee1228ec403ec9f3d487bc94a9c5dbec563c1e08c59bc917dd6f82521a35e869ff655c298270f43ca673265005b0cd05b292eb05ab languageName: node linkType: hard "css-what@npm:^6.0.1": version: 6.1.0 resolution: "css-what@npm:6.1.0" - checksum: b975e547e1e90b79625918f84e67db5d33d896e6de846c9b584094e529f0c63e2ab85ee33b9daffd05bff3a146a1916bec664e18bb76dd5f66cbff9fc13b2bbe + checksum: c67a3a2d0d81843af87f8bf0a4d0845b0f952377714abbb2884e48942409d57a2110eabee003609d02ee487b054614bdfcfc59ee265728ff105bd5aa221c1d0e languageName: node linkType: hard @@ -6296,22 +6348,22 @@ __metadata: version: 4.2.0 resolution: "csso@npm:4.2.0" dependencies: - css-tree: ^1.1.2 - checksum: 380ba9663da3bcea58dee358a0d8c4468bb6539be3c439dc266ac41c047217f52fd698fb7e4b6b6ccdfb8cf53ef4ceed8cc8ceccb8dfca2aa628319826b5b998 + css-tree: "npm:^1.1.2" + checksum: 8b6a2dc687f2a8165dde13f67999d5afec63cb07a00ab100fbb41e4e8b28d986cfa0bc466b4f5ba5de7260c2448a64e6ad26ec718dd204d3a7d109982f0bf1aa languageName: node linkType: hard "cssom@npm:^0.5.0": version: 0.5.0 resolution: "cssom@npm:0.5.0" - checksum: 823471aa30091c59e0a305927c30e7768939b6af70405808f8d2ce1ca778cddcb24722717392438329d1691f9a87cb0183b64b8d779b56a961546d54854fde01 + checksum: b502a315b1ce020a692036cc38cb36afa44157219b80deadfa040ab800aa9321fcfbecf02fd2e6ec87db169715e27978b4ab3701f916461e9cf7808899f23b54 languageName: node linkType: hard "cssom@npm:~0.3.6": version: 0.3.8 resolution: "cssom@npm:0.3.8" - checksum: 24beb3087c76c0d52dd458be9ee1fbc80ac771478a9baef35dd258cdeb527c68eb43204dd439692bb2b1ae5272fa5f2946d10946edab0d04f1078f85e06bc7f6 + checksum: 49eacc88077555e419646c0ea84ddc73c97e3a346ad7cb95e22f9413a9722d8964b91d781ce21d378bd5ae058af9a745402383fa4e35e9cdfd19654b63f892a9 languageName: node linkType: hard @@ -6319,22 +6371,22 @@ __metadata: version: 2.3.0 resolution: "cssstyle@npm:2.3.0" dependencies: - cssom: ~0.3.6 - checksum: 5f05e6fd2e3df0b44695c2f08b9ef38b011862b274e320665176467c0725e44a53e341bc4959a41176e83b66064ab786262e7380fd1cabeae6efee0d255bb4e3 + cssom: "npm:~0.3.6" + checksum: 46f7f05a153446c4018b0454ee1464b50f606cb1803c90d203524834b7438eb52f3b173ba0891c618f380ced34ee12020675dc0052a7f1be755fe4ebc27ee977 languageName: node linkType: hard "csstype@npm:^3.0.2": version: 3.1.3 resolution: "csstype@npm:3.1.3" - checksum: 8db785cc92d259102725b3c694ec0c823f5619a84741b5c7991b8ad135dfaa66093038a1cc63e03361a6cd28d122be48f2106ae72334e067dd619a51f49eddf7 + checksum: f593cce41ff5ade23f44e77521e3a1bcc2c64107041e1bf6c3c32adc5187d0d60983292fda326154d20b01079e24931aa5b08e4467cc488b60bb1e7f6d478ade languageName: node linkType: hard "damerau-levenshtein@npm:^1.0.8": version: 1.0.8 resolution: "damerau-levenshtein@npm:1.0.8" - checksum: d240b7757544460ae0586a341a53110ab0a61126570ef2d8c731e3eab3f0cb6e488e2609e6a69b46727635de49be20b071688698744417ff1b6c1d7ccd03e0de + checksum: f4eba1c90170f96be25d95fa3857141b5f81e254f7e4d530da929217b19990ea9a0390fc53d3c1cafac9152fda78e722ea4894f765cf6216be413b5af1fbf821 languageName: node linkType: hard @@ -6342,9 +6394,9 @@ __metadata: version: 3.0.2 resolution: "data-urls@npm:3.0.2" dependencies: - abab: ^2.0.6 - whatwg-mimetype: ^3.0.0 - whatwg-url: ^11.0.0 + abab: "npm:^2.0.6" + whatwg-mimetype: "npm:^3.0.0" + whatwg-url: "npm:^11.0.0" checksum: 033fc3dd0fba6d24bc9a024ddcf9923691dd24f90a3d26f6545d6a2f71ec6956f93462f2cdf2183cc46f10dc01ed3bcb36731a8208456eb1a08147e571fe2a76 languageName: node linkType: hard @@ -6352,16 +6404,16 @@ __metadata: "date-fns@npm:3.3.1": version: 3.3.1 resolution: "date-fns@npm:3.3.1" - checksum: 6245e93a47de28ac96dffd4d62877f86e6b64854860ae1e00a4f83174d80bc8e59bd1259cf265223fb2ddce5c8e586dc9cc210f0d052faba2f7660e265877283 + checksum: 98231936765dfb6fc6897676319b500a06a39f051b2c3ecbdd541a07ce9b1344b770277b8bfb1049fb7a2f70bf365ac8e6f1e2bb452b10e1a8101d518ca7f95d languageName: node linkType: hard -"debug@npm:2.6.9, debug@npm:^2.2.0, debug@npm:^2.3.3, debug@npm:^2.6.9": +"debug@npm:2.6.9, debug@npm:^2.6.9": version: 2.6.9 resolution: "debug@npm:2.6.9" dependencies: - ms: 2.0.0 - checksum: d2f51589ca66df60bf36e1fa6e4386b318c3f1e06772280eea5b1ae9fd3d05e9c2b7fd8a7d862457d00853c75b00451aa2d7459b924629ee385287a650f58fe6 + ms: "npm:2.0.0" + checksum: e07005f2b40e04f1bd14a3dd20520e9c4f25f60224cb006ce9d6781732c917964e9ec029fc7f1a151083cd929025ad5133814d4dc624a9aaf020effe4914ed14 languageName: node linkType: hard @@ -6369,11 +6421,11 @@ __metadata: version: 4.3.4 resolution: "debug@npm:4.3.4" dependencies: - ms: 2.1.2 + ms: "npm:2.1.2" peerDependenciesMeta: supports-color: optional: true - checksum: 3dbad3f94ea64f34431a9cbf0bafb61853eda57bff2880036153438f50fb5a84f27683ba0d8e5426bf41a8c6ff03879488120cf5b3a761e77953169c0600a708 + checksum: 0073c3bcbd9cb7d71dd5f6b55be8701af42df3e56e911186dfa46fac3a5b9eb7ce7f377dd1d3be6db8977221f8eb333d945216f645cf56f6b688cd484837d255 languageName: node linkType: hard @@ -6381,15 +6433,15 @@ __metadata: version: 3.2.7 resolution: "debug@npm:3.2.7" dependencies: - ms: ^2.1.1 - checksum: b3d8c5940799914d30314b7c3304a43305fd0715581a919dacb8b3176d024a782062368405b47491516d2091d6462d4d11f2f4974a405048094f8bfebfa3071c + ms: "npm:^2.1.1" + checksum: d86fd7be2b85462297ea16f1934dc219335e802f629ca9a69b63ed8ed041dda492389bb2ee039217c02e5b54792b1c51aa96ae954cf28634d363a2360c7a1639 languageName: node linkType: hard "decimal.js@npm:^10.4.2": version: 10.4.3 resolution: "decimal.js@npm:10.4.3" - checksum: 796404dcfa9d1dbfdc48870229d57f788b48c21c603c3f6554a1c17c10195fc1024de338b0cf9e1efe0c7c167eeb18f04548979bcc5fdfabebb7cc0ae3287bae + checksum: de663a7bc4d368e3877db95fcd5c87b965569b58d16cdc4258c063d231ca7118748738df17cd638f7e9dd0be8e34cec08d7234b20f1f2a756a52fc5a38b188d0 languageName: node linkType: hard @@ -6397,18 +6449,11 @@ __metadata: version: 1.0.2 resolution: "decode-named-character-reference@npm:1.0.2" dependencies: - character-entities: ^2.0.0 + character-entities: "npm:^2.0.0" checksum: f4c71d3b93105f20076052f9cb1523a22a9c796b8296cd35eef1ca54239c78d182c136a848b83ff8da2071e3ae2b1d300bf29d00650a6d6e675438cc31b11d78 languageName: node linkType: hard -"decode-uri-component@npm:^0.2.0": - version: 0.2.2 - resolution: "decode-uri-component@npm:0.2.2" - checksum: 95476a7d28f267292ce745eac3524a9079058bbb35767b76e3ee87d42e34cd0275d2eb19d9d08c3e167f97556e8a2872747f5e65cbebcac8b0c98d83e285f139 - languageName: node - linkType: hard - "dedent@npm:^1.0.0": version: 1.5.1 resolution: "dedent@npm:1.5.1" @@ -6417,7 +6462,7 @@ __metadata: peerDependenciesMeta: babel-plugin-macros: optional: true - checksum: c3c300a14edf1bdf5a873f9e4b22e839d62490bc5c8d6169c1f15858a1a76733d06a9a56930e963d677a2ceeca4b6b0894cc5ea2f501aa382ca5b92af3413c2a + checksum: fc00a8bc3dfb7c413a778dc40ee8151b6c6ff35159d641f36ecd839c1df5c6e0ec5f4992e658c82624a1a62aaecaffc23b9c965ceb0bbf4d698bfc16469ac27d languageName: node linkType: hard @@ -6425,39 +6470,39 @@ __metadata: version: 2.2.3 resolution: "deep-equal@npm:2.2.3" dependencies: - array-buffer-byte-length: ^1.0.0 - call-bind: ^1.0.5 - es-get-iterator: ^1.1.3 - get-intrinsic: ^1.2.2 - is-arguments: ^1.1.1 - is-array-buffer: ^3.0.2 - is-date-object: ^1.0.5 - is-regex: ^1.1.4 - is-shared-array-buffer: ^1.0.2 - isarray: ^2.0.5 - object-is: ^1.1.5 - object-keys: ^1.1.1 - object.assign: ^4.1.4 - regexp.prototype.flags: ^1.5.1 - side-channel: ^1.0.4 - which-boxed-primitive: ^1.0.2 - which-collection: ^1.0.1 - which-typed-array: ^1.1.13 - checksum: ee8852f23e4d20a5626c13b02f415ba443a1b30b4b3d39eaf366d59c4a85e6545d7ec917db44d476a85ae5a86064f7e5f7af7479f38f113995ba869f3a1ddc53 + array-buffer-byte-length: "npm:^1.0.0" + call-bind: "npm:^1.0.5" + es-get-iterator: "npm:^1.1.3" + get-intrinsic: "npm:^1.2.2" + is-arguments: "npm:^1.1.1" + is-array-buffer: "npm:^3.0.2" + is-date-object: "npm:^1.0.5" + is-regex: "npm:^1.1.4" + is-shared-array-buffer: "npm:^1.0.2" + isarray: "npm:^2.0.5" + object-is: "npm:^1.1.5" + object-keys: "npm:^1.1.1" + object.assign: "npm:^4.1.4" + regexp.prototype.flags: "npm:^1.5.1" + side-channel: "npm:^1.0.4" + which-boxed-primitive: "npm:^1.0.2" + which-collection: "npm:^1.0.1" + which-typed-array: "npm:^1.1.13" + checksum: 1ce49d0b71d0f14d8ef991a742665eccd488dfc9b3cada069d4d7a86291e591c92d2589c832811dea182b4015736b210acaaebce6184be356c1060d176f5a05f languageName: node linkType: hard "deep-is@npm:^0.1.3": version: 0.1.4 resolution: "deep-is@npm:0.1.4" - checksum: edb65dd0d7d1b9c40b2f50219aef30e116cedd6fc79290e740972c132c09106d2e80aa0bc8826673dd5a00222d4179c84b36a790eef63a4c4bca75a37ef90804 + checksum: ec12d074aef5ae5e81fa470b9317c313142c9e8e2afe3f8efa124db309720db96d1d222b82b84c834e5f87e7a614b44a4684b6683583118b87c833b3be40d4d8 languageName: node linkType: hard "deepmerge@npm:^4.2.2": version: 4.3.1 resolution: "deepmerge@npm:4.3.1" - checksum: 2024c6a980a1b7128084170c4cf56b0fd58a63f2da1660dcfe977415f27b17dbe5888668b59d0b063753f3220719d5e400b7f113609489c90160bb9a5518d052 + checksum: 058d9e1b0ff1a154468bf3837aea436abcfea1ba1d165ddaaf48ca93765fdd01a30d33c36173da8fbbed951dd0a267602bc782fe288b0fc4b7e1e7091afc4529 languageName: node linkType: hard @@ -6465,8 +6510,8 @@ __metadata: version: 3.0.0 resolution: "default-browser-id@npm:3.0.0" dependencies: - bplist-parser: ^0.2.0 - untildify: ^4.0.0 + bplist-parser: "npm:^0.2.0" + untildify: "npm:^4.0.0" checksum: 279c7ad492542e5556336b6c254a4eaf31b2c63a5433265655ae6e47301197b6cfb15c595a6fdc6463b2ff8e1a1a1ed3cba56038a60e1527ba4ab1628c6b9941 languageName: node linkType: hard @@ -6475,7 +6520,7 @@ __metadata: version: 1.0.4 resolution: "defaults@npm:1.0.4" dependencies: - clone: ^1.0.2 + clone: "npm:^1.0.2" checksum: 3a88b7a587fc076b84e60affad8b85245c01f60f38fc1d259e7ac1d89eb9ce6abb19e27215de46b98568dd5bc48471730b327637e6f20b0f1bc85cf00440c80a languageName: node linkType: hard @@ -6484,10 +6529,10 @@ __metadata: version: 1.1.4 resolution: "define-data-property@npm:1.1.4" dependencies: - es-define-property: ^1.0.0 - es-errors: ^1.3.0 - gopd: ^1.0.1 - checksum: 8068ee6cab694d409ac25936eb861eea704b7763f7f342adbdfe337fc27c78d7ae0eff2364b2917b58c508d723c7a074326d068eef2e45c4edcd85cf94d0313b + es-define-property: "npm:^1.0.0" + es-errors: "npm:^1.3.0" + gopd: "npm:^1.0.1" + checksum: abdcb2505d80a53524ba871273e5da75e77e52af9e15b3aa65d8aad82b8a3a424dad7aee2cc0b71470ac7acf501e08defac362e8b6a73cdb4309f028061df4ae languageName: node linkType: hard @@ -6502,45 +6547,17 @@ __metadata: version: 1.2.1 resolution: "define-properties@npm:1.2.1" dependencies: - define-data-property: ^1.0.1 - has-property-descriptors: ^1.0.0 - object-keys: ^1.1.1 + define-data-property: "npm:^1.0.1" + has-property-descriptors: "npm:^1.0.0" + object-keys: "npm:^1.1.1" checksum: b4ccd00597dd46cb2d4a379398f5b19fca84a16f3374e2249201992f36b30f6835949a9429669ee6b41b6e837205a163eadd745e472069e70dfc10f03e5fcc12 languageName: node linkType: hard -"define-property@npm:^0.2.5": - version: 0.2.5 - resolution: "define-property@npm:0.2.5" - dependencies: - is-descriptor: ^0.1.0 - checksum: 85af107072b04973b13f9e4128ab74ddfda48ec7ad2e54b193c0ffb57067c4ce5b7786a7b4ae1f24bd03e87c5d18766b094571810b314d7540f86d4354dbd394 - languageName: node - linkType: hard - -"define-property@npm:^1.0.0": - version: 1.0.0 - resolution: "define-property@npm:1.0.0" - dependencies: - is-descriptor: ^1.0.0 - checksum: 5fbed11dace44dd22914035ba9ae83ad06008532ca814d7936a53a09e897838acdad5b108dd0688cc8d2a7cf0681acbe00ee4136cf36743f680d10517379350a - languageName: node - linkType: hard - -"define-property@npm:^2.0.2": - version: 2.0.2 - resolution: "define-property@npm:2.0.2" - dependencies: - is-descriptor: ^1.0.2 - isobject: ^3.0.1 - checksum: 3217ed53fc9eed06ba8da6f4d33e28c68a82e2f2a8ab4d562c4920d8169a166fe7271453675e6c69301466f36a65d7f47edf0cf7f474b9aa52a5ead9c1b13c99 - languageName: node - linkType: hard - "defu@npm:^6.1.3": version: 6.1.4 resolution: "defu@npm:6.1.4" - checksum: 40e3af6338f195ac1564f53d1887fa2d0429ac7e8c081204bc4d29191180059d3952b5f4e08fe5df8d59eb873aa26e9c88b56d4fac699673d4a372c93620b229 + checksum: aeffdb47300f45b4fdef1c5bd3880ac18ea7a1fd5b8a8faf8df29350ff03bf16dd34f9800205cab513d476e4c0a3783aa0cff0a433aff0ac84a67ddc4c8a2d64 languageName: node linkType: hard @@ -6548,14 +6565,14 @@ __metadata: version: 6.1.1 resolution: "del@npm:6.1.1" dependencies: - globby: ^11.0.1 - graceful-fs: ^4.2.4 - is-glob: ^4.0.1 - is-path-cwd: ^2.2.0 - is-path-inside: ^3.0.2 - p-map: ^4.0.0 - rimraf: ^3.0.2 - slash: ^3.0.0 + globby: "npm:^11.0.1" + graceful-fs: "npm:^4.2.4" + is-glob: "npm:^4.0.1" + is-path-cwd: "npm:^2.2.0" + is-path-inside: "npm:^3.0.2" + p-map: "npm:^4.0.0" + rimraf: "npm:^3.0.2" + slash: "npm:^3.0.0" checksum: 563288b73b8b19a7261c47fd21a330eeab6e2acd7c6208c49790dfd369127120dd7836cdf0c1eca216b77c94782a81507eac6b4734252d3bef2795cb366996b6 languageName: node linkType: hard @@ -6570,14 +6587,14 @@ __metadata: "depd@npm:2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" - checksum: abbe19c768c97ee2eed6282d8ce3031126662252c58d711f646921c9623f9052e3e1906443066beec1095832f534e57c523b7333f8e7e0d93051ab6baef5ab3a + checksum: c0c8ff36079ce5ada64f46cc9d6fd47ebcf38241105b6e0c98f412e8ad91f084bcf906ff644cc3a4bd876ca27a62accb8b0fff72ea6ed1a414b89d8506f4a5ca languageName: node linkType: hard "dequal@npm:^2.0.2, dequal@npm:^2.0.3": version: 2.0.3 resolution: "dequal@npm:2.0.3" - checksum: 8679b850e1a3d0ebbc46ee780d5df7b478c23f335887464023a631d1b9af051ad4a6595a44220f9ff8ff95a8ddccf019b5ad778a976fd7bbf77383d36f412f90 + checksum: 6ff05a7561f33603df87c45e389c9ac0a95e3c056be3da1a0c4702149e3a7f6fe5ffbb294478687ba51a9e95f3a60e8b6b9005993acd79c292c7d15f71964b6b languageName: node linkType: hard @@ -6613,7 +6630,7 @@ __metadata: version: 2.0.1 resolution: "detect-package-manager@npm:2.0.1" dependencies: - execa: ^5.1.1 + execa: "npm:^5.1.1" checksum: e72b910182d5ad479198d4235be206ac64a479257b32201bb06f3c842cc34c65ea851d46f72cc1d4bf535bcc6c4b44b5b86bb29fe1192b8c9c07b46883672f28 languageName: node linkType: hard @@ -6622,8 +6639,8 @@ __metadata: version: 1.5.1 resolution: "detect-port@npm:1.5.1" dependencies: - address: ^1.0.1 - debug: 4 + address: "npm:^1.0.1" + debug: "npm:4" bin: detect: bin/detect-port.js detect-port: bin/detect-port.js @@ -6634,7 +6651,7 @@ __metadata: "diff-sequences@npm:^29.6.3": version: 29.6.3 resolution: "diff-sequences@npm:29.6.3" - checksum: f4914158e1f2276343d98ff5b31fc004e7304f5470bf0f1adb2ac6955d85a531a6458d33e87667f98f6ae52ebd3891bb47d420bb48a5bd8b7a27ee25b20e33aa + checksum: 179daf9d2f9af5c57ad66d97cb902a538bcf8ed64963fa7aa0c329b3de3665ce2eb6ffdc2f69f29d445fa4af2517e5e55e5b6e00c00a9ae4f43645f97f7078cb languageName: node linkType: hard @@ -6642,7 +6659,7 @@ __metadata: version: 3.0.1 resolution: "dir-glob@npm:3.0.1" dependencies: - path-type: ^4.0.0 + path-type: "npm:^4.0.0" checksum: fa05e18324510d7283f55862f3161c6759a3f2f8dbce491a2fc14c8324c498286c54282c1f0e933cb930da8419b30679389499b919122952a4f8592362ef4615 languageName: node linkType: hard @@ -6651,8 +6668,8 @@ __metadata: version: 2.1.0 resolution: "doctrine@npm:2.1.0" dependencies: - esutils: ^2.0.2 - checksum: a45e277f7feaed309fe658ace1ff286c6e2002ac515af0aaf37145b8baa96e49899638c7cd47dccf84c3d32abfc113246625b3ac8f552d1046072adee13b0dc8 + esutils: "npm:^2.0.2" + checksum: 555684f77e791b17173ea86e2eea45ef26c22219cb64670669c4f4bebd26dbc95cd90ec1f4159e9349a6bb9eb892ce4dde8cd0139e77bedd8bf4518238618474 languageName: node linkType: hard @@ -6660,32 +6677,22 @@ __metadata: version: 3.0.0 resolution: "doctrine@npm:3.0.0" dependencies: - esutils: ^2.0.2 - checksum: fd7673ca77fe26cd5cba38d816bc72d641f500f1f9b25b83e8ce28827fe2da7ad583a8da26ab6af85f834138cf8dae9f69b0cd6ab925f52ddab1754db44d99ce + esutils: "npm:^2.0.2" + checksum: b4b28f1df5c563f7d876e7461254a4597b8cabe915abe94d7c5d1633fed263fcf9a85e8d3836591fc2d040108e822b0d32758e5ec1fe31c590dc7e08086e3e48 languageName: node linkType: hard "dom-accessibility-api@npm:^0.5.9": version: 0.5.16 resolution: "dom-accessibility-api@npm:0.5.16" - checksum: 005eb283caef57fc1adec4d5df4dd49189b628f2f575af45decb210e04d634459e3f1ee64f18b41e2dcf200c844bc1d9279d80807e686a30d69a4756151ad248 + checksum: 377b4a7f9eae0a5d72e1068c369c99e0e4ca17fdfd5219f3abd32a73a590749a267475a59d7b03a891f9b673c27429133a818c44b2e47e32fec024b34274e2ca languageName: node linkType: hard "dom-accessibility-api@npm:^0.6.3": version: 0.6.3 resolution: "dom-accessibility-api@npm:0.6.3" - checksum: c325b5144bb406df23f4affecffc117dbaec9af03daad9ee6b510c5be647b14d28ef0a4ea5ca06d696d8ab40bb777e5fed98b985976fdef9d8790178fa1d573f - languageName: node - linkType: hard - -"dom-serializer@npm:0": - version: 0.2.2 - resolution: "dom-serializer@npm:0.2.2" - dependencies: - domelementtype: ^2.0.1 - entities: ^2.0.0 - checksum: 376344893e4feccab649a14ca1a46473e9961f40fe62479ea692d4fee4d9df1c00ca8654811a79c1ca7b020096987e1ca4fb4d7f8bae32c1db800a680a0e5d5e + checksum: 83d3371f8226487fbad36e160d44f1d9017fb26d46faba6a06fcad15f34633fc827b8c3e99d49f71d5f3253d866e2131826866fd0a3c86626f8eccfc361881ff languageName: node linkType: hard @@ -6693,10 +6700,10 @@ __metadata: version: 1.4.1 resolution: "dom-serializer@npm:1.4.1" dependencies: - domelementtype: ^2.0.1 - domhandler: ^4.2.0 - entities: ^2.0.0 - checksum: fbb0b01f87a8a2d18e6e5a388ad0f7ec4a5c05c06d219377da1abc7bb0f674d804f4a8a94e3f71ff15f6cb7dcfc75704a54b261db672b9b3ab03da6b758b0b22 + domelementtype: "npm:^2.0.1" + domhandler: "npm:^4.2.0" + entities: "npm:^2.0.0" + checksum: 53b217bcfed4a0f90dd47f34f239b1c81fff53ffa39d164d722325817fdb554903b145c2d12c8421ce0df7d31c1b180caf7eacd3c86391dd925f803df8027dcc languageName: node linkType: hard @@ -6704,17 +6711,10 @@ __metadata: version: 2.0.0 resolution: "dom-serializer@npm:2.0.0" dependencies: - domelementtype: ^2.3.0 - domhandler: ^5.0.2 - entities: ^4.2.0 - checksum: cd1810544fd8cdfbd51fa2c0c1128ec3a13ba92f14e61b7650b5de421b88205fd2e3f0cc6ace82f13334114addb90ed1c2f23074a51770a8e9c1273acbc7f3e6 - languageName: node - linkType: hard - -"domelementtype@npm:1, domelementtype@npm:^1.3.1": - version: 1.3.1 - resolution: "domelementtype@npm:1.3.1" - checksum: 7893da40218ae2106ec6ffc146b17f203487a52f5228b032ea7aa470e41dfe03e1bd762d0ee0139e792195efda765434b04b43cddcf63207b098f6ae44b36ad6 + domelementtype: "npm:^2.3.0" + domhandler: "npm:^5.0.2" + entities: "npm:^4.2.0" + checksum: e3bf9027a64450bca0a72297ecdc1e3abb7a2912268a9f3f5d33a2e29c1e2c3502c6e9f860fc6625940bfe0cfb57a44953262b9e94df76872fdfb8151097eeb3 languageName: node linkType: hard @@ -6729,8 +6729,8 @@ __metadata: version: 4.0.0 resolution: "domexception@npm:4.0.0" dependencies: - webidl-conversions: ^7.0.0 - checksum: ddbc1268edf33a8ba02ccc596735ede80375ee0cf124b30d2f05df5b464ba78ef4f49889b6391df4a04954e63d42d5631c7fcf8b1c4f12bc531252977a5f13d5 + webidl-conversions: "npm:^7.0.0" + checksum: 4ed443227d2871d76c58d852b2e93c68e0443815b2741348f20881bedee8c1ad4f9bfc5d30c7dec433cd026b57da63407c010260b1682fef4c8847e7181ea43f languageName: node linkType: hard @@ -6738,17 +6738,8 @@ __metadata: version: 5.0.3 resolution: "domhandler@npm:5.0.3" dependencies: - domelementtype: ^2.3.0 - checksum: 0f58f4a6af63e6f3a4320aa446d28b5790a009018707bce2859dcb1d21144c7876482b5188395a188dfa974238c019e0a1e610d2fc269a12b2c192ea2b0b131c - languageName: node - linkType: hard - -"domhandler@npm:^2.3.0": - version: 2.4.2 - resolution: "domhandler@npm:2.4.2" - dependencies: - domelementtype: 1 - checksum: 49bd70c9c784f845cd047e1dfb3611bd10891c05719acfc93f01fc726a419ed09fbe0b69f9064392d556a63fffc5a02010856cedae9368f4817146d95a97011f + domelementtype: "npm:^2.3.0" + checksum: 809b805a50a9c6884a29f38aec0a4e1b4537f40e1c861950ed47d10b049febe6b79ab72adaeeebb3cc8fc1cd33f34e97048a72a9265103426d93efafa78d3e96 languageName: node linkType: hard @@ -6756,18 +6747,8 @@ __metadata: version: 4.3.1 resolution: "domhandler@npm:4.3.1" dependencies: - domelementtype: ^2.2.0 - checksum: 4c665ceed016e1911bf7d1dadc09dc888090b64dee7851cccd2fcf5442747ec39c647bb1cb8c8919f8bbdd0f0c625a6bafeeed4b2d656bbecdbae893f43ffaaa - languageName: node - linkType: hard - -"domutils@npm:^1.5.1": - version: 1.7.0 - resolution: "domutils@npm:1.7.0" - dependencies: - dom-serializer: 0 - domelementtype: 1 - checksum: f60a725b1f73c1ae82f4894b691601ecc6ecb68320d87923ac3633137627c7865725af813ae5d188ad3954283853bcf46779eb50304ec5d5354044569fcefd2b + domelementtype: "npm:^2.2.0" + checksum: e0d2af7403997a3ca040a9ace4a233b75ebe321e0ef628b417e46d619d65d47781b2f2038b6c2ef6e56e73e66aec99caf6a12c7e687ecff18ef74af6dfbde5de languageName: node linkType: hard @@ -6775,10 +6756,10 @@ __metadata: version: 2.8.0 resolution: "domutils@npm:2.8.0" dependencies: - dom-serializer: ^1.0.1 - domelementtype: ^2.2.0 - domhandler: ^4.2.0 - checksum: abf7434315283e9aadc2a24bac0e00eab07ae4313b40cc239f89d84d7315ebdfd2fb1b5bf750a96bc1b4403d7237c7b2ebf60459be394d625ead4ca89b934391 + dom-serializer: "npm:^1.0.1" + domelementtype: "npm:^2.2.0" + domhandler: "npm:^4.2.0" + checksum: 1f316a03f00b09a8893d4a25d297d5cbffd02c564509dede28ef72d5ce38d93f6d61f1de88d439f31b14a1d9b42f587ed711b9e8b1b4d3bf6001399832bfc4e0 languageName: node linkType: hard @@ -6786,24 +6767,24 @@ __metadata: version: 3.1.0 resolution: "domutils@npm:3.1.0" dependencies: - dom-serializer: ^2.0.0 - domelementtype: ^2.3.0 - domhandler: ^5.0.3 - checksum: e5757456ddd173caa411cfc02c2bb64133c65546d2c4081381a3bafc8a57411a41eed70494551aa58030be9e58574fcc489828bebd673863d39924fb4878f416 + dom-serializer: "npm:^2.0.0" + domelementtype: "npm:^2.3.0" + domhandler: "npm:^5.0.3" + checksum: 9a169a6e57ac4c738269a73ab4caf785114ed70e46254139c1bbc8144ac3102aacb28a6149508395ae34aa5d6a40081f4fa5313855dc8319c6d8359866b6dfea languageName: node linkType: hard "dotenv-expand@npm:^10.0.0": version: 10.0.0 resolution: "dotenv-expand@npm:10.0.0" - checksum: 2a38b470efe0abcb1ac8490421a55e1d764dc9440fd220942bce40965074f3fb00b585f4346020cb0f0f219966ee6b4ee5023458b3e2953fe5b3214de1b314ee + checksum: b41eb278bc96b92cbf3037ca5f3d21e8845bf165dc06b6f9a0a03d278c2bd5a01c0cfbb3528ae3a60301ba1a8a9cace30e748c54b460753bc00d4c014b675597 languageName: node linkType: hard "dotenv@npm:^16.0.0": version: 16.4.5 resolution: "dotenv@npm:16.4.5" - checksum: 301a12c3d44fd49888b74eb9ccf9f07a1f5df43f489e7fcb89647a2edcd84c42d6bc349dc8df099cd18f07c35c7b04685c1a4f3e6a6a9e6b30f8d48c15b7f49c + checksum: 55a3134601115194ae0f924e54473459ed0d9fc340ae610b676e248cca45aa7c680d86365318ea964e6da4e2ea80c4514c1adab5adb43d6867fb57ff068f95c8 languageName: node linkType: hard @@ -6811,18 +6792,18 @@ __metadata: version: 3.7.1 resolution: "duplexify@npm:3.7.1" dependencies: - end-of-stream: ^1.0.0 - inherits: ^2.0.1 - readable-stream: ^2.0.0 - stream-shift: ^1.0.0 - checksum: 3c2ed2223d956a5da713dae12ba8295acb61d9acd966ccbba938090d04f4574ca4dca75cca089b5077c2d7e66101f32e6ea9b36a78ca213eff574e7a8b8accf2 + end-of-stream: "npm:^1.0.0" + inherits: "npm:^2.0.1" + readable-stream: "npm:^2.0.0" + stream-shift: "npm:^1.0.0" + checksum: 7799984d178fb57e11c43f5f172a10f795322ec85ff664c2a98d2c2de6deeb9d7a30b810f83923dcd7ebe0f1786724b8aee2b62ca4577522141f93d6d48fb31c languageName: node linkType: hard "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" - checksum: 7d00d7cd8e49b9afa762a813faac332dee781932d6f2c848dc348939c4253f1d4564341b7af1d041853bc3f32c2ef141b58e0a4d9862c17a7f08f68df1e0f1ed + checksum: 9b1d3e1baefeaf7d70799db8774149cef33b97183a6addceeba0cf6b85ba23ee2686f302f14482006df32df75d32b17c509c143a3689627929e4a8efaf483952 languageName: node linkType: hard @@ -6837,45 +6818,45 @@ __metadata: version: 3.1.9 resolution: "ejs@npm:3.1.9" dependencies: - jake: ^10.8.5 + jake: "npm:^10.8.5" bin: ejs: bin/cli.js - checksum: af6f10eb815885ff8a8cfacc42c6b6cf87daf97a4884f87a30e0c3271fedd85d76a3a297d9c33a70e735b97ee632887f85e32854b9cdd3a2d97edf931519a35f + checksum: 71f56d37540d2c2d71701f0116710c676f75314a3e997ef8b83515d5d4d2b111c5a72725377caeecb928671bacb84a0d38135f345904812e989847057d59f21a languageName: node linkType: hard "electron-to-chromium@npm:^1.4.668": version: 1.4.685 resolution: "electron-to-chromium@npm:1.4.685" - checksum: d97e19f0116d6aa2ec4f73de00c4ba72fd4e372185e0f9ba294b57b0a56406c7af592f0863a6754492d34f4e9dbce4f314da7bbeb7a59de4def4eae6d1d8a737 + checksum: 8067c77260c94dee9117da963cc2d4cab13854ce6da97b310cc2c23da3dbde82fd94b5c3c352e86ca522b8491bf9cc621d61d8b58ff94a46f7c0a60dc64c4fcf languageName: node linkType: hard "emittery@npm:^0.13.1": version: 0.13.1 resolution: "emittery@npm:0.13.1" - checksum: 2b089ab6306f38feaabf4f6f02792f9ec85fc054fda79f44f6790e61bbf6bc4e1616afb9b232e0c5ec5289a8a452f79bfa6d905a6fd64e94b49981f0934001c6 + checksum: fbe214171d878b924eedf1757badf58a5dce071cd1fa7f620fa841a0901a80d6da47ff05929d53163105e621ce11a71b9d8acb1148ffe1745e045145f6e69521 languageName: node linkType: hard "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" - checksum: d4c5c39d5a9868b5fa152f00cada8a936868fd3367f33f71be515ecee4c803132d11b31a6222b2571b1e5f7e13890156a94880345594d0ce7e3c9895f560f192 + checksum: c72d67a6821be15ec11997877c437491c313d924306b8da5d87d2a2bcc2cec9903cb5b04ee1a088460501d8e5b44f10df82fdc93c444101a7610b80c8b6938e1 languageName: node linkType: hard "emoji-regex@npm:^9.2.2": version: 9.2.2 resolution: "emoji-regex@npm:9.2.2" - checksum: 8487182da74aabd810ac6d6f1994111dfc0e331b01271ae01ec1eb0ad7b5ecc2bbbbd2f053c05cb55a1ac30449527d819bbfbf0e3de1023db308cbcb47f86601 + checksum: 915acf859cea7131dac1b2b5c9c8e35c4849e325a1d114c30adb8cd615970f6dca0e27f64f3a4949d7d6ed86ecd79a1c5c63f02e697513cddd7b5835c90948b8 languageName: node linkType: hard -"emojis-list@npm:^3.0.0": - version: 3.0.0 - resolution: "emojis-list@npm:3.0.0" - checksum: ddaaa02542e1e9436c03970eeed445f4ed29a5337dfba0fe0c38dfdd2af5da2429c2a0821304e8a8d1cadf27fdd5b22ff793571fa803ae16852a6975c65e8e70 +"enabled@npm:2.0.x": + version: 2.0.0 + resolution: "enabled@npm:2.0.0" + checksum: 9d256d89f4e8a46ff988c6a79b22fa814b4ffd82826c4fdacd9b42e9b9465709d3b748866d0ab4d442dfc6002d81de7f7b384146ccd1681f6a7f868d2acca063 languageName: node linkType: hard @@ -6890,7 +6871,7 @@ __metadata: version: 0.1.13 resolution: "encoding@npm:0.1.13" dependencies: - iconv-lite: ^0.6.2 + iconv-lite: "npm:^0.6.2" checksum: bb98632f8ffa823996e508ce6a58ffcf5856330fde839ae42c9e1f436cc3b5cc651d4aeae72222916545428e54fd0f6aa8862fd8d25bdbcc4589f1e3f3715e7f languageName: node linkType: hard @@ -6899,29 +6880,22 @@ __metadata: version: 1.4.4 resolution: "end-of-stream@npm:1.4.4" dependencies: - once: ^1.4.0 + once: "npm:^1.4.0" checksum: 530a5a5a1e517e962854a31693dbb5c0b2fc40b46dad2a56a2deec656ca040631124f4795823acc68238147805f8b021abbe221f4afed5ef3c8e8efc2024908b languageName: node linkType: hard -"entities@npm:^1.1.1": - version: 1.1.2 - resolution: "entities@npm:1.1.2" - checksum: d537b02799bdd4784ffd714d000597ed168727bddf4885da887c5a491d735739029a00794f1998abbf35f3f6aeda32ef5c15010dca1817d401903a501b6d3e05 - languageName: node - linkType: hard - "entities@npm:^2.0.0": version: 2.2.0 resolution: "entities@npm:2.2.0" - checksum: 19010dacaf0912c895ea262b4f6128574f9ccf8d4b3b65c7e8334ad0079b3706376360e28d8843ff50a78aabcb8f08f0a32dbfacdc77e47ed77ca08b713669b3 + checksum: 2c765221ee324dbe25e1b8ca5d1bf2a4d39e750548f2e85cbf7ca1d167d709689ddf1796623e66666ae747364c11ed512c03b48c5bbe70968d30f2a4009509b7 languageName: node linkType: hard "entities@npm:^4.2.0, entities@npm:^4.4.0, entities@npm:^4.5.0": version: 4.5.0 resolution: "entities@npm:4.5.0" - checksum: 853f8ebd5b425d350bffa97dd6958143179a5938352ccae092c62d1267c4e392a039be1bae7d51b6e4ffad25f51f9617531fedf5237f15df302ccfb452cbf2d7 + checksum: ede2a35c9bce1aeccd055a1b445d41c75a14a2bb1cd22e242f20cf04d236cdcd7f9c859eb83f76885327bfae0c25bf03303665ee1ce3d47c5927b98b0e3e3d48 languageName: node linkType: hard @@ -6937,14 +6911,14 @@ __metadata: resolution: "envinfo@npm:7.11.1" bin: envinfo: dist/cli.js - checksum: f3d38ab6bc62388466e86e2f5665f90f238ca349c81bb36b311d908cb5ca96650569b43b308c9dcb6725a222693f6c43a704794e74a68fb445ec5575a90ca05e + checksum: 5a18ead05954ac1643350170fefce2436a9cb758dc402e36fe4616553ee46469f766fcb6df72379d1741a2e5b55918949b343ff6174502c31c524a5cf75f05cd languageName: node linkType: hard "err-code@npm:^2.0.2": version: 2.0.3 resolution: "err-code@npm:2.0.3" - checksum: 8b7b1be20d2de12d2255c0bc2ca638b7af5171142693299416e6a9339bd7d88fc8d7707d913d78e0993176005405a236b066b45666b27b797252c771156ace54 + checksum: 1d20d825cdcce8d811bfbe86340f4755c02655a7feb2f13f8c880566d9d72a3f6c92c192a6867632e490d6da67b678271f46e01044996a6443e870331100dfdd languageName: node linkType: hard @@ -6952,8 +6926,8 @@ __metadata: version: 1.3.2 resolution: "error-ex@npm:1.3.2" dependencies: - is-arrayish: ^0.2.1 - checksum: c1c2b8b65f9c91b0f9d75f0debaa7ec5b35c266c2cac5de412c1a6de86d4cbae04ae44e510378cb14d032d0645a36925d0186f8bb7367bcc629db256b743a001 + is-arrayish: "npm:^0.2.1" + checksum: d547740aa29c34e753fb6fed2c5de81802438529c12b3673bd37b6bb1fe49b9b7abdc3c11e6062fe625d8a296b3cf769a80f878865e25e685f787763eede3ffb languageName: node linkType: hard @@ -6961,55 +6935,55 @@ __metadata: version: 1.22.4 resolution: "es-abstract@npm:1.22.4" dependencies: - array-buffer-byte-length: ^1.0.1 - arraybuffer.prototype.slice: ^1.0.3 - available-typed-arrays: ^1.0.6 - call-bind: ^1.0.7 - es-define-property: ^1.0.0 - es-errors: ^1.3.0 - es-set-tostringtag: ^2.0.2 - es-to-primitive: ^1.2.1 - function.prototype.name: ^1.1.6 - get-intrinsic: ^1.2.4 - get-symbol-description: ^1.0.2 - globalthis: ^1.0.3 - gopd: ^1.0.1 - has-property-descriptors: ^1.0.2 - has-proto: ^1.0.1 - has-symbols: ^1.0.3 - hasown: ^2.0.1 - internal-slot: ^1.0.7 - is-array-buffer: ^3.0.4 - is-callable: ^1.2.7 - is-negative-zero: ^2.0.2 - is-regex: ^1.1.4 - is-shared-array-buffer: ^1.0.2 - is-string: ^1.0.7 - is-typed-array: ^1.1.13 - is-weakref: ^1.0.2 - object-inspect: ^1.13.1 - object-keys: ^1.1.1 - object.assign: ^4.1.5 - regexp.prototype.flags: ^1.5.2 - safe-array-concat: ^1.1.0 - safe-regex-test: ^1.0.3 - string.prototype.trim: ^1.2.8 - string.prototype.trimend: ^1.0.7 - string.prototype.trimstart: ^1.0.7 - typed-array-buffer: ^1.0.1 - typed-array-byte-length: ^1.0.0 - typed-array-byte-offset: ^1.0.0 - typed-array-length: ^1.0.4 - unbox-primitive: ^1.0.2 - which-typed-array: ^1.1.14 - checksum: c254102395bd59315b713d72a1ce07980c0f71c9edcac6b036868740789ab5344020e940d6321fc1b31aecf6b27941fdd9655b602696e08f170986dd4d75ddc6 + array-buffer-byte-length: "npm:^1.0.1" + arraybuffer.prototype.slice: "npm:^1.0.3" + available-typed-arrays: "npm:^1.0.6" + call-bind: "npm:^1.0.7" + es-define-property: "npm:^1.0.0" + es-errors: "npm:^1.3.0" + es-set-tostringtag: "npm:^2.0.2" + es-to-primitive: "npm:^1.2.1" + function.prototype.name: "npm:^1.1.6" + get-intrinsic: "npm:^1.2.4" + get-symbol-description: "npm:^1.0.2" + globalthis: "npm:^1.0.3" + gopd: "npm:^1.0.1" + has-property-descriptors: "npm:^1.0.2" + has-proto: "npm:^1.0.1" + has-symbols: "npm:^1.0.3" + hasown: "npm:^2.0.1" + internal-slot: "npm:^1.0.7" + is-array-buffer: "npm:^3.0.4" + is-callable: "npm:^1.2.7" + is-negative-zero: "npm:^2.0.2" + is-regex: "npm:^1.1.4" + is-shared-array-buffer: "npm:^1.0.2" + is-string: "npm:^1.0.7" + is-typed-array: "npm:^1.1.13" + is-weakref: "npm:^1.0.2" + object-inspect: "npm:^1.13.1" + object-keys: "npm:^1.1.1" + object.assign: "npm:^4.1.5" + regexp.prototype.flags: "npm:^1.5.2" + safe-array-concat: "npm:^1.1.0" + safe-regex-test: "npm:^1.0.3" + string.prototype.trim: "npm:^1.2.8" + string.prototype.trimend: "npm:^1.0.7" + string.prototype.trimstart: "npm:^1.0.7" + typed-array-buffer: "npm:^1.0.1" + typed-array-byte-length: "npm:^1.0.0" + typed-array-byte-offset: "npm:^1.0.0" + typed-array-length: "npm:^1.0.4" + unbox-primitive: "npm:^1.0.2" + which-typed-array: "npm:^1.1.14" + checksum: 062e562a000e280c0c0683ad4a7b81732f97463bc769110c668a8edb739cd5df56975fa55965f5304a3256fd6eee03b9b66a47d863076f8976c2050731946b1f languageName: node linkType: hard "es-array-method-boxes-properly@npm:^1.0.0": version: 1.0.0 resolution: "es-array-method-boxes-properly@npm:1.0.0" - checksum: 2537fcd1cecf187083890bc6f5236d3a26bf39237433587e5bf63392e88faae929dbba78ff0120681a3f6f81c23fe3816122982c160d63b38c95c830b633b826 + checksum: 27a8a21acf20f3f51f69dce8e643f151e380bffe569e95dc933b9ded9fcd89a765ee21b5229c93f9206c93f87395c6b75f80be8ac8c08a7ceb8771e1822ff1fb languageName: node linkType: hard @@ -7017,7 +6991,7 @@ __metadata: version: 1.0.0 resolution: "es-define-property@npm:1.0.0" dependencies: - get-intrinsic: ^1.2.4 + get-intrinsic: "npm:^1.2.4" checksum: f66ece0a887b6dca71848fa71f70461357c0e4e7249696f81bad0a1f347eed7b31262af4a29f5d726dc026426f085483b6b90301855e647aa8e21936f07293c6 languageName: node linkType: hard @@ -7025,7 +6999,7 @@ __metadata: "es-errors@npm:^1.0.0, es-errors@npm:^1.1.0, es-errors@npm:^1.2.1, es-errors@npm:^1.3.0": version: 1.3.0 resolution: "es-errors@npm:1.3.0" - checksum: ec1414527a0ccacd7f15f4a3bc66e215f04f595ba23ca75cdae0927af099b5ec865f9f4d33e9d7e86f512f252876ac77d4281a7871531a50678132429b1271b5 + checksum: 96e65d640156f91b707517e8cdc454dd7d47c32833aa3e85d79f24f9eb7ea85f39b63e36216ef0114996581969b59fe609a94e30316b08f5f4df1d44134cf8d5 languageName: node linkType: hard @@ -7033,16 +7007,16 @@ __metadata: version: 1.1.3 resolution: "es-get-iterator@npm:1.1.3" dependencies: - call-bind: ^1.0.2 - get-intrinsic: ^1.1.3 - has-symbols: ^1.0.3 - is-arguments: ^1.1.1 - is-map: ^2.0.2 - is-set: ^2.0.2 - is-string: ^1.0.7 - isarray: ^2.0.5 - stop-iteration-iterator: ^1.0.0 - checksum: 8fa118da42667a01a7c7529f8a8cca514feeff243feec1ce0bb73baaa3514560bd09d2b3438873cf8a5aaec5d52da248131de153b28e2638a061b6e4df13267d + call-bind: "npm:^1.0.2" + get-intrinsic: "npm:^1.1.3" + has-symbols: "npm:^1.0.3" + is-arguments: "npm:^1.1.1" + is-map: "npm:^2.0.2" + is-set: "npm:^2.0.2" + is-string: "npm:^1.0.7" + isarray: "npm:^2.0.5" + stop-iteration-iterator: "npm:^1.0.0" + checksum: bc2194befbe55725f9489098626479deee3c801eda7e83ce0dff2eb266a28dc808edb9b623ff01d31ebc1328f09d661333d86b601036692c2e3c1a6942319433 languageName: node linkType: hard @@ -7050,29 +7024,29 @@ __metadata: version: 1.0.17 resolution: "es-iterator-helpers@npm:1.0.17" dependencies: - asynciterator.prototype: ^1.0.0 - call-bind: ^1.0.7 - define-properties: ^1.2.1 - es-abstract: ^1.22.4 - es-errors: ^1.3.0 - es-set-tostringtag: ^2.0.2 - function-bind: ^1.1.2 - get-intrinsic: ^1.2.4 - globalthis: ^1.0.3 - has-property-descriptors: ^1.0.2 - has-proto: ^1.0.1 - has-symbols: ^1.0.3 - internal-slot: ^1.0.7 - iterator.prototype: ^1.1.2 - safe-array-concat: ^1.1.0 - checksum: f0962abbf120c37516c9008716fcaffeacf7bc6147a07e63cda3c3ac8be94b88e4ef8d71234c4b8873d1fc209f65c6d9e11a7faac78f59b5d3bcfa399affed7b + asynciterator.prototype: "npm:^1.0.0" + call-bind: "npm:^1.0.7" + define-properties: "npm:^1.2.1" + es-abstract: "npm:^1.22.4" + es-errors: "npm:^1.3.0" + es-set-tostringtag: "npm:^2.0.2" + function-bind: "npm:^1.1.2" + get-intrinsic: "npm:^1.2.4" + globalthis: "npm:^1.0.3" + has-property-descriptors: "npm:^1.0.2" + has-proto: "npm:^1.0.1" + has-symbols: "npm:^1.0.3" + internal-slot: "npm:^1.0.7" + iterator.prototype: "npm:^1.1.2" + safe-array-concat: "npm:^1.1.0" + checksum: 42c6eb65368d34b556dac1cc8d34ba753eb526bc7d4594be3b77799440be78d31fddfd60717af2d9ce6d021de8346e7a573141d789821e38836e60441f93ccfd languageName: node linkType: hard "es-module-lexer@npm:^0.9.3": version: 0.9.3 resolution: "es-module-lexer@npm:0.9.3" - checksum: 84bbab23c396281db2c906c766af58b1ae2a1a2599844a504df10b9e8dc77ec800b3211fdaa133ff700f5703d791198807bba25d9667392d27a5e9feda344da8 + checksum: c3e39465d06a6ecd103ccdb746508c88ee4bdd56c15238b0013de38b949a4eca91d5e44d2a9b88d772fe7821547c5fe9200ba0f3353116e208d44bb50c7bc1ea languageName: node linkType: hard @@ -7080,9 +7054,9 @@ __metadata: version: 2.0.3 resolution: "es-set-tostringtag@npm:2.0.3" dependencies: - get-intrinsic: ^1.2.4 - has-tostringtag: ^1.0.2 - hasown: ^2.0.1 + get-intrinsic: "npm:^1.2.4" + has-tostringtag: "npm:^1.0.2" + hasown: "npm:^2.0.1" checksum: 7227fa48a41c0ce83e0377b11130d324ac797390688135b8da5c28994c0165be8b252e15cd1de41e1325e5a5412511586960213e88f9ab4a5e7d028895db5129 languageName: node linkType: hard @@ -7091,8 +7065,8 @@ __metadata: version: 1.0.2 resolution: "es-shim-unscopables@npm:1.0.2" dependencies: - hasown: ^2.0.0 - checksum: 432bd527c62065da09ed1d37a3f8e623c423683285e6188108286f4a1e8e164a5bcbfbc0051557c7d14633cd2a41ce24c7048e6bbb66a985413fd32f1be72626 + hasown: "npm:^2.0.0" + checksum: 6d3bf91f658a27cc7217cd32b407a0d714393a84d125ad576319b9e83a893bea165cf41270c29e9ceaa56d3cf41608945d7e2a2c31fd51c0009b0c31402b91c7 languageName: node linkType: hard @@ -7100,10 +7074,10 @@ __metadata: version: 1.2.1 resolution: "es-to-primitive@npm:1.2.1" dependencies: - is-callable: ^1.1.4 - is-date-object: ^1.0.1 - is-symbol: ^1.0.2 - checksum: 4ead6671a2c1402619bdd77f3503991232ca15e17e46222b0a41a5d81aebc8740a77822f5b3c965008e631153e9ef0580540007744521e72de8e33599fca2eed + is-callable: "npm:^1.1.4" + is-date-object: "npm:^1.0.1" + is-symbol: "npm:^1.0.2" + checksum: 74aeeefe2714cf99bb40cab7ce3012d74e1e2c1bd60d0a913b467b269edde6e176ca644b5ba03a5b865fb044a29bca05671cd445c85ca2cdc2de155d7fc8fe9b languageName: node linkType: hard @@ -7118,10 +7092,10 @@ __metadata: version: 3.5.0 resolution: "esbuild-register@npm:3.5.0" dependencies: - debug: ^4.3.4 + debug: "npm:^4.3.4" peerDependencies: esbuild: ">=0.12 <1" - checksum: f4307753c9672a2c901d04a1165031594a854f0a4c6f4c1db08aa393b68a193d38f2df483dc8ca0513e89f7b8998415e7e26fb9830989fb8cdccc5fb5f181c6b + checksum: af6874ce9b5fcdb0974c9d9e9f16530a5b9bd80c699b2ba9d7ace33439c1af1be6948535c775d9a6439e2bf23fb31cfd54ac882cfa38308a3f182039f4b98a01 languageName: node linkType: hard @@ -7129,28 +7103,28 @@ __metadata: version: 0.18.20 resolution: "esbuild@npm:0.18.20" dependencies: - "@esbuild/android-arm": 0.18.20 - "@esbuild/android-arm64": 0.18.20 - "@esbuild/android-x64": 0.18.20 - "@esbuild/darwin-arm64": 0.18.20 - "@esbuild/darwin-x64": 0.18.20 - "@esbuild/freebsd-arm64": 0.18.20 - "@esbuild/freebsd-x64": 0.18.20 - "@esbuild/linux-arm": 0.18.20 - "@esbuild/linux-arm64": 0.18.20 - "@esbuild/linux-ia32": 0.18.20 - "@esbuild/linux-loong64": 0.18.20 - "@esbuild/linux-mips64el": 0.18.20 - "@esbuild/linux-ppc64": 0.18.20 - "@esbuild/linux-riscv64": 0.18.20 - "@esbuild/linux-s390x": 0.18.20 - "@esbuild/linux-x64": 0.18.20 - "@esbuild/netbsd-x64": 0.18.20 - "@esbuild/openbsd-x64": 0.18.20 - "@esbuild/sunos-x64": 0.18.20 - "@esbuild/win32-arm64": 0.18.20 - "@esbuild/win32-ia32": 0.18.20 - "@esbuild/win32-x64": 0.18.20 + "@esbuild/android-arm": "npm:0.18.20" + "@esbuild/android-arm64": "npm:0.18.20" + "@esbuild/android-x64": "npm:0.18.20" + "@esbuild/darwin-arm64": "npm:0.18.20" + "@esbuild/darwin-x64": "npm:0.18.20" + "@esbuild/freebsd-arm64": "npm:0.18.20" + "@esbuild/freebsd-x64": "npm:0.18.20" + "@esbuild/linux-arm": "npm:0.18.20" + "@esbuild/linux-arm64": "npm:0.18.20" + "@esbuild/linux-ia32": "npm:0.18.20" + "@esbuild/linux-loong64": "npm:0.18.20" + "@esbuild/linux-mips64el": "npm:0.18.20" + "@esbuild/linux-ppc64": "npm:0.18.20" + "@esbuild/linux-riscv64": "npm:0.18.20" + "@esbuild/linux-s390x": "npm:0.18.20" + "@esbuild/linux-x64": "npm:0.18.20" + "@esbuild/netbsd-x64": "npm:0.18.20" + "@esbuild/openbsd-x64": "npm:0.18.20" + "@esbuild/sunos-x64": "npm:0.18.20" + "@esbuild/win32-arm64": "npm:0.18.20" + "@esbuild/win32-ia32": "npm:0.18.20" + "@esbuild/win32-x64": "npm:0.18.20" dependenciesMeta: "@esbuild/android-arm": optional: true @@ -7198,14 +7172,14 @@ __metadata: optional: true bin: esbuild: bin/esbuild - checksum: 5d253614e50cdb6ec22095afd0c414f15688e7278a7eb4f3720a6dd1306b0909cf431e7b9437a90d065a31b1c57be60130f63fe3e8d0083b588571f31ee6ec7b + checksum: 1f723ec71c3aa196473bf3298316eedc3f62d523924652dfeb60701b609792f918fc60db84b420d1d8ba9bfa7d69de2fc1d3157ba47c028bdae5d507a26a3c64 languageName: node linkType: hard "escalade@npm:^3.1.1": version: 3.1.2 resolution: "escalade@npm:3.1.2" - checksum: 1ec0977aa2772075493002bdbd549d595ff6e9393b1cb0d7d6fcaf78c750da0c158f180938365486f75cb69fba20294351caddfce1b46552a7b6c3cde52eaa02 + checksum: a1e07fea2f15663c30e40b9193d658397846ffe28ce0a3e4da0d8e485fedfeca228ab846aee101a05015829adf39f9934ff45b2a3fca47bed37a29646bd05cd3 languageName: node linkType: hard @@ -7216,7 +7190,7 @@ __metadata: languageName: node linkType: hard -"escape-string-regexp@npm:1.0.5, escape-string-regexp@npm:^1.0.2, escape-string-regexp@npm:^1.0.5": +"escape-string-regexp@npm:^1.0.5": version: 1.0.5 resolution: "escape-string-regexp@npm:1.0.5" checksum: 6092fda75c63b110c706b6a9bfde8a612ad595b628f0bd2147eea1d3406723020810e591effc7db1da91d80a71a737a313567c5abb3813e8d9c71f4aa595b410 @@ -7241,17 +7215,17 @@ __metadata: version: 2.1.0 resolution: "escodegen@npm:2.1.0" dependencies: - esprima: ^4.0.1 - estraverse: ^5.2.0 - esutils: ^2.0.2 - source-map: ~0.6.1 + esprima: "npm:^4.0.1" + estraverse: "npm:^5.2.0" + esutils: "npm:^2.0.2" + source-map: "npm:~0.6.1" dependenciesMeta: source-map: optional: true bin: escodegen: bin/escodegen.js esgenerate: bin/esgenerate.js - checksum: 096696407e161305cd05aebb95134ad176708bc5cb13d0dcc89a5fcbb959b8ed757e7f2591a5f8036f8f4952d4a724de0df14cd419e29212729fa6df5ce16bf6 + checksum: 47719a65b2888b4586e3fa93769068b275961c13089e90d5d01a96a6e8e95871b1c3893576814c8fbf08a4a31a496f37e7b2c937cf231270f4d81de012832c7c languageName: node linkType: hard @@ -7262,7 +7236,7 @@ __metadata: eslint: ">=7.0.0" bin: eslint-config-prettier: bin/cli.js - checksum: 9229b768c879f500ee54ca05925f31b0c0bafff3d9f5521f98ff05127356de78c81deb9365c86a5ec4efa990cb72b74df8612ae15965b14136044c73e1f6a907 + checksum: 411e3b3b1c7aa04e3e0f20d561271b3b909014956c4dba51c878bf1a23dbb8c800a3be235c46c4732c70827276e540b6eed4636d9b09b444fd0a8e07f0fcd830 languageName: node linkType: hard @@ -7279,10 +7253,10 @@ __metadata: version: 0.3.9 resolution: "eslint-import-resolver-node@npm:0.3.9" dependencies: - debug: ^3.2.7 - is-core-module: ^2.13.0 - resolve: ^1.22.4 - checksum: 439b91271236b452d478d0522a44482e8c8540bf9df9bd744062ebb89ab45727a3acd03366a6ba2bdbcde8f9f718bab7fe8db64688aca75acf37e04eafd25e22 + debug: "npm:^3.2.7" + is-core-module: "npm:^2.13.0" + resolve: "npm:^1.22.4" + checksum: d52e08e1d96cf630957272e4f2644dcfb531e49dcfd1edd2e07e43369eb2ec7a7d4423d417beee613201206ff2efa4eb9a582b5825ee28802fc7c71fcd53ca83 languageName: node linkType: hard @@ -7290,11 +7264,11 @@ __metadata: version: 2.8.1 resolution: "eslint-module-utils@npm:2.8.1" dependencies: - debug: ^3.2.7 + debug: "npm:^3.2.7" peerDependenciesMeta: eslint: optional: true - checksum: 3cecd99b6baf45ffc269167da0f95dcb75e5aa67b93d73a3bab63e2a7eedd9cdd6f188eed048e2f57c1b77db82c9cbf2adac20b512fa70e597d863dd3720170d + checksum: 3e7892c0a984c963632da56b30ccf8254c29b535467138f91086c2ecdb2ebd10e2be61b54e553f30e5abf1d14d47a7baa0dac890e3a658fd3cd07dca63afbe6d languageName: node linkType: hard @@ -7302,26 +7276,26 @@ __metadata: version: 2.29.1 resolution: "eslint-plugin-import@npm:2.29.1" dependencies: - array-includes: ^3.1.7 - array.prototype.findlastindex: ^1.2.3 - array.prototype.flat: ^1.3.2 - array.prototype.flatmap: ^1.3.2 - debug: ^3.2.7 - doctrine: ^2.1.0 - eslint-import-resolver-node: ^0.3.9 - eslint-module-utils: ^2.8.0 - hasown: ^2.0.0 - is-core-module: ^2.13.1 - is-glob: ^4.0.3 - minimatch: ^3.1.2 - object.fromentries: ^2.0.7 - object.groupby: ^1.0.1 - object.values: ^1.1.7 - semver: ^6.3.1 - tsconfig-paths: ^3.15.0 + array-includes: "npm:^3.1.7" + array.prototype.findlastindex: "npm:^1.2.3" + array.prototype.flat: "npm:^1.3.2" + array.prototype.flatmap: "npm:^1.3.2" + debug: "npm:^3.2.7" + doctrine: "npm:^2.1.0" + eslint-import-resolver-node: "npm:^0.3.9" + eslint-module-utils: "npm:^2.8.0" + hasown: "npm:^2.0.0" + is-core-module: "npm:^2.13.1" + is-glob: "npm:^4.0.3" + minimatch: "npm:^3.1.2" + object.fromentries: "npm:^2.0.7" + object.groupby: "npm:^1.0.1" + object.values: "npm:^1.1.7" + semver: "npm:^6.3.1" + tsconfig-paths: "npm:^3.15.0" peerDependencies: eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 - checksum: e65159aef808136d26d029b71c8c6e4cb5c628e65e5de77f1eb4c13a379315ae55c9c3afa847f43f4ff9df7e54515c77ffc6489c6a6f81f7dd7359267577468c + checksum: 5865f05c38552145423c535326ec9a7113ab2305c7614c8b896ff905cfabc859c8805cac21e979c9f6f742afa333e6f62f812eabf891a7e8f5f0b853a32593c1 languageName: node linkType: hard @@ -7329,25 +7303,25 @@ __metadata: version: 6.8.0 resolution: "eslint-plugin-jsx-a11y@npm:6.8.0" dependencies: - "@babel/runtime": ^7.23.2 - aria-query: ^5.3.0 - array-includes: ^3.1.7 - array.prototype.flatmap: ^1.3.2 - ast-types-flow: ^0.0.8 - axe-core: =4.7.0 - axobject-query: ^3.2.1 - damerau-levenshtein: ^1.0.8 - emoji-regex: ^9.2.2 - es-iterator-helpers: ^1.0.15 - hasown: ^2.0.0 - jsx-ast-utils: ^3.3.5 - language-tags: ^1.0.9 - minimatch: ^3.1.2 - object.entries: ^1.1.7 - object.fromentries: ^2.0.7 + "@babel/runtime": "npm:^7.23.2" + aria-query: "npm:^5.3.0" + array-includes: "npm:^3.1.7" + array.prototype.flatmap: "npm:^1.3.2" + ast-types-flow: "npm:^0.0.8" + axe-core: "npm:=4.7.0" + axobject-query: "npm:^3.2.1" + damerau-levenshtein: "npm:^1.0.8" + emoji-regex: "npm:^9.2.2" + es-iterator-helpers: "npm:^1.0.15" + hasown: "npm:^2.0.0" + jsx-ast-utils: "npm:^3.3.5" + language-tags: "npm:^1.0.9" + minimatch: "npm:^3.1.2" + object.entries: "npm:^1.1.7" + object.fromentries: "npm:^2.0.7" peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 - checksum: 3dec00e2a3089c4c61ac062e4196a70985fb7eda1fd67fe035363d92578debde92fdb8ed2e472321fc0d71e75f4a1e8888c6a3218c14dd93c8e8d19eb6f51554 + checksum: 7a8e4498531a43d988ce2f12502a3f5ce96eacfec13f956cf927f24bb041b724fb7fc0f0306ea19d143bfc79e138bf25e25acca0822847206ac6bf5ce095e846 languageName: node linkType: hard @@ -7355,8 +7329,8 @@ __metadata: version: 5.1.3 resolution: "eslint-plugin-prettier@npm:5.1.3" dependencies: - prettier-linter-helpers: ^1.0.0 - synckit: ^0.8.6 + prettier-linter-helpers: "npm:^1.0.0" + synckit: "npm:^0.8.6" peerDependencies: "@types/eslint": ">=8.0.0" eslint: ">=8.0.0" @@ -7367,7 +7341,7 @@ __metadata: optional: true eslint-config-prettier: optional: true - checksum: eb2a7d46a1887e1b93788ee8f8eb81e0b6b2a6f5a66a62bc6f375b033fc4e7ca16448da99380be800042786e76cf5c0df9c87a51a2c9b960ed47acbd7c0b9381 + checksum: 4f26a30444adc61ed692cdb5a9f7e8d9f5794f0917151051e66755ce032a08c3cc72c8b5d56101412e90f6d77035bd8194ea8731e9c16aacdd5ae345a8dae188 languageName: node linkType: hard @@ -7376,7 +7350,7 @@ __metadata: resolution: "eslint-plugin-react-hooks@npm:4.6.0" peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 - checksum: 23001801f14c1d16bf0a837ca7970d9dd94e7b560384b41db378b49b6e32dc43d6e2790de1bd737a652a86f81a08d6a91f402525061b47719328f586a57e86c3 + checksum: 3c63134e056a6d98d66e2c475c81f904169db817e89316d14e36269919e31f4876a2588aa0e466ec8ef160465169c627fe823bfdaae7e213946584e4a165a3ac languageName: node linkType: hard @@ -7385,7 +7359,7 @@ __metadata: resolution: "eslint-plugin-react-refresh@npm:0.4.5" peerDependencies: eslint: ">=7" - checksum: 953e665f6aaf097291a2bb07fde05466338aecd169bcd6faa708b50166912e3a757f45a45252cf7738b5e0d986224d363cc227864e98c979fe9978770b2b9f42 + checksum: f1526f55829f7eb4d9031fa082cb967f0bc578e8e2a3dfb9e5a47fc31cb0785bfa58ae717157f57241f7086a8790a88a6ec82743eaa5ca392a6b0fdb379169f8 languageName: node linkType: hard @@ -7393,25 +7367,25 @@ __metadata: version: 7.33.2 resolution: "eslint-plugin-react@npm:7.33.2" dependencies: - array-includes: ^3.1.6 - array.prototype.flatmap: ^1.3.1 - array.prototype.tosorted: ^1.1.1 - doctrine: ^2.1.0 - es-iterator-helpers: ^1.0.12 - estraverse: ^5.3.0 - jsx-ast-utils: ^2.4.1 || ^3.0.0 - minimatch: ^3.1.2 - object.entries: ^1.1.6 - object.fromentries: ^2.0.6 - object.hasown: ^1.1.2 - object.values: ^1.1.6 - prop-types: ^15.8.1 - resolve: ^2.0.0-next.4 - semver: ^6.3.1 - string.prototype.matchall: ^4.0.8 + array-includes: "npm:^3.1.6" + array.prototype.flatmap: "npm:^1.3.1" + array.prototype.tosorted: "npm:^1.1.1" + doctrine: "npm:^2.1.0" + es-iterator-helpers: "npm:^1.0.12" + estraverse: "npm:^5.3.0" + jsx-ast-utils: "npm:^2.4.1 || ^3.0.0" + minimatch: "npm:^3.1.2" + object.entries: "npm:^1.1.6" + object.fromentries: "npm:^2.0.6" + object.hasown: "npm:^1.1.2" + object.values: "npm:^1.1.6" + prop-types: "npm:^15.8.1" + resolve: "npm:^2.0.0-next.4" + semver: "npm:^6.3.1" + string.prototype.matchall: "npm:^4.0.8" peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 - checksum: b4c3d76390b0ae6b6f9fed78170604cc2c04b48e6778a637db339e8e3911ec9ef22510b0ae77c429698151d0f1b245f282177f384105b6830e7b29b9c9b26610 + checksum: cb8c5dd5859cace330e24b7d74b9c652c0d93ef1d87957261fe1ac2975c27c918d0d5dc607f25aba4972ce74d04456f4f93883a16ac10cd598680d047fc3495d languageName: node linkType: hard @@ -7419,12 +7393,12 @@ __metadata: version: 0.0.20 resolution: "eslint-plugin-spellcheck@npm:0.0.20" dependencies: - globals: ^13.0.0 - hunspell-spellchecker: ^1.0.2 - lodash: ^4.17.15 + globals: "npm:^13.0.0" + hunspell-spellchecker: "npm:^1.0.2" + lodash: "npm:^4.17.15" peerDependencies: eslint: ">=0.8.0" - checksum: 2ab85337774910de86586581f76bf10e40f83be4e0971d2f6f5a31b658ed3bb003528bedd25d70b07e2942e8418cd0009bf6eeab829eb7cbf2108e13afa83f35 + checksum: 073102a28b25f81f9ea0f4334dd0bf9fe23d3585039bae0706b7b1d7848601c3cf8d003f56932d7948745ba5290e748f9f62f93b4fca2fe0cfdb9508dab3e835 languageName: node linkType: hard @@ -7432,13 +7406,13 @@ __metadata: version: 0.8.0 resolution: "eslint-plugin-storybook@npm:0.8.0" dependencies: - "@storybook/csf": ^0.0.1 - "@typescript-eslint/utils": ^5.62.0 - requireindex: ^1.2.0 - ts-dedent: ^2.2.0 + "@storybook/csf": "npm:^0.0.1" + "@typescript-eslint/utils": "npm:^5.62.0" + requireindex: "npm:^1.2.0" + ts-dedent: "npm:^2.2.0" peerDependencies: eslint: ">=6" - checksum: 71e4b064259e09a6353360ca4a3ec929df0ea3aabe1dc83a40b9264fe5c16bcecb94d097e7403f6916622b8fdb739e91f1268bbad220d838fcbc2b9a901345ec + checksum: a66e6737298af9bb830e3b14cdbd204e589a38adb810f02d843849936ef9175a80a49c8b8fa9263f8c2b9a8f36fdd3a2d429382d8051568c58d6272c65c2f5d3 languageName: node linkType: hard @@ -7446,9 +7420,9 @@ __metadata: version: 5.1.1 resolution: "eslint-scope@npm:5.1.1" dependencies: - esrecurse: ^4.3.0 - estraverse: ^4.1.1 - checksum: 47e4b6a3f0cc29c7feedee6c67b225a2da7e155802c6ea13bbef4ac6b9e10c66cd2dcb987867ef176292bf4e64eccc680a49e35e9e9c669f4a02bac17e86abdb + esrecurse: "npm:^4.3.0" + estraverse: "npm:^4.1.1" + checksum: c541ef384c92eb5c999b7d3443d80195fcafb3da335500946f6db76539b87d5826c8f2e1d23bf6afc3154ba8cd7c8e566f8dc00f1eea25fdf3afc8fb9c87b238 languageName: node linkType: hard @@ -7456,16 +7430,16 @@ __metadata: version: 7.2.2 resolution: "eslint-scope@npm:7.2.2" dependencies: - esrecurse: ^4.3.0 - estraverse: ^5.2.0 - checksum: ec97dbf5fb04b94e8f4c5a91a7f0a6dd3c55e46bfc7bbcd0e3138c3a76977570e02ed89a1810c778dcd72072ff0e9621ba1379b4babe53921d71e2e4486fda3e + esrecurse: "npm:^4.3.0" + estraverse: "npm:^5.2.0" + checksum: 5c660fb905d5883ad018a6fea2b49f3cb5b1cbf2cd4bd08e98646e9864f9bc2c74c0839bed2d292e90a4a328833accc197c8f0baed89cbe8d605d6f918465491 languageName: node linkType: hard "eslint-visitor-keys@npm:^3.3.0, eslint-visitor-keys@npm:^3.4.1, eslint-visitor-keys@npm:^3.4.3": version: 3.4.3 resolution: "eslint-visitor-keys@npm:3.4.3" - checksum: 36e9ef87fca698b6fd7ca5ca35d7b2b6eeaaf106572e2f7fd31c12d3bfdaccdb587bba6d3621067e5aece31c8c3a348b93922ab8f7b2cbc6aaab5e1d89040c60 + checksum: 3f357c554a9ea794b094a09bd4187e5eacd1bc0d0653c3adeb87962c548e6a1ab8f982b86963ae1337f5d976004146536dcee5d0e2806665b193fbfbf1a9231b languageName: node linkType: hard @@ -7473,47 +7447,47 @@ __metadata: version: 8.57.0 resolution: "eslint@npm:8.57.0" dependencies: - "@eslint-community/eslint-utils": ^4.2.0 - "@eslint-community/regexpp": ^4.6.1 - "@eslint/eslintrc": ^2.1.4 - "@eslint/js": 8.57.0 - "@humanwhocodes/config-array": ^0.11.14 - "@humanwhocodes/module-importer": ^1.0.1 - "@nodelib/fs.walk": ^1.2.8 - "@ungap/structured-clone": ^1.2.0 - ajv: ^6.12.4 - chalk: ^4.0.0 - cross-spawn: ^7.0.2 - debug: ^4.3.2 - doctrine: ^3.0.0 - escape-string-regexp: ^4.0.0 - eslint-scope: ^7.2.2 - eslint-visitor-keys: ^3.4.3 - espree: ^9.6.1 - esquery: ^1.4.2 - esutils: ^2.0.2 - fast-deep-equal: ^3.1.3 - file-entry-cache: ^6.0.1 - find-up: ^5.0.0 - glob-parent: ^6.0.2 - globals: ^13.19.0 - graphemer: ^1.4.0 - ignore: ^5.2.0 - imurmurhash: ^0.1.4 - is-glob: ^4.0.0 - is-path-inside: ^3.0.3 - js-yaml: ^4.1.0 - json-stable-stringify-without-jsonify: ^1.0.1 - levn: ^0.4.1 - lodash.merge: ^4.6.2 - minimatch: ^3.1.2 - natural-compare: ^1.4.0 - optionator: ^0.9.3 - strip-ansi: ^6.0.1 - text-table: ^0.2.0 + "@eslint-community/eslint-utils": "npm:^4.2.0" + "@eslint-community/regexpp": "npm:^4.6.1" + "@eslint/eslintrc": "npm:^2.1.4" + "@eslint/js": "npm:8.57.0" + "@humanwhocodes/config-array": "npm:^0.11.14" + "@humanwhocodes/module-importer": "npm:^1.0.1" + "@nodelib/fs.walk": "npm:^1.2.8" + "@ungap/structured-clone": "npm:^1.2.0" + ajv: "npm:^6.12.4" + chalk: "npm:^4.0.0" + cross-spawn: "npm:^7.0.2" + debug: "npm:^4.3.2" + doctrine: "npm:^3.0.0" + escape-string-regexp: "npm:^4.0.0" + eslint-scope: "npm:^7.2.2" + eslint-visitor-keys: "npm:^3.4.3" + espree: "npm:^9.6.1" + esquery: "npm:^1.4.2" + esutils: "npm:^2.0.2" + fast-deep-equal: "npm:^3.1.3" + file-entry-cache: "npm:^6.0.1" + find-up: "npm:^5.0.0" + glob-parent: "npm:^6.0.2" + globals: "npm:^13.19.0" + graphemer: "npm:^1.4.0" + ignore: "npm:^5.2.0" + imurmurhash: "npm:^0.1.4" + is-glob: "npm:^4.0.0" + is-path-inside: "npm:^3.0.3" + js-yaml: "npm:^4.1.0" + json-stable-stringify-without-jsonify: "npm:^1.0.1" + levn: "npm:^0.4.1" + lodash.merge: "npm:^4.6.2" + minimatch: "npm:^3.1.2" + natural-compare: "npm:^1.4.0" + optionator: "npm:^0.9.3" + strip-ansi: "npm:^6.0.1" + text-table: "npm:^0.2.0" bin: eslint: bin/eslint.js - checksum: 3a48d7ff85ab420a8447e9810d8087aea5b1df9ef68c9151732b478de698389ee656fd895635b5f2871c89ee5a2652b3f343d11e9db6f8486880374ebc74a2d9 + checksum: 00496e218b23747a7a9817bf58b522276d0dc1f2e546dceb4eea49f9871574088f72f1f069a6b560ef537efa3a75261b8ef70e51ef19033da1cc4c86a755ef15 languageName: node linkType: hard @@ -7521,10 +7495,10 @@ __metadata: version: 9.6.1 resolution: "espree@npm:9.6.1" dependencies: - acorn: ^8.9.0 - acorn-jsx: ^5.3.2 - eslint-visitor-keys: ^3.4.1 - checksum: eb8c149c7a2a77b3f33a5af80c10875c3abd65450f60b8af6db1bfcfa8f101e21c1e56a561c6dc13b848e18148d43469e7cd208506238554fb5395a9ea5a1ab9 + acorn: "npm:^8.9.0" + acorn-jsx: "npm:^5.3.2" + eslint-visitor-keys: "npm:^3.4.1" + checksum: 255ab260f0d711a54096bdeda93adff0eadf02a6f9b92f02b323e83a2b7fc258797919437ad331efec3930475feb0142c5ecaaf3cdab4befebd336d47d3f3134 languageName: node linkType: hard @@ -7534,7 +7508,7 @@ __metadata: bin: esparse: ./bin/esparse.js esvalidate: ./bin/esvalidate.js - checksum: b45bc805a613dbea2835278c306b91aff6173c8d034223fa81498c77dcbce3b2931bf6006db816f62eacd9fd4ea975dfd85a5b7f3c6402cfd050d4ca3c13a628 + checksum: f1d3c622ad992421362294f7acf866aa9409fbad4eb2e8fa230bd33944ce371d32279667b242d8b8907ec2b6ad7353a717f3c0e60e748873a34a7905174bc0eb languageName: node linkType: hard @@ -7542,8 +7516,8 @@ __metadata: version: 1.5.0 resolution: "esquery@npm:1.5.0" dependencies: - estraverse: ^5.1.0 - checksum: aefb0d2596c230118656cd4ec7532d447333a410a48834d80ea648b1e7b5c9bc9ed8b5e33a89cb04e487b60d622f44cf5713bf4abed7c97343edefdc84a35900 + estraverse: "npm:^5.1.0" + checksum: e65fcdfc1e0ff5effbf50fb4f31ea20143ae5df92bb2e4953653d8d40aa4bc148e0d06117a592ce4ea53eeab1dafdfded7ea7e22a5be87e82d73757329a1b01d languageName: node linkType: hard @@ -7551,36 +7525,36 @@ __metadata: version: 4.3.0 resolution: "esrecurse@npm:4.3.0" dependencies: - estraverse: ^5.2.0 - checksum: ebc17b1a33c51cef46fdc28b958994b1dc43cd2e86237515cbc3b4e5d2be6a811b2315d0a1a4d9d340b6d2308b15322f5c8291059521cc5f4802f65e7ec32837 + estraverse: "npm:^5.2.0" + checksum: 44ffcd89e714ea6b30143e7f119b104fc4d75e77ee913f34d59076b40ef2d21967f84e019f84e1fd0465b42cdbf725db449f232b5e47f29df29ed76194db8e16 languageName: node linkType: hard "estraverse@npm:^4.1.1": version: 4.3.0 resolution: "estraverse@npm:4.3.0" - checksum: a6299491f9940bb246124a8d44b7b7a413a8336f5436f9837aaa9330209bd9ee8af7e91a654a3545aee9c54b3308e78ee360cef1d777d37cfef77d2fa33b5827 + checksum: 3f67ad02b6dbfaddd9ea459cf2b6ef4ecff9a6082a7af9d22e445b9abc082ad9ca47e1825557b293fcdae477f4714e561123e30bb6a5b2f184fb2bad4a9497eb languageName: node linkType: hard "estraverse@npm:^5.1.0, estraverse@npm:^5.2.0, estraverse@npm:^5.3.0": version: 5.3.0 resolution: "estraverse@npm:5.3.0" - checksum: 072780882dc8416ad144f8fe199628d2b3e7bbc9989d9ed43795d2c90309a2047e6bc5979d7e2322a341163d22cfad9e21f4110597fe487519697389497e4e2b + checksum: 37cbe6e9a68014d34dbdc039f90d0baf72436809d02edffcc06ba3c2a12eb298048f877511353b130153e532aac8d68ba78430c0dd2f44806ebc7c014b01585e languageName: node linkType: hard "estree-walker@npm:^2.0.1, estree-walker@npm:^2.0.2": version: 2.0.2 resolution: "estree-walker@npm:2.0.2" - checksum: 6151e6f9828abe2259e57f5fd3761335bb0d2ebd76dc1a01048ccee22fabcfef3c0859300f6d83ff0d1927849368775ec5a6d265dde2f6de5a1be1721cd94efc + checksum: b02109c5d46bc2ed47de4990eef770f7457b1159a229f0999a09224d2b85ffeed2d7679cffcff90aeb4448e94b0168feb5265b209cdec29aad50a3d6e93d21e2 languageName: node linkType: hard "esutils@npm:^2.0.2": version: 2.0.3 resolution: "esutils@npm:2.0.3" - checksum: 22b5b08f74737379a840b8ed2036a5fb35826c709ab000683b092d9054e5c2a82c27818f12604bfc2a9a76b90b6834ef081edbc1c7ae30d1627012e067c6ec87 + checksum: b23acd24791db11d8f65be5ea58fd9a6ce2df5120ae2da65c16cfc5331ff59d5ac4ef50af66cd4bde238881503ec839928a0135b99a036a9cdfa22d17fd56cdb languageName: node linkType: hard @@ -7595,16 +7569,16 @@ __metadata: version: 5.1.1 resolution: "execa@npm:5.1.1" dependencies: - cross-spawn: ^7.0.3 - get-stream: ^6.0.0 - human-signals: ^2.1.0 - is-stream: ^2.0.0 - merge-stream: ^2.0.0 - npm-run-path: ^4.0.1 - onetime: ^5.1.2 - signal-exit: ^3.0.3 - strip-final-newline: ^2.0.0 - checksum: fba9022c8c8c15ed862847e94c252b3d946036d7547af310e344a527e59021fd8b6bb0723883ea87044dc4f0201f949046993124a42ccb0855cae5bf8c786343 + cross-spawn: "npm:^7.0.3" + get-stream: "npm:^6.0.0" + human-signals: "npm:^2.1.0" + is-stream: "npm:^2.0.0" + merge-stream: "npm:^2.0.0" + npm-run-path: "npm:^4.0.1" + onetime: "npm:^5.1.2" + signal-exit: "npm:^3.0.3" + strip-final-newline: "npm:^2.0.0" + checksum: 8ada91f2d70f7dff702c861c2c64f21dfdc1525628f3c0454fd6f02fce65f7b958616cbd2b99ca7fa4d474e461a3d363824e91b3eb881705231abbf387470597 languageName: node linkType: hard @@ -7612,38 +7586,23 @@ __metadata: version: 8.0.1 resolution: "execa@npm:8.0.1" dependencies: - cross-spawn: ^7.0.3 - get-stream: ^8.0.1 - human-signals: ^5.0.0 - is-stream: ^3.0.0 - merge-stream: ^2.0.0 - npm-run-path: ^5.1.0 - onetime: ^6.0.0 - signal-exit: ^4.1.0 - strip-final-newline: ^3.0.0 - checksum: cac1bf86589d1d9b73bdc5dda65c52012d1a9619c44c526891956745f7b366ca2603d29fe3f7460bacc2b48c6eab5d6a4f7afe0534b31473d3708d1265545e1f + cross-spawn: "npm:^7.0.3" + get-stream: "npm:^8.0.1" + human-signals: "npm:^5.0.0" + is-stream: "npm:^3.0.0" + merge-stream: "npm:^2.0.0" + npm-run-path: "npm:^5.1.0" + onetime: "npm:^6.0.0" + signal-exit: "npm:^4.1.0" + strip-final-newline: "npm:^3.0.0" + checksum: d2ab5fe1e2bb92b9788864d0713f1fce9a07c4594e272c0c97bc18c90569897ab262e4ea58d27a694d288227a2e24f16f5e2575b44224ad9983b799dc7f1098d languageName: node linkType: hard "exit@npm:^0.1.2": version: 0.1.2 resolution: "exit@npm:0.1.2" - checksum: abc407f07a875c3961e4781dfcb743b58d6c93de9ab263f4f8c9d23bb6da5f9b7764fc773f86b43dd88030444d5ab8abcb611cb680fba8ca075362b77114bba3 - languageName: node - linkType: hard - -"expand-brackets@npm:^2.1.4": - version: 2.1.4 - resolution: "expand-brackets@npm:2.1.4" - dependencies: - debug: ^2.3.3 - define-property: ^0.2.5 - extend-shallow: ^2.0.1 - posix-character-classes: ^0.1.0 - regex-not: ^1.0.0 - snapdragon: ^0.8.1 - to-regex: ^3.0.1 - checksum: 1781d422e7edfa20009e2abda673cadb040a6037f0bd30fcd7357304f4f0c284afd420d7622722ca4a016f39b6d091841ab57b401c1f7e2e5131ac65b9f14fa1 + checksum: 387555050c5b3c10e7a9e8df5f43194e95d7737c74532c409910e585d5554eaff34960c166643f5e23d042196529daad059c292dcf1fb61b8ca878d3677f4b87 languageName: node linkType: hard @@ -7651,19 +7610,19 @@ __metadata: version: 29.7.0 resolution: "expect@npm:29.7.0" dependencies: - "@jest/expect-utils": ^29.7.0 - jest-get-type: ^29.6.3 - jest-matcher-utils: ^29.7.0 - jest-message-util: ^29.7.0 - jest-util: ^29.7.0 - checksum: 9257f10288e149b81254a0fda8ffe8d54a7061cd61d7515779998b012579d2b8c22354b0eb901daf0145f347403da582f75f359f4810c007182ad3fb318b5c0c + "@jest/expect-utils": "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 63f97bc51f56a491950fb525f9ad94f1916e8a014947f8d8445d3847a665b5471b768522d659f5e865db20b6c2033d2ac10f35fcbd881a4d26407a4f6f18451a languageName: node linkType: hard "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" - checksum: 3d21519a4f8207c99f7457287291316306255a328770d320b401114ec8481986e4e467e854cb9914dd965e0a1ca810a23ccb559c642c88f4c7f55c55778a9b48 + checksum: 2d9bbb6473de7051f96790d5f9a678f32e60ed0aa70741dc7fdc96fec8d631124ec3374ac144387604f05afff9500f31a1d45bd9eee4cdc2e4f9ad2d9b9d5dbd languageName: node linkType: hard @@ -7671,80 +7630,45 @@ __metadata: version: 4.18.2 resolution: "express@npm:4.18.2" dependencies: - accepts: ~1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.1 - content-disposition: 0.5.4 - content-type: ~1.0.4 - cookie: 0.5.0 - cookie-signature: 1.0.6 - debug: 2.6.9 - depd: 2.0.0 - encodeurl: ~1.0.2 - escape-html: ~1.0.3 - etag: ~1.8.1 - finalhandler: 1.2.0 - fresh: 0.5.2 - http-errors: 2.0.0 - merge-descriptors: 1.0.1 - methods: ~1.1.2 - on-finished: 2.4.1 - parseurl: ~1.3.3 - path-to-regexp: 0.1.7 - proxy-addr: ~2.0.7 - qs: 6.11.0 - range-parser: ~1.2.1 - safe-buffer: 5.2.1 - send: 0.18.0 - serve-static: 1.15.0 - setprototypeof: 1.2.0 - statuses: 2.0.1 - type-is: ~1.6.18 - utils-merge: 1.0.1 - vary: ~1.1.2 - checksum: 3c4b9b076879442f6b968fe53d85d9f1eeacbb4f4c41e5f16cc36d77ce39a2b0d81b3f250514982110d815b2f7173f5561367f9110fcc541f9371948e8c8b037 - languageName: node - linkType: hard - -"extend-shallow@npm:^2.0.1": - version: 2.0.1 - resolution: "extend-shallow@npm:2.0.1" - dependencies: - is-extendable: ^0.1.0 - checksum: 8fb58d9d7a511f4baf78d383e637bd7d2e80843bd9cd0853649108ea835208fb614da502a553acc30208e1325240bb7cc4a68473021612496bb89725483656d8 - languageName: node - linkType: hard - -"extend-shallow@npm:^3.0.0, extend-shallow@npm:^3.0.2": - version: 3.0.2 - resolution: "extend-shallow@npm:3.0.2" - dependencies: - assign-symbols: ^1.0.0 - is-extendable: ^1.0.1 - checksum: a920b0cd5838a9995ace31dfd11ab5e79bf6e295aa566910ce53dff19f4b1c0fda2ef21f26b28586c7a2450ca2b42d97bd8c0f5cec9351a819222bf861e02461 + accepts: "npm:~1.3.8" + array-flatten: "npm:1.1.1" + body-parser: "npm:1.20.1" + content-disposition: "npm:0.5.4" + content-type: "npm:~1.0.4" + cookie: "npm:0.5.0" + cookie-signature: "npm:1.0.6" + debug: "npm:2.6.9" + depd: "npm:2.0.0" + encodeurl: "npm:~1.0.2" + escape-html: "npm:~1.0.3" + etag: "npm:~1.8.1" + finalhandler: "npm:1.2.0" + fresh: "npm:0.5.2" + http-errors: "npm:2.0.0" + merge-descriptors: "npm:1.0.1" + methods: "npm:~1.1.2" + on-finished: "npm:2.4.1" + parseurl: "npm:~1.3.3" + path-to-regexp: "npm:0.1.7" + proxy-addr: "npm:~2.0.7" + qs: "npm:6.11.0" + range-parser: "npm:~1.2.1" + safe-buffer: "npm:5.2.1" + send: "npm:0.18.0" + serve-static: "npm:1.15.0" + setprototypeof: "npm:1.2.0" + statuses: "npm:2.0.1" + type-is: "npm:~1.6.18" + utils-merge: "npm:1.0.1" + vary: "npm:~1.1.2" + checksum: 869ae89ed6ff4bed7b373079dc58e5dddcf2915a2669b36037ff78c99d675ae930e5fe052b35c24f56557d28a023bb1cbe3e2f2fb87eaab96a1cedd7e597809d languageName: node linkType: hard "extend@npm:^3.0.0": version: 3.0.2 resolution: "extend@npm:3.0.2" - checksum: a50a8309ca65ea5d426382ff09f33586527882cf532931cb08ca786ea3146c0553310bda688710ff61d7668eba9f96b923fe1420cdf56a2c3eaf30fcab87b515 - languageName: node - linkType: hard - -"extglob@npm:^2.0.2": - version: 2.0.4 - resolution: "extglob@npm:2.0.4" - dependencies: - array-unique: ^0.3.2 - define-property: ^1.0.0 - expand-brackets: ^2.1.4 - extend-shallow: ^2.0.1 - fragment-cache: ^0.2.1 - regex-not: ^1.0.0 - snapdragon: ^0.8.1 - to-regex: ^3.0.1 - checksum: a41531b8934735b684cef5e8c5a01d0f298d7d384500ceca38793a9ce098125aab04ee73e2d75d5b2901bc5dddd2b64e1b5e3bf19139ea48bac52af4a92f1d00 + checksum: 59e89e2dc798ec0f54b36d82f32a27d5f6472c53974f61ca098db5d4648430b725387b53449a34df38fd0392045434426b012f302b3cc049a6500ccf82877e4e languageName: node linkType: hard @@ -7752,13 +7676,13 @@ __metadata: version: 1.7.0 resolution: "extract-zip@npm:1.7.0" dependencies: - concat-stream: ^1.6.2 - debug: ^2.6.9 - mkdirp: ^0.5.4 - yauzl: ^2.10.0 + concat-stream: "npm:^1.6.2" + debug: "npm:^2.6.9" + mkdirp: "npm:^0.5.4" + yauzl: "npm:^2.10.0" bin: extract-zip: cli.js - checksum: 011bab660d738614555773d381a6ba4815d98c1cfcdcdf027e154ebcc9fc8c9ef637b3ea5c9b2144013100071ee41722ed041fc9aacc60f6198ef747cac0c073 + checksum: a9a5e2b118cc1d3b780d296f056308a8fda580bb18a26e12d6137321e5d3ef1d09355195ff187e9c7039aab42a253ac1e3996c66d031c44abca5abde6fd51393 languageName: node linkType: hard @@ -7772,7 +7696,7 @@ __metadata: "fast-diff@npm:^1.1.2": version: 1.3.0 resolution: "fast-diff@npm:1.3.0" - checksum: d22d371b994fdc8cce9ff510d7b8dc4da70ac327bcba20df607dd5b9cae9f908f4d1028f5fe467650f058d1e7270235ae0b8230809a262b4df587a3b3aa216c3 + checksum: 9e57415bc69cd6efcc720b3b8fe9fdaf42dcfc06f86f0f45378b1fa512598a8aac48aa3928c8751d58e2f01bb4ba4f07e4f3d9bc0d57586d45f1bd1e872c6cde languageName: node linkType: hard @@ -7780,26 +7704,26 @@ __metadata: version: 3.3.2 resolution: "fast-glob@npm:3.3.2" dependencies: - "@nodelib/fs.stat": ^2.0.2 - "@nodelib/fs.walk": ^1.2.3 - glob-parent: ^5.1.2 - merge2: ^1.3.0 - micromatch: ^4.0.4 - checksum: 900e4979f4dbc3313840078419245621259f349950411ca2fa445a2f9a1a6d98c3b5e7e0660c5ccd563aa61abe133a21765c6c0dec8e57da1ba71d8000b05ec1 + "@nodelib/fs.stat": "npm:^2.0.2" + "@nodelib/fs.walk": "npm:^1.2.3" + glob-parent: "npm:^5.1.2" + merge2: "npm:^1.3.0" + micromatch: "npm:^4.0.4" + checksum: 222512e9315a0efca1276af9adb2127f02105d7288fa746145bf45e2716383fb79eb983c89601a72a399a56b7c18d38ce70457c5466218c5f13fad957cee16df languageName: node linkType: hard "fast-json-stable-stringify@npm:^2.0.0, fast-json-stable-stringify@npm:^2.1.0": version: 2.1.0 resolution: "fast-json-stable-stringify@npm:2.1.0" - checksum: b191531e36c607977e5b1c47811158733c34ccb3bfde92c44798929e9b4154884378536d26ad90dfecd32e1ffc09c545d23535ad91b3161a27ddbb8ebe0cbecb + checksum: 2c20055c1fa43c922428f16ca8bb29f2807de63e5c851f665f7ac9790176c01c3b40335257736b299764a8d383388dabc73c8083b8e1bc3d99f0a941444ec60e languageName: node linkType: hard "fast-levenshtein@npm:^2.0.6": version: 2.0.6 resolution: "fast-levenshtein@npm:2.0.6" - checksum: 92cfec0a8dfafd9c7a15fba8f2cc29cd0b62b85f056d99ce448bbcd9f708e18ab2764bda4dd5158364f4145a7c72788538994f0d1787b956ef0d1062b0f7c24c + checksum: eb7e220ecf2bab5159d157350b81d01f75726a4382f5a9266f42b9150c4523b9795f7f5d9fbbbeaeac09a441b2369f05ee02db48ea938584205530fe5693cfe1 languageName: node linkType: hard @@ -7807,8 +7731,8 @@ __metadata: version: 1.17.1 resolution: "fastq@npm:1.17.1" dependencies: - reusify: ^1.0.4 - checksum: a8c5b26788d5a1763f88bae56a8ddeee579f935a831c5fe7a8268cea5b0a91fbfe705f612209e02d639b881d7b48e461a50da4a10cfaa40da5ca7cc9da098d88 + reusify: "npm:^1.0.4" + checksum: a443180068b527dd7b3a63dc7f2a47ceca2f3e97b9c00a1efe5538757e6cc4056a3526df94308075d7727561baf09ebaa5b67da8dcbddb913a021c5ae69d1f69 languageName: node linkType: hard @@ -7816,7 +7740,7 @@ __metadata: version: 1.0.4 resolution: "fault@npm:1.0.4" dependencies: - format: ^0.2.0 + format: "npm:^0.2.0" checksum: 5ac610d8b09424e0f2fa8cf913064372f2ee7140a203a79957f73ed557c0e79b1a3d096064d7f40bde8132a69204c1fe25ec23634c05c6da2da2039cff26c4e7 languageName: node linkType: hard @@ -7825,8 +7749,8 @@ __metadata: version: 2.0.2 resolution: "fb-watchman@npm:2.0.2" dependencies: - bser: 2.1.1 - checksum: b15a124cef28916fe07b400eb87cbc73ca082c142abf7ca8e8de6af43eca79ca7bd13eb4d4d48240b3bd3136eaac40d16e42d6edf87a8e5d1dd8070626860c78 + bser: "npm:2.1.1" + checksum: 4f95d336fb805786759e383fd7fff342ceb7680f53efcc0ef82f502eb479ce35b98e8b207b6dfdfeea0eba845862107dc73813775fc6b56b3098c6e90a2dad77 languageName: node linkType: hard @@ -7834,15 +7758,22 @@ __metadata: version: 1.1.0 resolution: "fd-slicer@npm:1.1.0" dependencies: - pend: ~1.2.0 - checksum: c8585fd5713f4476eb8261150900d2cb7f6ff2d87f8feb306ccc8a1122efd152f1783bdb2b8dc891395744583436bfd8081d8e63ece0ec8687eeefea394d4ff2 + pend: "npm:~1.2.0" + checksum: db3e34fa483b5873b73f248e818f8a8b59a6427fd8b1436cd439c195fdf11e8659419404826059a642b57d18075c856d06d6a50a1413b714f12f833a9341ead3 + languageName: node + linkType: hard + +"fecha@npm:^4.2.0": + version: 4.2.3 + resolution: "fecha@npm:4.2.3" + checksum: 534ce630c8f63c116292145607fc18c0f06bfa2fd74094357bf65daacc5d3f4f2b285bf8eb112c3bbf98c5caa6d386cced797f44b9b1b33da0c0a81020444826 languageName: node linkType: hard "fetch-retry@npm:^5.0.2": version: 5.0.6 resolution: "fetch-retry@npm:5.0.6" - checksum: 4ad8bca6ec7a7b1212e636bb422a9ae8bb9dce38df0b441c9eb77a29af99b368029d6248ff69427da67e3d43c53808b121135ea395e7fe4f8f383e0ad65b4f27 + checksum: 9d64b37f9d179fecf486725ada210d169375803b731304a9500754e094a2a6aa81630d946adbb313d7f9d54457ad0d17c3ed5c115034961a719e8a65faa8b77c languageName: node linkType: hard @@ -7850,8 +7781,8 @@ __metadata: version: 6.0.1 resolution: "file-entry-cache@npm:6.0.1" dependencies: - flat-cache: ^3.0.4 - checksum: f49701feaa6314c8127c3c2f6173cfefff17612f5ed2daaafc6da13b5c91fd43e3b2a58fd0d63f9f94478a501b167615931e7200e31485e320f74a33885a9c74 + flat-cache: "npm:^3.0.4" + checksum: 099bb9d4ab332cb93c48b14807a6918a1da87c45dce91d4b61fd40e6505d56d0697da060cb901c729c90487067d93c9243f5da3dc9c41f0358483bfdebca736b languageName: node linkType: hard @@ -7859,9 +7790,9 @@ __metadata: version: 2.3.0 resolution: "file-system-cache@npm:2.3.0" dependencies: - fs-extra: 11.1.1 - ramda: 0.29.0 - checksum: 74afa2870a062500643d41e02d1fbd47a3f30100f9e153dec5233d59f05545f4c8ada6085629d624e043479ac28c0cafc31824f7b49a3f997efab8cc5d05bfee + fs-extra: "npm:11.1.1" + ramda: "npm:0.29.0" + checksum: 8f0530aaa8bed115ef1b00f69accde8d1311d0eaffc6e37bb0b5057b8be79e6e960823025ea3c980a58147eed0ba690b9906c2229e132f5d96158e9b635a052c languageName: node linkType: hard @@ -7869,20 +7800,8 @@ __metadata: version: 1.0.4 resolution: "filelist@npm:1.0.4" dependencies: - minimatch: ^5.0.1 - checksum: a303573b0821e17f2d5e9783688ab6fbfce5d52aaac842790ae85e704a6f5e4e3538660a63183d6453834dedf1e0f19a9dadcebfa3e926c72397694ea11f5160 - languageName: node - linkType: hard - -"fill-range@npm:^4.0.0": - version: 4.0.0 - resolution: "fill-range@npm:4.0.0" - dependencies: - extend-shallow: ^2.0.1 - is-number: ^3.0.0 - repeat-string: ^1.6.1 - to-regex-range: ^2.1.0 - checksum: dbb5102467786ab42bc7a3ec7380ae5d6bfd1b5177b2216de89e4a541193f8ba599a6db84651bd2c58c8921db41b8cc3d699ea83b477342d3ce404020f73c298 + minimatch: "npm:^5.0.1" + checksum: 4b436fa944b1508b95cffdfc8176ae6947b92825483639ef1b9a89b27d82f3f8aa22b21eed471993f92709b431670d4e015b39c087d435a61e1bb04564cf51de languageName: node linkType: hard @@ -7890,8 +7809,8 @@ __metadata: version: 7.0.1 resolution: "fill-range@npm:7.0.1" dependencies: - to-regex-range: ^5.0.1 - checksum: cc283f4e65b504259e64fd969bcf4def4eb08d85565e906b7d36516e87819db52029a76b6363d0f02d0d532f0033c9603b9e2d943d56ee3b0d4f7ad3328ff917 + to-regex-range: "npm:^5.0.1" + checksum: e260f7592fd196b4421504d3597cc76f4a1ca7a9488260d533b611fc3cefd61e9a9be1417cb82d3b01ad9f9c0ff2dbf258e1026d2445e26b0cf5148ff4250429 languageName: node linkType: hard @@ -7899,14 +7818,14 @@ __metadata: version: 1.2.0 resolution: "finalhandler@npm:1.2.0" dependencies: - debug: 2.6.9 - encodeurl: ~1.0.2 - escape-html: ~1.0.3 - on-finished: 2.4.1 - parseurl: ~1.3.3 - statuses: 2.0.1 - unpipe: ~1.0.0 - checksum: 92effbfd32e22a7dff2994acedbd9bcc3aa646a3e919ea6a53238090e87097f8ef07cced90aa2cc421abdf993aefbdd5b00104d55c7c5479a8d00ed105b45716 + debug: "npm:2.6.9" + encodeurl: "npm:~1.0.2" + escape-html: "npm:~1.0.3" + on-finished: "npm:2.4.1" + parseurl: "npm:~1.3.3" + statuses: "npm:2.0.1" + unpipe: "npm:~1.0.0" + checksum: 635718cb203c6d18e6b48dfbb6c54ccb08ea470e4f474ddcef38c47edcf3227feec316f886dd701235997d8af35240cae49856721ce18f539ad038665ebbf163 languageName: node linkType: hard @@ -7914,9 +7833,9 @@ __metadata: version: 2.1.0 resolution: "find-cache-dir@npm:2.1.0" dependencies: - commondir: ^1.0.1 - make-dir: ^2.0.0 - pkg-dir: ^3.0.0 + commondir: "npm:^1.0.1" + make-dir: "npm:^2.0.0" + pkg-dir: "npm:^3.0.0" checksum: 60ad475a6da9f257df4e81900f78986ab367d4f65d33cf802c5b91e969c28a8762f098693d7a571b6e4dd4c15166c2da32ae2d18b6766a18e2071079448fdce4 languageName: node linkType: hard @@ -7925,10 +7844,10 @@ __metadata: version: 3.3.2 resolution: "find-cache-dir@npm:3.3.2" dependencies: - commondir: ^1.0.1 - make-dir: ^3.0.2 - pkg-dir: ^4.1.0 - checksum: 1e61c2e64f5c0b1c535bd85939ae73b0e5773142713273818cc0b393ee3555fb0fd44e1a5b161b8b6c3e03e98c2fcc9c227d784850a13a90a8ab576869576817 + commondir: "npm:^1.0.1" + make-dir: "npm:^3.0.2" + pkg-dir: "npm:^4.1.0" + checksum: 3907c2e0b15132704ed67083686cd3e68ab7d9ecc22e50ae9da20678245d488b01fa22c0e34c0544dc6edc4354c766f016c8c186a787be7c17f7cde8c5281e85 languageName: node linkType: hard @@ -7936,7 +7855,7 @@ __metadata: version: 3.0.0 resolution: "find-up@npm:3.0.0" dependencies: - locate-path: ^3.0.0 + locate-path: "npm:^3.0.0" checksum: 38eba3fe7a66e4bc7f0f5a1366dc25508b7cfc349f852640e3678d26ad9a6d7e2c43eff0a472287de4a9753ef58f066a0ea892a256fa3636ad51b3fe1e17fae9 languageName: node linkType: hard @@ -7945,8 +7864,8 @@ __metadata: version: 4.1.0 resolution: "find-up@npm:4.1.0" dependencies: - locate-path: ^5.0.0 - path-exists: ^4.0.0 + locate-path: "npm:^5.0.0" + path-exists: "npm:^4.0.0" checksum: 4c172680e8f8c1f78839486e14a43ef82e9decd0e74145f40707cc42e7420506d5ec92d9a11c22bd2c48fb0c384ea05dd30e10dd152fefeec6f2f75282a8b844 languageName: node linkType: hard @@ -7955,8 +7874,8 @@ __metadata: version: 5.0.0 resolution: "find-up@npm:5.0.0" dependencies: - locate-path: ^6.0.0 - path-exists: ^4.0.0 + locate-path: "npm:^6.0.0" + path-exists: "npm:^4.0.0" checksum: 07955e357348f34660bde7920783204ff5a26ac2cafcaa28bace494027158a97b9f56faaf2d89a6106211a8174db650dd9f503f9c0d526b1202d5554a00b9095 languageName: node linkType: hard @@ -7965,24 +7884,31 @@ __metadata: version: 3.2.0 resolution: "flat-cache@npm:3.2.0" dependencies: - flatted: ^3.2.9 - keyv: ^4.5.3 - rimraf: ^3.0.2 - checksum: e7e0f59801e288b54bee5cb9681e9ee21ee28ef309f886b312c9d08415b79fc0f24ac842f84356ce80f47d6a53de62197ce0e6e148dc42d5db005992e2a756ec + flatted: "npm:^3.2.9" + keyv: "npm:^4.5.3" + rimraf: "npm:^3.0.2" + checksum: 02381c6ece5e9fa5b826c9bbea481d7fd77645d96e4b0b1395238124d581d10e56f17f723d897b6d133970f7a57f0fab9148cbbb67237a0a0ffe794ba60c0c70 languageName: node linkType: hard "flatted@npm:^3.2.9": version: 3.3.1 resolution: "flatted@npm:3.3.1" - checksum: 85ae7181650bb728c221e7644cbc9f4bf28bc556f2fc89bb21266962bdf0ce1029cc7acc44bb646cd469d9baac7c317f64e841c4c4c00516afa97320cdac7f94 + checksum: 7b8376061d5be6e0d3658bbab8bde587647f68797cf6bfeae9dea0e5137d9f27547ab92aaff3512dd9d1299086a6d61be98e9d48a56d17531b634f77faadbc49 languageName: node linkType: hard "flow-parser@npm:0.*": version: 0.229.2 resolution: "flow-parser@npm:0.229.2" - checksum: ddc5094c6e2d864a3c9dd59258077e241bb1929b31baf2bea97b8f42bc6601c48136807a171576aaa48a384024776be9ed014f0beb17c8ea174fe938163d2a6b + checksum: 6308b26f8dbeed073ef2d1890d99cef59669a19e87afd6b071114867795dce71901f81207b9d89ed649f6cd826452f8f826702a49c7b2dc4bc94264d6cdd3bd3 + languageName: node + linkType: hard + +"fn.name@npm:1.x.x": + version: 1.1.0 + resolution: "fn.name@npm:1.1.0" + checksum: 000198af190ae02f0138ac5fa4310da733224c628e0230c81e3fff7c4e094af7e0e8bb9f4357cabd21db601759d89f3445da744afbae20623cfa41edf3888397 languageName: node linkType: hard @@ -7992,7 +7918,7 @@ __metadata: peerDependenciesMeta: debug: optional: true - checksum: 5ca49b5ce6f44338cbfc3546823357e7a70813cecc9b7b768158a1d32c1e62e7407c944402a918ea8c38ae2e78266312d617dc68783fac502cbb55e1047b34ec + checksum: d467f13c1c6aa734599b8b369cd7a625b20081af358f6204ff515f6f4116eb440de9c4e0c49f10798eeb0df26c95dd05d5e0d9ddc5786ab1a8a8abefe92929b4 languageName: node linkType: hard @@ -8000,15 +7926,8 @@ __metadata: version: 0.3.3 resolution: "for-each@npm:0.3.3" dependencies: - is-callable: ^1.1.3 - checksum: 6c48ff2bc63362319c65e2edca4a8e1e3483a2fabc72fbe7feaf8c73db94fc7861bd53bc02c8a66a0c1dd709da6b04eec42e0abdd6b40ce47305ae92a25e5d28 - languageName: node - linkType: hard - -"for-in@npm:^1.0.2": - version: 1.0.2 - resolution: "for-in@npm:1.0.2" - checksum: 09f4ae93ce785d253ac963d94c7f3432d89398bf25ac7a24ed034ca393bf74380bdeccc40e0f2d721a895e54211b07c8fad7132e8157827f6f7f059b70b4043d + is-callable: "npm:^1.1.3" + checksum: fdac0cde1be35610bd635ae958422e8ce0cc1313e8d32ea6d34cfda7b60850940c1fd07c36456ad76bd9c24aef6ff5e03b02beb58c83af5ef6c968a64eada676 languageName: node linkType: hard @@ -8016,9 +7935,9 @@ __metadata: version: 3.1.1 resolution: "foreground-child@npm:3.1.1" dependencies: - cross-spawn: ^7.0.0 - signal-exit: ^4.0.1 - checksum: 139d270bc82dc9e6f8bc045fe2aae4001dc2472157044fdfad376d0a3457f77857fa883c1c8b21b491c6caade9a926a4bed3d3d2e8d3c9202b151a4cbbd0bcd5 + cross-spawn: "npm:^7.0.0" + signal-exit: "npm:^4.0.1" + checksum: 087edd44857d258c4f73ad84cb8df980826569656f2550c341b27adf5335354393eec24ea2fabd43a253233fb27cee177ebe46bd0b7ea129c77e87cb1e9936fb languageName: node linkType: hard @@ -8026,40 +7945,31 @@ __metadata: version: 4.0.0 resolution: "form-data@npm:4.0.0" dependencies: - asynckit: ^0.4.0 - combined-stream: ^1.0.8 - mime-types: ^2.1.12 - checksum: 01135bf8675f9d5c61ff18e2e2932f719ca4de964e3be90ef4c36aacfc7b9cb2fceb5eca0b7e0190e3383fe51c5b37f4cb80b62ca06a99aaabfcfd6ac7c9328c + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + mime-types: "npm:^2.1.12" + checksum: 7264aa760a8cf09482816d8300f1b6e2423de1b02bba612a136857413fdc96d7178298ced106817655facc6b89036c6e12ae31c9eb5bdc16aabf502ae8a5d805 languageName: node linkType: hard "format@npm:^0.2.0": version: 0.2.2 resolution: "format@npm:0.2.2" - checksum: 646a60e1336250d802509cf24fb801e43bd4a70a07510c816fa133aa42cdbc9c21e66e9cc0801bb183c5b031c9d68be62e7fbb6877756e52357850f92aa28799 + checksum: 5f878b8fc1a672c8cbefa4f293bdd977c822862577d70d53456a48b4169ec9b51677c0c995bf62c633b4e5cd673624b7c273f57923b28735a6c0c0a72c382a4a languageName: node linkType: hard "forwarded@npm:0.2.0": version: 0.2.0 resolution: "forwarded@npm:0.2.0" - checksum: fd27e2394d8887ebd16a66ffc889dc983fbbd797d5d3f01087c020283c0f019a7d05ee85669383d8e0d216b116d720fc0cef2f6e9b7eb9f4c90c6e0bc7fd28e6 - languageName: node - linkType: hard - -"fragment-cache@npm:^0.2.1": - version: 0.2.1 - resolution: "fragment-cache@npm:0.2.1" - dependencies: - map-cache: ^0.2.2 - checksum: 1cbbd0b0116b67d5790175de0038a11df23c1cd2e8dcdbade58ebba5594c2d641dade6b4f126d82a7b4a6ffc2ea12e3d387dbb64ea2ae97cf02847d436f60fdc + checksum: 29ba9fd347117144e97cbb8852baae5e8b2acb7d1b591ef85695ed96f5b933b1804a7fac4a15dd09ca7ac7d0cdc104410e8102aae2dd3faa570a797ba07adb81 languageName: node linkType: hard "fresh@npm:0.5.2": version: 0.5.2 resolution: "fresh@npm:0.5.2" - checksum: 13ea8b08f91e669a64e3ba3a20eb79d7ca5379a81f1ff7f4310d54e2320645503cc0c78daedc93dfb6191287295f6479544a649c64d8e41a1c0fb0c221552346 + checksum: 64c88e489b5d08e2f29664eb3c79c705ff9a8eb15d3e597198ef76546d4ade295897a44abb0abd2700e7ef784b2e3cbf1161e4fbf16f59129193fd1030d16da1 languageName: node linkType: hard @@ -8074,10 +7984,10 @@ __metadata: version: 11.1.1 resolution: "fs-extra@npm:11.1.1" dependencies: - graceful-fs: ^4.2.0 - jsonfile: ^6.0.1 - universalify: ^2.0.0 - checksum: fb883c68245b2d777fbc1f2082c9efb084eaa2bbf9fddaa366130d196c03608eebef7fb490541276429ee1ca99f317e2d73e96f5ca0999eefedf5a624ae1edfd + graceful-fs: "npm:^4.2.0" + jsonfile: "npm:^6.0.1" + universalify: "npm:^2.0.0" + checksum: c4e9fabf9762a70d1403316b7faa899f3d3303c8afa765b891c2210fdeba368461e04ae1203920b64ef6a7d066a39ab8cef2160b5ce8d1011bb4368688cd9bb7 languageName: node linkType: hard @@ -8085,10 +7995,10 @@ __metadata: version: 11.2.0 resolution: "fs-extra@npm:11.2.0" dependencies: - graceful-fs: ^4.2.0 - jsonfile: ^6.0.1 - universalify: ^2.0.0 - checksum: b12e42fa40ba47104202f57b8480dd098aa931c2724565e5e70779ab87605665594e76ee5fb00545f772ab9ace167fe06d2ab009c416dc8c842c5ae6df7aa7e8 + graceful-fs: "npm:^4.2.0" + jsonfile: "npm:^6.0.1" + universalify: "npm:^2.0.0" + checksum: 0579bf6726a4cd054d4aa308f10b483f52478bb16284f32cf60b4ce0542063d551fca1a08a2af365e35db21a3fa5a06cf2a6ed614004b4368982bc754cb816b3 languageName: node linkType: hard @@ -8096,8 +8006,8 @@ __metadata: version: 2.1.0 resolution: "fs-minipass@npm:2.1.0" dependencies: - minipass: ^3.0.0 - checksum: 1b8d128dae2ac6cc94230cc5ead341ba3e0efaef82dab46a33d171c044caaa6ca001364178d42069b2809c35a1c3c35079a32107c770e9ffab3901b59af8c8b1 + minipass: "npm:^3.0.0" + checksum: 03191781e94bc9a54bd376d3146f90fe8e082627c502185dbf7b9b3032f66b0b142c1115f3b2cc5936575fc1b44845ce903dd4c21bec2a8d69f3bd56f9cee9ec languageName: node linkType: hard @@ -8105,15 +8015,15 @@ __metadata: version: 3.0.3 resolution: "fs-minipass@npm:3.0.3" dependencies: - minipass: ^7.0.3 - checksum: 8722a41109130851d979222d3ec88aabaceeaaf8f57b2a8f744ef8bd2d1ce95453b04a61daa0078822bc5cd21e008814f06fe6586f56fef511e71b8d2394d802 + minipass: "npm:^7.0.3" + checksum: af143246cf6884fe26fa281621d45cfe111d34b30535a475bfa38dafe343dadb466c047a924ffc7d6b7b18265df4110224ce3803806dbb07173bf2087b648d7f languageName: node linkType: hard "fs.realpath@npm:^1.0.0": version: 1.0.0 resolution: "fs.realpath@npm:1.0.0" - checksum: 99ddea01a7e75aa276c250a04eedeffe5662bce66c65c07164ad6264f9de18fb21be9433ead460e54cff20e31721c811f4fb5d70591799df5f85dce6d6746fd0 + checksum: e703107c28e362d8d7b910bbcbfd371e640a3bb45ae157a362b5952c0030c0b6d4981140ec319b347bce7adc025dd7813da1ff908a945ac214d64f5402a51b96 languageName: node linkType: hard @@ -8121,17 +8031,17 @@ __metadata: version: 2.3.3 resolution: "fsevents@npm:2.3.3" dependencies: - node-gyp: latest - checksum: 11e6ea6fea15e42461fc55b4b0e4a0a3c654faa567f1877dbd353f39156f69def97a69936d1746619d656c4b93de2238bf731f6085a03a50cabf287c9d024317 + node-gyp: "npm:latest" + checksum: 4c1ade961ded57cdbfbb5cac5106ec17bc8bccd62e16343c569a0ceeca83b9dfef87550b4dc5cbb89642da412b20c5071f304c8c464b80415446e8e155a038c0 conditions: os=darwin languageName: node linkType: hard -"fsevents@patch:fsevents@^2.3.2#~builtin, fsevents@patch:fsevents@~2.3.2#~builtin": +"fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": version: 2.3.3 - resolution: "fsevents@patch:fsevents@npm%3A2.3.3#~builtin::version=2.3.3&hash=df0bf1" + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" dependencies: - node-gyp: latest + node-gyp: "npm:latest" conditions: os=darwin languageName: node linkType: hard @@ -8139,7 +8049,7 @@ __metadata: "function-bind@npm:^1.1.2": version: 1.1.2 resolution: "function-bind@npm:1.1.2" - checksum: 2b0ff4ce708d99715ad14a6d1f894e2a83242e4a52ccfcefaee5e40050562e5f6dafc1adbb4ce2d4ab47279a45dc736ab91ea5042d843c3c092820dfe032efb1 + checksum: 185e20d20f10c8d661d59aac0f3b63b31132d492e1b11fcc2a93cb2c47257ebaee7407c38513efd2b35cafdf972d9beb2ea4593c1e0f3bf8f2744836928d7454 languageName: node linkType: hard @@ -8147,25 +8057,25 @@ __metadata: version: 1.1.6 resolution: "function.prototype.name@npm:1.1.6" dependencies: - call-bind: ^1.0.2 - define-properties: ^1.2.0 - es-abstract: ^1.22.1 - functions-have-names: ^1.2.3 - checksum: 7a3f9bd98adab09a07f6e1f03da03d3f7c26abbdeaeee15223f6c04a9fb5674792bdf5e689dac19b97ac71de6aad2027ba3048a9b883aa1b3173eed6ab07f479 + call-bind: "npm:^1.0.2" + define-properties: "npm:^1.2.0" + es-abstract: "npm:^1.22.1" + functions-have-names: "npm:^1.2.3" + checksum: 4d40be44d4609942e4e90c4fff77a811fa936f4985d92d2abfcf44f673ba344e2962bf223a33101f79c1a056465f36f09b072b9c289d7660ca554a12491cd5a2 languageName: node linkType: hard "functions-have-names@npm:^1.2.3": version: 1.2.3 resolution: "functions-have-names@npm:1.2.3" - checksum: c3f1f5ba20f4e962efb71344ce0a40722163e85bee2101ce25f88214e78182d2d2476aa85ef37950c579eb6cf6ee811c17b3101bb84004bb75655f3e33f3fdb5 + checksum: 0ddfd3ed1066a55984aaecebf5419fbd9344a5c38dd120ffb0739fac4496758dcf371297440528b115e4367fc46e3abc86a2cc0ff44612181b175ae967a11a05 languageName: node linkType: hard "gensync@npm:^1.0.0-beta.2": version: 1.0.0-beta.2 resolution: "gensync@npm:1.0.0-beta.2" - checksum: a7437e58c6be12aa6c90f7730eac7fa9833dc78872b4ad2963d2031b00a3367a93f98aec75f9aaac7220848e4026d67a8655e870b24f20a543d103c0d65952ec + checksum: 17d8333460204fbf1f9160d067e1e77f908a5447febb49424b8ab043026049835c9ef3974445c57dbd39161f4d2b04356d7de12b2eecaa27a7a7ea7d871cbedd languageName: node linkType: hard @@ -8180,19 +8090,19 @@ __metadata: version: 1.2.4 resolution: "get-intrinsic@npm:1.2.4" dependencies: - es-errors: ^1.3.0 - function-bind: ^1.1.2 - has-proto: ^1.0.1 - has-symbols: ^1.0.3 - hasown: ^2.0.0 - checksum: 414e3cdf2c203d1b9d7d33111df746a4512a1aa622770b361dadddf8ed0b5aeb26c560f49ca077e24bfafb0acb55ca908d1f709216ccba33ffc548ec8a79a951 + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + has-proto: "npm:^1.0.1" + has-symbols: "npm:^1.0.3" + hasown: "npm:^2.0.0" + checksum: 85bbf4b234c3940edf8a41f4ecbd4e25ce78e5e6ad4e24ca2f77037d983b9ef943fd72f00f3ee97a49ec622a506b67db49c36246150377efcda1c9eb03e5f06d languageName: node linkType: hard "get-nonce@npm:^1.0.0": version: 1.0.1 resolution: "get-nonce@npm:1.0.1" - checksum: e2614e43b4694c78277bb61b0f04583d45786881289285c73770b07ded246a98be7e1f78b940c80cbe6f2b07f55f0b724e6db6fd6f1bcbd1e8bdac16521074ed + checksum: ad5104871d114a694ecc506a2d406e2331beccb961fe1e110dc25556b38bcdbf399a823a8a375976cd8889668156a9561e12ebe3fa6a4c6ba169c8466c2ff868 languageName: node linkType: hard @@ -8220,14 +8130,14 @@ __metadata: "get-stream@npm:^6.0.0": version: 6.0.1 resolution: "get-stream@npm:6.0.1" - checksum: e04ecece32c92eebf5b8c940f51468cd53554dcbb0ea725b2748be583c9523d00128137966afce410b9b051eb2ef16d657cd2b120ca8edafcf5a65e81af63cad + checksum: 781266d29725f35c59f1d214aedc92b0ae855800a980800e2923b3fbc4e56b3cb6e462c42e09a1cf1a00c64e056a78fa407cbe06c7c92b7e5cd49b4b85c2a497 languageName: node linkType: hard "get-stream@npm:^8.0.1": version: 8.0.1 resolution: "get-stream@npm:8.0.1" - checksum: 01e3d3cf29e1393f05f44d2f00445c5f9ec3d1c49e8179b31795484b9c117f4c695e5e07b88b50785d5c8248a788c85d9913a79266fc77e3ef11f78f10f1b974 + checksum: dde5511e2e65a48e9af80fea64aff11b4921b14b6e874c6f8294c50975095af08f41bfb0b680c887f28b566dd6ec2cb2f960f9d36a323359be324ce98b766e9e languageName: node linkType: hard @@ -8235,35 +8145,28 @@ __metadata: version: 1.0.2 resolution: "get-symbol-description@npm:1.0.2" dependencies: - call-bind: ^1.0.5 - es-errors: ^1.3.0 - get-intrinsic: ^1.2.4 + call-bind: "npm:^1.0.5" + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.4" checksum: e1cb53bc211f9dbe9691a4f97a46837a553c4e7caadd0488dc24ac694db8a390b93edd412b48dcdd0b4bbb4c595de1709effc75fc87c0839deedc6968f5bd973 languageName: node linkType: hard -"get-value@npm:^2.0.3, get-value@npm:^2.0.6": - version: 2.0.6 - resolution: "get-value@npm:2.0.6" - checksum: 5c3b99cb5398ea8016bf46ff17afc5d1d286874d2ad38ca5edb6e87d75c0965b0094cb9a9dddef2c59c23d250702323539a7fbdd870620db38c7e7d7ec87c1eb - languageName: node - linkType: hard - "giget@npm:^1.0.0": version: 1.2.1 resolution: "giget@npm:1.2.1" dependencies: - citty: ^0.1.5 - consola: ^3.2.3 - defu: ^6.1.3 - node-fetch-native: ^1.6.1 - nypm: ^0.3.3 - ohash: ^1.1.3 - pathe: ^1.1.1 - tar: ^6.2.0 + citty: "npm:^0.1.5" + consola: "npm:^3.2.3" + defu: "npm:^6.1.3" + node-fetch-native: "npm:^1.6.1" + nypm: "npm:^0.3.3" + ohash: "npm:^1.1.3" + pathe: "npm:^1.1.1" + tar: "npm:^6.2.0" bin: giget: dist/cli.mjs - checksum: 0af12adf846d22afb3ef4f4574ed4db79456a749c288163de4741fd9620fa4f8cd93491087551f9bca09cfd24f073a8a7bfb003b493e79ea7d5cd86102332a8f + checksum: 5d50c70754fef1f199547fc58ad8ad18fed7f4ee3a2e624827d3f214476b731492ee96bd14934ae23b863524369801b23fd0785028576837be0c23bf2031c2b7 languageName: node linkType: hard @@ -8278,8 +8181,8 @@ __metadata: version: 5.1.2 resolution: "glob-parent@npm:5.1.2" dependencies: - is-glob: ^4.0.1 - checksum: f4f2bfe2425296e8a47e36864e4f42be38a996db40420fe434565e4480e3322f18eb37589617a98640c5dc8fdec1a387007ee18dbb1f3f5553409c34d17f425e + is-glob: "npm:^4.0.1" + checksum: 32cd106ce8c0d83731966d31517adb766d02c3812de49c30cfe0675c7c0ae6630c11214c54a5ae67aca882cf738d27fd7768f21aa19118b9245950554be07247 languageName: node linkType: hard @@ -8287,7 +8190,7 @@ __metadata: version: 6.0.2 resolution: "glob-parent@npm:6.0.2" dependencies: - is-glob: ^4.0.3 + is-glob: "npm:^4.0.3" checksum: c13ee97978bef4f55106b71e66428eb1512e71a7466ba49025fc2aec59a5bfb0954d5abd58fc5ee6c9b076eef4e1f6d3375c2e964b88466ca390da4419a786a8 languageName: node linkType: hard @@ -8296,7 +8199,7 @@ __metadata: version: 4.2.2 resolution: "glob-promise@npm:4.2.2" dependencies: - "@types/glob": ^7.1.3 + "@types/glob": "npm:^7.1.3" peerDependencies: glob: ^7.1.6 checksum: c1a3d95f7c8393e4151d4899ec4e42bb2e8237160f840ad1eccbe9247407da8b6c13e28f463022e011708bc40862db87b9b77236d35afa3feb8aa86d518f2dfe @@ -8306,7 +8209,7 @@ __metadata: "glob-to-regexp@npm:^0.4.1": version: 0.4.1 resolution: "glob-to-regexp@npm:0.4.1" - checksum: e795f4e8f06d2a15e86f76e4d92751cf8bbfcf0157cea5c2f0f35678a8195a750b34096b1256e436f0cebc1883b5ff0888c47348443e69546a5a87f9e1eb1167 + checksum: 9009529195a955c40d7b9690794aeff5ba665cc38f1519e111c58bb54366fd0c106bde80acf97ba4e533208eb53422c83b136611a54c5fefb1edd8dc267cb62e languageName: node linkType: hard @@ -8314,35 +8217,35 @@ __metadata: version: 10.3.10 resolution: "glob@npm:10.3.10" dependencies: - foreground-child: ^3.1.0 - jackspeak: ^2.3.5 - minimatch: ^9.0.1 - minipass: ^5.0.0 || ^6.0.2 || ^7.0.0 - path-scurry: ^1.10.1 + foreground-child: "npm:^3.1.0" + jackspeak: "npm:^2.3.5" + minimatch: "npm:^9.0.1" + minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0" + path-scurry: "npm:^1.10.1" bin: glob: dist/esm/bin.mjs - checksum: 4f2fe2511e157b5a3f525a54092169a5f92405f24d2aed3142f4411df328baca13059f4182f1db1bf933e2c69c0bd89e57ae87edd8950cba8c7ccbe84f721cf3 + checksum: 38bdb2c9ce75eb5ed168f309d4ed05b0798f640b637034800a6bf306f39d35409bf278b0eaaffaec07591085d3acb7184a201eae791468f0f617771c2486a6a8 languageName: node linkType: hard -"glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.2.0": +"glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.2.0, glob@npm:^7.2.3": version: 7.2.3 resolution: "glob@npm:7.2.3" dependencies: - fs.realpath: ^1.0.0 - inflight: ^1.0.4 - inherits: 2 - minimatch: ^3.1.1 - once: ^1.3.0 - path-is-absolute: ^1.0.0 - checksum: 29452e97b38fa704dabb1d1045350fb2467cf0277e155aa9ff7077e90ad81d1ea9d53d3ee63bd37c05b09a065e90f16aec4a65f5b8de401d1dac40bc5605d133 + fs.realpath: "npm:^1.0.0" + inflight: "npm:^1.0.4" + inherits: "npm:2" + minimatch: "npm:^3.1.1" + once: "npm:^1.3.0" + path-is-absolute: "npm:^1.0.0" + checksum: 59452a9202c81d4508a43b8af7082ca5c76452b9fcc4a9ab17655822e6ce9b21d4f8fbadabe4fe3faef448294cec249af305e2cd824b7e9aaf689240e5e96a7b languageName: node linkType: hard "globals@npm:^11.1.0": version: 11.12.0 resolution: "globals@npm:11.12.0" - checksum: 67051a45eca3db904aee189dfc7cd53c20c7d881679c93f6146ddd4c9f4ab2268e68a919df740d39c71f4445d2b38ee360fc234428baea1dbdfe68bbcb46979e + checksum: 9f054fa38ff8de8fa356502eb9d2dae0c928217b8b5c8de1f09f5c9b6c8a96d8b9bd3afc49acbcd384a98a81fea713c859e1b09e214c60509517bb8fc2bc13c2 languageName: node linkType: hard @@ -8350,8 +8253,8 @@ __metadata: version: 13.24.0 resolution: "globals@npm:13.24.0" dependencies: - type-fest: ^0.20.2 - checksum: 56066ef058f6867c04ff203b8a44c15b038346a62efbc3060052a1016be9f56f4cf0b2cd45b74b22b81e521a889fc7786c73691b0549c2f3a6e825b3d394f43c + type-fest: "npm:^0.20.2" + checksum: 62c5b1997d06674fc7191d3e01e324d3eda4d65ac9cc4e78329fa3b5c4fd42a0e1c8722822497a6964eee075255ce21ccf1eec2d83f92ef3f06653af4d0ee28e languageName: node linkType: hard @@ -8359,8 +8262,8 @@ __metadata: version: 1.0.3 resolution: "globalthis@npm:1.0.3" dependencies: - define-properties: ^1.1.3 - checksum: fbd7d760dc464c886d0196166d92e5ffb4c84d0730846d6621a39fbbc068aeeb9c8d1421ad330e94b7bca4bb4ea092f5f21f3d36077812af5d098b4dc006c998 + define-properties: "npm:^1.1.3" + checksum: 45ae2f3b40a186600d0368f2a880ae257e8278b4c7704f0417d6024105ad7f7a393661c5c2fa1334669cd485ea44bc883a08fdd4516df2428aec40c99f52aa89 languageName: node linkType: hard @@ -8368,20 +8271,20 @@ __metadata: version: 11.1.0 resolution: "globby@npm:11.1.0" dependencies: - array-union: ^2.1.0 - dir-glob: ^3.0.1 - fast-glob: ^3.2.9 - ignore: ^5.2.0 - merge2: ^1.4.1 - slash: ^3.0.0 - checksum: b4be8885e0cfa018fc783792942d53926c35c50b3aefd3fdcfb9d22c627639dc26bd2327a40a0b74b074100ce95bb7187bfeae2f236856aa3de183af7a02aea6 + array-union: "npm:^2.1.0" + dir-glob: "npm:^3.0.1" + fast-glob: "npm:^3.2.9" + ignore: "npm:^5.2.0" + merge2: "npm:^1.4.1" + slash: "npm:^3.0.0" + checksum: 288e95e310227bbe037076ea81b7c2598ccbc3122d87abc6dab39e1eec309aa14f0e366a98cdc45237ffcfcbad3db597778c0068217dcb1950fef6249104e1b1 languageName: node linkType: hard "globrex@npm:^0.1.2": version: 0.1.2 resolution: "globrex@npm:0.1.2" - checksum: adca162494a176ce9ecf4dd232f7b802956bb1966b37f60c15e49d2e7d961b66c60826366dc2649093cad5a0d69970cfa8875bd1695b5a1a2f33dcd2aa88da3c + checksum: 81ce62ee6f800d823d6b7da7687f841676d60ee8f51f934ddd862e4057316d26665c4edc0358d4340a923ac00a514f8b67c787e28fe693aae16350f4e60d55e9 languageName: node linkType: hard @@ -8389,22 +8292,22 @@ __metadata: version: 1.0.1 resolution: "gopd@npm:1.0.1" dependencies: - get-intrinsic: ^1.1.3 - checksum: a5ccfb8806e0917a94e0b3de2af2ea4979c1da920bc381667c260e00e7cafdbe844e2cb9c5bcfef4e5412e8bf73bab837285bc35c7ba73aaaf0134d4583393a6 + get-intrinsic: "npm:^1.1.3" + checksum: 5fbc7ad57b368ae4cd2f41214bd947b045c1a4be2f194a7be1778d71f8af9dbf4004221f3b6f23e30820eb0d052b4f819fe6ebe8221e2a3c6f0ee4ef173421ca languageName: node linkType: hard "graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" - checksum: ac85f94da92d8eb6b7f5a8b20ce65e43d66761c55ce85ac96df6865308390da45a8d3f0296dd3a663de65d30ba497bd46c696cc1e248c72b13d6d567138a4fc7 + checksum: bf152d0ed1dc159239db1ba1f74fdbc40cb02f626770dcd5815c427ce0688c2635a06ed69af364396da4636d0408fcf7d4afdf7881724c3307e46aff30ca49e2 languageName: node linkType: hard "graphemer@npm:^1.4.0": version: 1.4.0 resolution: "graphemer@npm:1.4.0" - checksum: bab8f0be9b568857c7bec9fda95a89f87b783546d02951c40c33f84d05bb7da3fd10f863a9beb901463669b6583173a8c8cc6d6b306ea2b9b9d5d3d943c3a673 + checksum: 6dd60dba97007b21e3a829fab3f771803cc1292977fe610e240ea72afd67e5690ac9eeaafc4a99710e78962e5936ab5a460787c2a1180f1cb0ccfac37d29f897 languageName: node linkType: hard @@ -8412,15 +8315,15 @@ __metadata: version: 1.4.2 resolution: "gunzip-maybe@npm:1.4.2" dependencies: - browserify-zlib: ^0.1.4 - is-deflate: ^1.0.0 - is-gzip: ^1.0.0 - peek-stream: ^1.1.0 - pumpify: ^1.3.3 - through2: ^2.0.3 + browserify-zlib: "npm:^0.1.4" + is-deflate: "npm:^1.0.0" + is-gzip: "npm:^1.0.0" + peek-stream: "npm:^1.1.0" + pumpify: "npm:^1.3.3" + through2: "npm:^2.0.3" bin: gunzip-maybe: bin.js - checksum: bc4d4977c24a2860238df271de75d53dd72a359d19f1248d1c613807dc221d3b8ae09624e3085c8106663e3e1b59db62a85b261d1138c2cc24efad9df577d4e1 + checksum: 82a4eadb617e50ac63cb88b3c1ebef0f85de702c0c2031c5d9c0575837e1eef7c94fa4ad69ca4aec2dc3d939c89054ec07c91c233648433058efa7d44354d456 languageName: node linkType: hard @@ -8428,47 +8331,31 @@ __metadata: version: 4.7.8 resolution: "handlebars@npm:4.7.8" dependencies: - minimist: ^1.2.5 - neo-async: ^2.6.2 - source-map: ^0.6.1 - uglify-js: ^3.1.4 - wordwrap: ^1.0.0 + minimist: "npm:^1.2.5" + neo-async: "npm:^2.6.2" + source-map: "npm:^0.6.1" + uglify-js: "npm:^3.1.4" + wordwrap: "npm:^1.0.0" dependenciesMeta: uglify-js: optional: true bin: handlebars: bin/handlebars - checksum: 00e68bb5c183fd7b8b63322e6234b5ac8fbb960d712cb3f25587d559c2951d9642df83c04a1172c918c41bcfc81bfbd7a7718bbce93b893e0135fc99edea93ff + checksum: bd528f4dd150adf67f3f857118ef0fa43ff79a153b1d943fa0a770f2599e38b25a7a0dbac1a3611a4ec86970fd2325a81310fb788b5c892308c9f8743bd02e11 languageName: node linkType: hard "harmony-reflect@npm:^1.4.6": version: 1.6.2 resolution: "harmony-reflect@npm:1.6.2" - checksum: 2e5bae414cd2bfae5476147f9935dc69ee9b9a413206994dcb94c5b3208d4555da3d4313aff6fd14bd9991c1e3ef69cdda5c8fac1eb1d7afc064925839339b8c - languageName: node - linkType: hard - -"has-ansi@npm:^2.0.0": - version: 2.0.0 - resolution: "has-ansi@npm:2.0.0" - dependencies: - ansi-regex: ^2.0.0 - checksum: 1b51daa0214440db171ff359d0a2d17bc20061164c57e76234f614c91dbd2a79ddd68dfc8ee73629366f7be45a6df5f2ea9de83f52e1ca24433f2cc78c35d8ec + checksum: 69d30ebfb5dbd6ff0553725c7922404cf1dfe5390db1618298eed27fe6c9bd2f3f677727e9da969d21648f4a6a39041e2f46e99976be4385f9e34bac23058cd4 languageName: node linkType: hard "has-bigints@npm:^1.0.1, has-bigints@npm:^1.0.2": version: 1.0.2 resolution: "has-bigints@npm:1.0.2" - checksum: 390e31e7be7e5c6fe68b81babb73dfc35d413604d7ee5f56da101417027a4b4ce6a27e46eff97ad040c835b5d228676eae99a9b5c3bc0e23c8e81a49241ff45b - languageName: node - linkType: hard - -"has-flag@npm:^1.0.0": - version: 1.0.0 - resolution: "has-flag@npm:1.0.0" - checksum: ce3f8ae978e70f16e4bbe17d3f0f6d6c0a3dd3b62a23f97c91d0fda9ed8e305e13baf95cc5bee4463b9f25ac9f5255de113165c5fb285e01b8065b2ac079b301 + checksum: 4e0426c900af034d12db14abfece02ce7dbf53f2022d28af1a97913ff4c07adb8799476d57dc44fbca0e07d1dbda2a042c2928b1f33d3f09c15de0640a7fb81b languageName: node linkType: hard @@ -8490,22 +8377,22 @@ __metadata: version: 1.0.2 resolution: "has-property-descriptors@npm:1.0.2" dependencies: - es-define-property: ^1.0.0 - checksum: fcbb246ea2838058be39887935231c6d5788babed499d0e9d0cc5737494c48aba4fe17ba1449e0d0fbbb1e36175442faa37f9c427ae357d6ccb1d895fbcd3de3 + es-define-property: "npm:^1.0.0" + checksum: 2d8c9ab8cebb572e3362f7d06139a4592105983d4317e68f7adba320fe6ddfc8874581e0971e899e633fd5f72e262830edce36d5a0bc863dad17ad20572484b2 languageName: node linkType: hard "has-proto@npm:^1.0.1, has-proto@npm:^1.0.3": version: 1.0.3 resolution: "has-proto@npm:1.0.3" - checksum: fe7c3d50b33f50f3933a04413ed1f69441d21d2d2944f81036276d30635cad9279f6b43bc8f32036c31ebdfcf6e731150f46c1907ad90c669ffe9b066c3ba5c4 + checksum: 0b67c2c94e3bea37db3e412e3c41f79d59259875e636ba471e94c009cdfb1fa82bf045deeffafc7dbb9c148e36cae6b467055aaa5d9fad4316e11b41e3ba551a languageName: node linkType: hard "has-symbols@npm:^1.0.2, has-symbols@npm:^1.0.3": version: 1.0.3 resolution: "has-symbols@npm:1.0.3" - checksum: a054c40c631c0d5741a8285010a0777ea0c068f99ed43e5d6eb12972da223f8af553a455132fdb0801bdcfa0e0f443c0c03a68d8555aa529b3144b446c3f2410 + checksum: 464f97a8202a7690dadd026e6d73b1ceeddd60fe6acfd06151106f050303eaa75855aaa94969df8015c11ff7c505f196114d22f7386b4a471038da5874cf5e9b languageName: node linkType: hard @@ -8513,47 +8400,8 @@ __metadata: version: 1.0.2 resolution: "has-tostringtag@npm:1.0.2" dependencies: - has-symbols: ^1.0.3 - checksum: 999d60bb753ad714356b2c6c87b7fb74f32463b8426e159397da4bde5bca7e598ab1073f4d8d4deafac297f2eb311484cd177af242776bf05f0d11565680468d - languageName: node - linkType: hard - -"has-value@npm:^0.3.1": - version: 0.3.1 - resolution: "has-value@npm:0.3.1" - dependencies: - get-value: ^2.0.3 - has-values: ^0.1.4 - isobject: ^2.0.0 - checksum: 29e2a1e6571dad83451b769c7ce032fce6009f65bccace07c2962d3ad4d5530b6743d8f3229e4ecf3ea8e905d23a752c5f7089100c1f3162039fa6dc3976558f - languageName: node - linkType: hard - -"has-value@npm:^1.0.0": - version: 1.0.0 - resolution: "has-value@npm:1.0.0" - dependencies: - get-value: ^2.0.6 - has-values: ^1.0.0 - isobject: ^3.0.0 - checksum: b9421d354e44f03d3272ac39fd49f804f19bc1e4fa3ceef7745df43d6b402053f828445c03226b21d7d934a21ac9cf4bc569396dc312f496ddff873197bbd847 - languageName: node - linkType: hard - -"has-values@npm:^0.1.4": - version: 0.1.4 - resolution: "has-values@npm:0.1.4" - checksum: ab1c4bcaf811ccd1856c11cfe90e62fca9e2b026ebe474233a3d282d8d67e3b59ed85b622c7673bac3db198cb98bd1da2b39300a2f98e453729b115350af49bc - languageName: node - linkType: hard - -"has-values@npm:^1.0.0": - version: 1.0.0 - resolution: "has-values@npm:1.0.0" - dependencies: - is-number: ^3.0.0 - kind-of: ^4.0.0 - checksum: 77e6693f732b5e4cf6c38dfe85fdcefad0fab011af74995c3e83863fabf5e3a836f406d83565816baa0bc0a523c9410db8b990fe977074d61aeb6d8f4fcffa11 + has-symbols: "npm:^1.0.3" + checksum: c74c5f5ceee3c8a5b8bc37719840dc3749f5b0306d818974141dda2471a1a2ca6c8e46b9d6ac222c5345df7a901c9b6f350b1e6d62763fec877e26609a401bfe languageName: node linkType: hard @@ -8561,8 +8409,8 @@ __metadata: version: 2.0.1 resolution: "hasown@npm:2.0.1" dependencies: - function-bind: ^1.1.2 - checksum: 9081c382a4fe8a62639a8da5c7d3322b203c319147e48783763dd741863d9f2dcaa743574fe2a1283871c445d8ba99ea45d5fff384e5ad27ca9dd7a367d79de0 + function-bind: "npm:^1.1.2" + checksum: b7f9107387ee68abed88e965c2b99e868b5e0e9d289db1ddd080706ffafb69533b4f538b0e6362585bae8d6cbd080249f65e79702f74c225990f66d6106be3f6 languageName: node linkType: hard @@ -8577,7 +8425,7 @@ __metadata: version: 3.1.1 resolution: "hast-util-parse-selector@npm:3.1.1" dependencies: - "@types/hast": ^2.0.0 + "@types/hast": "npm:^2.0.0" checksum: 511d373465f60dd65e924f88bf0954085f4fb6e3a2b062a4b5ac43b93cbfd36a8dce6234b5d1e3e63499d936375687e83fc5da55628b22bd6b581b5ee167d1c4 languageName: node linkType: hard @@ -8586,12 +8434,12 @@ __metadata: version: 6.0.0 resolution: "hastscript@npm:6.0.0" dependencies: - "@types/hast": ^2.0.0 - comma-separated-tokens: ^1.0.0 - hast-util-parse-selector: ^2.0.0 - property-information: ^5.0.0 - space-separated-tokens: ^1.0.0 - checksum: 5e50b85af0d2cb7c17979cb1ddca75d6b96b53019dd999b39e7833192c9004201c3cee6445065620ea05d0087d9ae147a4844e582d64868be5bc6b0232dfe52d + "@types/hast": "npm:^2.0.0" + comma-separated-tokens: "npm:^1.0.0" + hast-util-parse-selector: "npm:^2.0.0" + property-information: "npm:^5.0.0" + space-separated-tokens: "npm:^1.0.0" + checksum: 78f91b71e50506f7499c8275d67645f9f4f130e6f12b038853261d1fa7393432da4113baf3508c41b79d933f255089d6d593beea9d4cda89dfd34d0a498cf378 languageName: node linkType: hard @@ -8599,28 +8447,19 @@ __metadata: version: 7.2.0 resolution: "hastscript@npm:7.2.0" dependencies: - "@types/hast": ^2.0.0 - comma-separated-tokens: ^2.0.0 - hast-util-parse-selector: ^3.0.0 - property-information: ^6.0.0 - space-separated-tokens: ^2.0.0 - checksum: 928a21576ff7b9a8c945e7940bcbf2d27f770edb4279d4d04b33dc90753e26ca35c1172d626f54afebd377b2afa32331e399feb3eb0f7b91a399dca5927078ae - languageName: node - linkType: hard - -"he@npm:^1.1.1": - version: 1.2.0 - resolution: "he@npm:1.2.0" - bin: - he: bin/he - checksum: 3d4d6babccccd79c5c5a3f929a68af33360d6445587d628087f39a965079d84f18ce9c3d3f917ee1e3978916fc833bb8b29377c3b403f919426f91bc6965e7a7 + "@types/hast": "npm:^2.0.0" + comma-separated-tokens: "npm:^2.0.0" + hast-util-parse-selector: "npm:^3.0.0" + property-information: "npm:^6.0.0" + space-separated-tokens: "npm:^2.0.0" + checksum: 98740e0b69b4765a23d0174fb93eb1c1bdcae6a9f1c9e1b07de6aca2d578427a42e1d45ee98eda26463ac58ff73a8ce45af19c4eb8b5f6f768a9c8543964d28f languageName: node linkType: hard "highlight.js@npm:^10.4.1, highlight.js@npm:~10.7.0": version: 10.7.3 resolution: "highlight.js@npm:10.7.3" - checksum: defeafcd546b535d710d8efb8e650af9e3b369ef53e28c3dc7893eacfe263200bba4c5fcf43524ae66d5c0c296b1af0870523ceae3e3104d24b7abf6374a4fea + checksum: db8d10a541936b058e221dbde77869664b2b45bca75d660aa98065be2cd29f3924755fbc7348213f17fd931aefb6e6597448ba6fe82afba6d8313747a91983ee languageName: node linkType: hard @@ -8628,15 +8467,15 @@ __metadata: version: 3.3.2 resolution: "hoist-non-react-statics@npm:3.3.2" dependencies: - react-is: ^16.7.0 - checksum: b1538270429b13901ee586aa44f4cc3ecd8831c061d06cb8322e50ea17b3f5ce4d0e2e66394761e6c8e152cd8c34fb3b4b690116c6ce2bd45b18c746516cb9e8 + react-is: "npm:^16.7.0" + checksum: 1acbe85f33e5a39f90c822ad4d28b24daeb60f71c545279431dc98c312cd28a54f8d64788e477fe21dc502b0e3cf58589ebe5c1ad22af27245370391c2d24ea6 languageName: node linkType: hard "hosted-git-info@npm:^2.1.4": version: 2.8.9 resolution: "hosted-git-info@npm:2.8.9" - checksum: c955394bdab888a1e9bb10eb33029e0f7ce5a2ac7b3f158099dc8c486c99e73809dca609f5694b223920ca2174db33d32b12f9a2a47141dc59607c29da5a62dd + checksum: 96da7d412303704af41c3819207a09ea2cab2de97951db4cf336bb8bce8d8e36b9a6821036ad2e55e67d3be0af8f967a7b57981203fbfb88bc05cd803407b8c3 languageName: node linkType: hard @@ -8644,9 +8483,9 @@ __metadata: version: 5.0.8 resolution: "html-dom-parser@npm:5.0.8" dependencies: - domhandler: 5.0.3 - htmlparser2: 9.1.0 - checksum: f264d58665ab1c49e84b9aa28aad4366b5661a9ed742d40827315ba2b8a2915d16624ec517c743421e5543cc7eeb62c0cadfb07b1399d5f11fecf6611861b04d + domhandler: "npm:5.0.3" + htmlparser2: "npm:9.1.0" + checksum: fb1f67e151008abc00404e80d69d499ee365b7e8a1a618ad0ccaedfd28889c99fb8fcd0cf57464c7b0898784c28b9c0ef9704110d51bca6ff2e5367e5a0d13a3 languageName: node linkType: hard @@ -8654,15 +8493,15 @@ __metadata: version: 3.0.0 resolution: "html-encoding-sniffer@npm:3.0.0" dependencies: - whatwg-encoding: ^2.0.0 - checksum: 8d806aa00487e279e5ccb573366a951a9f68f65c90298eac9c3a2b440a7ffe46615aff2995a2f61c6746c639234e6179a97e18ca5ccbbf93d3725ef2099a4502 + whatwg-encoding: "npm:^2.0.0" + checksum: 707a812ec2acaf8bb5614c8618dc81e2fb6b4399d03e95ff18b65679989a072f4e919b9bef472039301a1bbfba64063ba4c79ea6e851c653ac9db80dbefe8fe5 languageName: node linkType: hard "html-escaper@npm:^2.0.0": version: 2.0.2 resolution: "html-escaper@npm:2.0.2" - checksum: d2df2da3ad40ca9ee3a39c5cc6475ef67c8f83c234475f24d8e9ce0dc80a2c82df8e1d6fa78ddd1e9022a586ea1bd247a615e80a5cd9273d90111ddda7d9e974 + checksum: 034d74029dcca544a34fb6135e98d427acd73019796ffc17383eaa3ec2fe1c0471dcbbc8f8ed39e46e86d43ccd753a160631615e4048285e313569609b66d5b7 languageName: node linkType: hard @@ -8670,24 +8509,24 @@ __metadata: version: 5.1.8 resolution: "html-react-parser@npm:5.1.8" dependencies: - domhandler: 5.0.3 - html-dom-parser: 5.0.8 - react-property: 2.0.2 - style-to-js: 1.1.10 + domhandler: "npm:5.0.3" + html-dom-parser: "npm:5.0.8" + react-property: "npm:2.0.2" + style-to-js: "npm:1.1.10" peerDependencies: "@types/react": 17 || 18 react: 0.14 || 15 || 16 || 17 || 18 peerDependenciesMeta: "@types/react": optional: true - checksum: 5de4e4215b6ca636cff9710dad6deff1db1b4b6ada1ae97c674e7ef7cdfeaac0835aeb6405f4a2681db3cd0a36d3b3456c6dd8134995bff680952c83a4b2ac19 + checksum: d52c121d043d20402c0c768d38f85f89dfa280191acad7367c5c86f7cbd9de67899856b6ff9fbc113143ce22756393dc0452851b9784cc8cd0473f2a813c47a6 languageName: node linkType: hard "html-tags@npm:^3.1.0": version: 3.3.1 resolution: "html-tags@npm:3.3.1" - checksum: b4ef1d5a76b678e43cce46e3783d563607b1d550cab30b4f511211564574770aa8c658a400b100e588bc60b8234e59b35ff72c7851cc28f3b5403b13a2c6cbce + checksum: d0e808544b92d8b999cbcc86d539577255a2f0f2f4f73110d10749d1d36e6fe6ad706a0355a8477afb6e000ecdc93d8455b3602951f9a2b694ac9e28f1b52878 languageName: node linkType: hard @@ -8695,32 +8534,18 @@ __metadata: version: 9.1.0 resolution: "htmlparser2@npm:9.1.0" dependencies: - domelementtype: ^2.3.0 - domhandler: ^5.0.3 - domutils: ^3.1.0 - entities: ^4.5.0 - checksum: e5f8d5193967e4a500226f37bdf2c0f858cecb39dde14d0439f24bf2c461a4342778740d988fbaba652b0e4cb6052f7f2e99e69fc1a329a86c629032bb76e7c8 - languageName: node - linkType: hard - -"htmlparser2@npm:^3.8.3": - version: 3.10.1 - resolution: "htmlparser2@npm:3.10.1" - dependencies: - domelementtype: ^1.3.1 - domhandler: ^2.3.0 - domutils: ^1.5.1 - entities: ^1.1.1 - inherits: ^2.0.1 - readable-stream: ^3.1.1 - checksum: 6875f7dd875aa10be17d9b130e3738cd8ed4010b1f2edaf4442c82dfafe9d9336b155870dcc39f38843cbf7fef5e4fcfdf0c4c1fd4db3a1b91a1e0ee8f6c3475 + domelementtype: "npm:^2.3.0" + domhandler: "npm:^5.0.3" + domutils: "npm:^3.1.0" + entities: "npm:^4.5.0" + checksum: 6352fa2a5495781fa9a02c9049908334cd068ff36d753870d30cd13b841e99c19646717567a2f9e9c44075bbe43d364e102f9d013a731ce962226d63746b794f languageName: node linkType: hard "http-cache-semantics@npm:^4.1.1": version: 4.1.1 resolution: "http-cache-semantics@npm:4.1.1" - checksum: 83ac0bc60b17a3a36f9953e7be55e5c8f41acc61b22583060e8dedc9dd5e3607c823a88d0926f9150e571f90946835c7fe150732801010845c72cd8bbff1a236 + checksum: 362d5ed66b12ceb9c0a328fb31200b590ab1b02f4a254a697dc796850cc4385603e75f53ec59f768b2dad3bfa1464bd229f7de278d2899a0e3beffc634b6683f languageName: node linkType: hard @@ -8728,12 +8553,12 @@ __metadata: version: 2.0.0 resolution: "http-errors@npm:2.0.0" dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.1 - toidentifier: 1.0.1 - checksum: 9b0a3782665c52ce9dc658a0d1560bcb0214ba5699e4ea15aefb2a496e2ca83db03ebc42e1cce4ac1f413e4e0d2d736a3fd755772c556a9a06853ba2a0b7d920 + depd: "npm:2.0.0" + inherits: "npm:2.0.4" + setprototypeof: "npm:1.2.0" + statuses: "npm:2.0.1" + toidentifier: "npm:1.0.1" + checksum: 0e7f76ee8ff8a33e58a3281a469815b893c41357378f408be8f6d4aa7d1efafb0da064625518e7078381b6a92325949b119dc38fcb30bdbc4e3a35f78c44c439 languageName: node linkType: hard @@ -8741,10 +8566,10 @@ __metadata: version: 5.0.0 resolution: "http-proxy-agent@npm:5.0.0" dependencies: - "@tootallnate/once": 2 - agent-base: 6 - debug: 4 - checksum: e2ee1ff1656a131953839b2a19cd1f3a52d97c25ba87bd2559af6ae87114abf60971e498021f9b73f9fd78aea8876d1fb0d4656aac8a03c6caa9fc175f22b786 + "@tootallnate/once": "npm:2" + agent-base: "npm:6" + debug: "npm:4" + checksum: 5ee19423bc3e0fd5f23ce991b0755699ad2a46a440ce9cec99e8126bb98448ad3479d2c0ea54be5519db5b19a4ffaa69616bac01540db18506dd4dac3dc418f0 languageName: node linkType: hard @@ -8752,9 +8577,9 @@ __metadata: version: 7.0.2 resolution: "http-proxy-agent@npm:7.0.2" dependencies: - agent-base: ^7.1.0 - debug: ^4.3.4 - checksum: 670858c8f8f3146db5889e1fa117630910101db601fff7d5a8aa637da0abedf68c899f03d3451cac2f83bcc4c3d2dabf339b3aa00ff8080571cceb02c3ce02f3 + agent-base: "npm:^7.1.0" + debug: "npm:^4.3.4" + checksum: d062acfa0cb82beeb558f1043c6ba770ea892b5fb7b28654dbc70ea2aeea55226dd34c02a294f6c1ca179a5aa483c4ea641846821b182edbd9cc5d89b54c6848 languageName: node linkType: hard @@ -8762,9 +8587,9 @@ __metadata: version: 4.0.0 resolution: "https-proxy-agent@npm:4.0.0" dependencies: - agent-base: 5 - debug: 4 - checksum: 19471d5aae3e747b1c98b17556647e2a1362e68220c6b19585a8527498f32e62e03c41d2872d059d8720d56846bd7460a80ac06f876bccfa786468ff40dd5eef + agent-base: "npm:5" + debug: "npm:4" + checksum: e90ca77ec10ef9987ad464853dfee744fb13fb02ad72f31c770ba09fb55675206a1de3c8b7e74d809fc00ed3baa7e01a48c569a419a675bfa3ef1ee975822b70 languageName: node linkType: hard @@ -8772,9 +8597,9 @@ __metadata: version: 5.0.1 resolution: "https-proxy-agent@npm:5.0.1" dependencies: - agent-base: 6 - debug: 4 - checksum: 571fccdf38184f05943e12d37d6ce38197becdd69e58d03f43637f7fa1269cf303a7d228aa27e5b27bbd3af8f09fd938e1c91dcfefff2df7ba77c20ed8dfc765 + agent-base: "npm:6" + debug: "npm:4" + checksum: f0dce7bdcac5e8eaa0be3c7368bb8836ed010fb5b6349ffb412b172a203efe8f807d9a6681319105ea1b6901e1972c7b5ea899672a7b9aad58309f766dcbe0df languageName: node linkType: hard @@ -8782,23 +8607,23 @@ __metadata: version: 7.0.4 resolution: "https-proxy-agent@npm:7.0.4" dependencies: - agent-base: ^7.0.2 - debug: 4 - checksum: daaab857a967a2519ddc724f91edbbd388d766ff141b9025b629f92b9408fc83cee8a27e11a907aede392938e9c398e240d643e178408a59e4073539cde8cfe9 + agent-base: "npm:^7.0.2" + debug: "npm:4" + checksum: 405fe582bba461bfe5c7e2f8d752b384036854488b828ae6df6a587c654299cbb2c50df38c4b6ab303502c3c5e029a793fbaac965d1e86ee0be03faceb554d63 languageName: node linkType: hard "human-signals@npm:^2.1.0": version: 2.1.0 resolution: "human-signals@npm:2.1.0" - checksum: b87fd89fce72391625271454e70f67fe405277415b48bcc0117ca73d31fa23a4241787afdc8d67f5a116cf37258c052f59ea82daffa72364d61351423848e3b8 + checksum: df59be9e0af479036798a881d1f136c4a29e0b518d4abb863afbd11bf30efa3eeb1d0425fc65942dcc05ab3bf40205ea436b0ff389f2cd20b75b8643d539bf86 languageName: node linkType: hard "human-signals@npm:^5.0.0": version: 5.0.0 resolution: "human-signals@npm:5.0.0" - checksum: 6504560d5ed91444f16bea3bd9dfc66110a339442084e56c3e7fa7bbdf3f406426d6563d662bdce67064b165eac31eeabfc0857ed170aaa612cf14ec9f9a464c + checksum: 30f8870d831cdcd2d6ec0486a7d35d49384996742052cee792854273fa9dd9e7d5db06bb7985d4953e337e10714e994e0302e90dc6848069171b05ec836d65b0 languageName: node linkType: hard @@ -8807,7 +8632,7 @@ __metadata: resolution: "hunspell-spellchecker@npm:1.0.2" bin: hunspell-tojson: ./bin/hunspell-tojson.js - checksum: 098e00a705b1b8975924ccef1b23e1d63acc139a493a392bb55f887926c518b2edae01f9a697cffa73346160c562f89610f80d354360a2cb637a67f7c54f8227 + checksum: 84f58980d354f63e81a630370d19cd36790770fb545d4e9025858ed40c0a641b90fae4925cd0b83f766eefafcedc239803c52c617dd8c63b428266bf2ff63a7d languageName: node linkType: hard @@ -8816,7 +8641,7 @@ __metadata: resolution: "husky@npm:9.0.11" bin: husky: bin.mjs - checksum: 1aebc3334dc7ac6288ff5e1fb72cfb447cfa474e72cf7ba692e8c5698c573ab725c28c6a5088c9f8e6aca5f47d40fa7261beffbc07a4d307ca21656dc4571f07 + checksum: 8a9b7cb9dc8494b470b3b47b386e65d579608c6206da80d3cc8b71d10e37947264af3dfe00092368dad9673b51d2a5ee87afb4b2291e77ba9e7ec1ac36e56cd1 languageName: node linkType: hard @@ -8824,8 +8649,8 @@ __metadata: version: 0.4.24 resolution: "iconv-lite@npm:0.4.24" dependencies: - safer-buffer: ">= 2.1.2 < 3" - checksum: bd9f120f5a5b306f0bc0b9ae1edeb1577161503f5f8252a20f1a9e56ef8775c9959fd01c55f2d3a39d9a8abaf3e30c1abeb1895f367dcbbe0a8fd1c9ca01c4f6 + safer-buffer: "npm:>= 2.1.2 < 3" + checksum: 6d3a2dac6e5d1fb126d25645c25c3a1209f70cceecc68b8ef51ae0da3cdc078c151fade7524a30b12a3094926336831fca09c666ef55b37e2c69638b5d6bd2e3 languageName: node linkType: hard @@ -8833,8 +8658,8 @@ __metadata: version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" dependencies: - safer-buffer: ">= 2.1.2 < 3.0.0" - checksum: 3f60d47a5c8fc3313317edfd29a00a692cc87a19cac0159e2ce711d0ebc9019064108323b5e493625e25594f11c6236647d8e256fbe7a58f4a3b33b89e6d30bf + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: 24e3292dd3dadaa81d065c6f8c41b274a47098150d444b96e5f53b4638a9a71482921ea6a91a1f59bb71d9796de25e04afd05919fa64c360347ba65d3766f10f languageName: node linkType: hard @@ -8842,45 +8667,36 @@ __metadata: version: 3.0.0 resolution: "identity-obj-proxy@npm:3.0.0" dependencies: - harmony-reflect: ^1.4.6 - checksum: 97559f8ea2aeaa1a880d279d8c49550dce01148321e00a2102cda5ddf9ce622fa1d7f3efc7bed63458af78889de888fdaebaf31c816312298bb3fdd0ef8aaf2c + harmony-reflect: "npm:^1.4.6" + checksum: 66fe4d2ffc67655174f6abe100ab3b36d2f5e4de5b28a7c3121e5f51bd4e7c8c1bee4f9a41ce0586ace57fb63bfedbfc39508b7cb43b9e3ed6dc42f762158b4e languageName: node linkType: hard "ieee754@npm:^1.1.13": version: 1.2.1 resolution: "ieee754@npm:1.2.1" - checksum: 5144c0c9815e54ada181d80a0b810221a253562422e7c6c3a60b1901154184f49326ec239d618c416c1c5945a2e197107aee8d986a3dd836b53dffefd99b5e7e + checksum: d9f2557a59036f16c282aaeb107832dc957a93d73397d89bbad4eb1130560560eb695060145e8e6b3b498b15ab95510226649a0b8f52ae06583575419fe10fc4 languageName: node linkType: hard "ignore@npm:^5.2.0, ignore@npm:^5.2.4": version: 5.3.1 resolution: "ignore@npm:5.3.1" - checksum: 71d7bb4c1dbe020f915fd881108cbe85a0db3d636a0ea3ba911393c53946711d13a9b1143c7e70db06d571a5822c0a324a6bcde5c9904e7ca5047f01f1bf8cd3 - languageName: node - linkType: hard - -"image-size@npm:^0.5.1": - version: 0.5.5 - resolution: "image-size@npm:0.5.5" - bin: - image-size: bin/image-size.js - checksum: 6709d5cb73e96d5097ae5e9aa746dd36d6a9c8cf645e7eecac72ea07dbd6f312a65183752762fa92e2f3b698d4ed8d85dd55bf5207b6367245996bd16576d8fe + checksum: 0a884c2fbc8c316f0b9f92beaf84464253b73230a4d4d286697be45fca081199191ca33e1c2e82d9e5f851f5e9a48a78e25a35c951e7eb41e59f150db3530065 languageName: node linkType: hard "immer@npm:^9.0.21": version: 9.0.21 resolution: "immer@npm:9.0.21" - checksum: 70e3c274165995352f6936695f0ef4723c52c92c92dd0e9afdfe008175af39fa28e76aafb3a2ca9d57d1fb8f796efc4dd1e1cc36f18d33fa5b74f3dfb0375432 + checksum: 8455d6b4dc8abfe40f06eeec9bcc944d147c81279424c0f927a4d4905ae34e5af19ab6da60bcc700c14f51c452867d7089b3b9236f5a9a2248e39b4a09ee89de languageName: node linkType: hard "immutable@npm:^4.0.0": version: 4.3.5 resolution: "immutable@npm:4.3.5" - checksum: 0e25dd5c314421faede9e1122ab26cdb638cc3edc8678c4a75dee104279b12621a30c80a480fae7f68bc7e81672f1e672e454dc0fdc7e6cf0af10809348387b8 + checksum: dbc1b8c808b9aa18bfce2e0c7bc23714a47267bc311f082145cc9220b2005e9b9cd2ae78330f164a19266a2b0f78846c60f4f74893853ac16fd68b5ae57092d2 languageName: node linkType: hard @@ -8888,8 +8704,8 @@ __metadata: version: 3.3.0 resolution: "import-fresh@npm:3.3.0" dependencies: - parent-module: ^1.0.0 - resolve-from: ^4.0.0 + parent-module: "npm:^1.0.0" + resolve-from: "npm:^4.0.0" checksum: 2cacfad06e652b1edc50be650f7ec3be08c5e5a6f6d12d035c440a42a8cc028e60a5b99ca08a77ab4d6b1346da7d971915828f33cdab730d3d42f08242d09baa languageName: node linkType: hard @@ -8898,8 +8714,8 @@ __metadata: version: 3.1.0 resolution: "import-local@npm:3.1.0" dependencies: - pkg-dir: ^4.2.0 - resolve-cwd: ^3.0.0 + pkg-dir: "npm:^4.2.0" + resolve-cwd: "npm:^3.0.0" bin: import-local-fixture: fixtures/cli.js checksum: bfcdb63b5e3c0e245e347f3107564035b128a414c4da1172a20dc67db2504e05ede4ac2eee1252359f78b0bfd7b19ef180aec427c2fce6493ae782d73a04cddd @@ -8909,14 +8725,14 @@ __metadata: "imurmurhash@npm:^0.1.4": version: 0.1.4 resolution: "imurmurhash@npm:0.1.4" - checksum: 7cae75c8cd9a50f57dadd77482359f659eaebac0319dd9368bcd1714f55e65badd6929ca58569da2b6494ef13fdd5598cd700b1eba23f8b79c5f19d195a3ecf7 + checksum: 2d30b157a91fe1c1d7c6f653cbf263f039be6c5bfa959245a16d4ee191fc0f2af86c08545b6e6beeb041c56b574d2d5b9f95343d378ab49c0f37394d541e7fc8 languageName: node linkType: hard "indent-string@npm:^4.0.0": version: 4.0.0 resolution: "indent-string@npm:4.0.0" - checksum: 824cfb9929d031dabf059bebfe08cf3137365e112019086ed3dcff6a0a7b698cb80cf67ccccde0e25b9e2d7527aa6cc1fed1ac490c752162496caba3e6699612 + checksum: cd3f5cbc9ca2d624c6a1f53f12e6b341659aba0e2d3254ae2b4464aaea8b4294cdb09616abbc59458f980531f2429784ed6a420d48d245bcad0811980c9efae9 languageName: node linkType: hard @@ -8924,23 +8740,23 @@ __metadata: version: 1.0.6 resolution: "inflight@npm:1.0.6" dependencies: - once: ^1.3.0 - wrappy: 1 - checksum: f4f76aa072ce19fae87ce1ef7d221e709afb59d445e05d47fba710e85470923a75de35bfae47da6de1b18afc3ce83d70facf44cfb0aff89f0a3f45c0a0244dfd + once: "npm:^1.3.0" + wrappy: "npm:1" + checksum: d2ebd65441a38c8336c223d1b80b921b9fa737e37ea466fd7e253cb000c64ae1f17fa59e68130ef5bda92cfd8d36b83d37dab0eb0a4558bcfec8e8cdfd2dcb67 languageName: node linkType: hard "inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3": version: 2.0.4 resolution: "inherits@npm:2.0.4" - checksum: 4a48a733847879d6cf6691860a6b1e3f0f4754176e4d71494c41f3475553768b10f84b5ce1d40fbd0e34e6bfbb864ee35858ad4dd2cf31e02fc4a154b724d7f1 + checksum: cd45e923bee15186c07fa4c89db0aace24824c482fb887b528304694b2aa6ff8a898da8657046a5dcf3e46cd6db6c61629551f9215f208d7c3f157cf9b290521 languageName: node linkType: hard "inline-style-parser@npm:0.2.2": version: 0.2.2 resolution: "inline-style-parser@npm:0.2.2" - checksum: 698893d6542d4e7c0377936a1c7daec34a197765bd77c5599384756a95ce8804e6b79347b783aa591d5e9c6f3d33dae74c6d4cad3a94647eb05f3a785e927a3f + checksum: 352b1b9a691113033fc72e67b906244713551dc497d7e12791034668fe7d9e4c9e74eb8c251183d6225d3a263d0bcea911b9ca6281dec0413f6e2465ee8fbc2e languageName: node linkType: hard @@ -8948,10 +8764,10 @@ __metadata: version: 1.0.7 resolution: "internal-slot@npm:1.0.7" dependencies: - es-errors: ^1.3.0 - hasown: ^2.0.0 - side-channel: ^1.0.4 - checksum: cadc5eea5d7d9bc2342e93aae9f31f04c196afebb11bde97448327049f492cd7081e18623ae71388aac9cd237b692ca3a105be9c68ac39c1dec679d7409e33eb + es-errors: "npm:^1.3.0" + hasown: "npm:^2.0.0" + side-channel: "npm:^1.0.4" + checksum: 3e66720508831153ecf37d13def9f6856f9f2960989ec8a0a0476c98f887fca9eff0163127466485cb825c900c2d6fc601aa9117b7783b90ffce23a71ea5d053 languageName: node linkType: hard @@ -8959,7 +8775,7 @@ __metadata: version: 2.2.4 resolution: "invariant@npm:2.2.4" dependencies: - loose-envify: ^1.0.0 + loose-envify: "npm:^1.0.0" checksum: cc3182d793aad82a8d1f0af697b462939cb46066ec48bbf1707c150ad5fad6406137e91a262022c269702e01621f35ef60269f6c0d7fd178487959809acdfb14 languageName: node linkType: hard @@ -8968,23 +8784,23 @@ __metadata: version: 9.0.5 resolution: "ip-address@npm:9.0.5" dependencies: - jsbn: 1.1.0 - sprintf-js: ^1.1.3 - checksum: aa15f12cfd0ef5e38349744e3654bae649a34c3b10c77a674a167e99925d1549486c5b14730eebce9fea26f6db9d5e42097b00aa4f9f612e68c79121c71652dc + jsbn: "npm:1.1.0" + sprintf-js: "npm:^1.1.3" + checksum: 1ed81e06721af012306329b31f532b5e24e00cb537be18ddc905a84f19fe8f83a09a1699862bf3a1ec4b9dea93c55a3fa5faf8b5ea380431469df540f38b092c languageName: node linkType: hard "ip@npm:^2.0.1": version: 2.0.1 resolution: "ip@npm:2.0.1" - checksum: d765c9fd212b8a99023a4cde6a558a054c298d640fec1020567494d257afd78ca77e37126b1a3ef0e053646ced79a816bf50621d38d5e768cdde0431fa3b0d35 + checksum: d6dd154e1bc5e8725adfdd6fb92218635b9cbe6d873d051bd63b178f009777f751a5eea4c67021723a7056325fc3052f8b6599af0a2d56f042c93e684b4a0349 languageName: node linkType: hard "ipaddr.js@npm:1.9.1": version: 1.9.1 resolution: "ipaddr.js@npm:1.9.1" - checksum: f88d3825981486f5a1942414c8d77dd6674dd71c065adcfa46f578d677edcb99fda25af42675cb59db492fdf427b34a5abfcde3982da11a8fd83a500b41cfe77 + checksum: 864d0cced0c0832700e9621913a6429ccdc67f37c1bd78fb8c6789fff35c9d167cb329134acad2290497a53336813ab4798d2794fd675d5eb33b5fdf0982b9ca languageName: node linkType: hard @@ -8995,15 +8811,6 @@ __metadata: languageName: node linkType: hard -"is-accessor-descriptor@npm:^1.0.1": - version: 1.0.1 - resolution: "is-accessor-descriptor@npm:1.0.1" - dependencies: - hasown: ^2.0.0 - checksum: 8db44c02230a5e9b9dec390a343178791f073d5d5556a400527d2fd67a72d93b226abab2bd4123305c268f5dc22831bfdbd38430441fda82ea9e0b95ddc6b267 - languageName: node - linkType: hard - "is-alphabetical@npm:^1.0.0": version: 1.0.4 resolution: "is-alphabetical@npm:1.0.4" @@ -9022,8 +8829,8 @@ __metadata: version: 1.0.4 resolution: "is-alphanumerical@npm:1.0.4" dependencies: - is-alphabetical: ^1.0.0 - is-decimal: ^1.0.0 + is-alphabetical: "npm:^1.0.0" + is-decimal: "npm:^1.0.0" checksum: e2e491acc16fcf5b363f7c726f666a9538dba0a043665740feb45bba1652457a73441e7c5179c6768a638ed396db3437e9905f403644ec7c468fb41f4813d03f languageName: node linkType: hard @@ -9032,8 +8839,8 @@ __metadata: version: 2.0.1 resolution: "is-alphanumerical@npm:2.0.1" dependencies: - is-alphabetical: ^2.0.0 - is-decimal: ^2.0.0 + is-alphabetical: "npm:^2.0.0" + is-decimal: "npm:^2.0.0" checksum: 87acc068008d4c9c4e9f5bd5e251041d42e7a50995c77b1499cf6ed248f971aadeddb11f239cabf09f7975ee58cac7a48ffc170b7890076d8d227b24a68663c9 languageName: node linkType: hard @@ -9042,9 +8849,9 @@ __metadata: version: 1.1.1 resolution: "is-arguments@npm:1.1.1" dependencies: - call-bind: ^1.0.2 - has-tostringtag: ^1.0.0 - checksum: 7f02700ec2171b691ef3e4d0e3e6c0ba408e8434368504bb593d0d7c891c0dbfda6d19d30808b904a6cb1929bca648c061ba438c39f296c2a8ca083229c49f27 + call-bind: "npm:^1.0.2" + has-tostringtag: "npm:^1.0.0" + checksum: a170c7e26082e10de9be6e96d32ae3db4d5906194051b792e85fae3393b53cf2cb5b3557863e5c8ccbab55e2fd8f2f75aa643d437613f72052cf0356615c34be languageName: node linkType: hard @@ -9052,16 +8859,23 @@ __metadata: version: 3.0.4 resolution: "is-array-buffer@npm:3.0.4" dependencies: - call-bind: ^1.0.2 - get-intrinsic: ^1.2.1 - checksum: e4e3e6ef0ff2239e75371d221f74bc3c26a03564a22efb39f6bb02609b598917ddeecef4e8c877df2a25888f247a98198959842a5e73236bc7f22cabdf6351a7 + call-bind: "npm:^1.0.2" + get-intrinsic: "npm:^1.2.1" + checksum: 34a26213d981d58b30724ef37a1e0682f4040d580fa9ff58fdfdd3cefcb2287921718c63971c1c404951e7b747c50fdc7caf6e867e951353fa71b369c04c969b languageName: node linkType: hard "is-arrayish@npm:^0.2.1": version: 0.2.1 resolution: "is-arrayish@npm:0.2.1" - checksum: eef4417e3c10e60e2c810b6084942b3ead455af16c4509959a27e490e7aee87cfb3f38e01bbde92220b528a0ee1a18d52b787e1458ee86174d8c7f0e58cd488f + checksum: 73ced84fa35e59e2c57da2d01e12cd01479f381d7f122ce41dcbb713f09dbfc651315832cd2bf8accba7681a69e4d6f1e03941d94dd10040d415086360e7005e + languageName: node + linkType: hard + +"is-arrayish@npm:^0.3.1": + version: 0.3.2 + resolution: "is-arrayish@npm:0.3.2" + checksum: 81a78d518ebd8b834523e25d102684ee0f7e98637136d3bdc93fd09636350fa06f1d8ca997ea28143d4d13cb1b69c0824f082db0ac13e1ab3311c10ffea60ade languageName: node linkType: hard @@ -9069,8 +8883,8 @@ __metadata: version: 2.0.0 resolution: "is-async-function@npm:2.0.0" dependencies: - has-tostringtag: ^1.0.0 - checksum: e3471d95e6c014bf37cad8a93f2f4b6aac962178e0a5041e8903147166964fdc1c5c1d2ef87e86d77322c370ca18f2ea004fa7420581fa747bcaf7c223069dbd + has-tostringtag: "npm:^1.0.0" + checksum: 2cf336fbf8cba3badcf526aa3d10384c30bab32615ac4831b74492eb4e843ccb7d8439a119c27f84bcf217d72024e611b1373f870f433b48f3fa57d3d1b863f1 languageName: node linkType: hard @@ -9078,8 +8892,8 @@ __metadata: version: 1.0.4 resolution: "is-bigint@npm:1.0.4" dependencies: - has-bigints: ^1.0.1 - checksum: c56edfe09b1154f8668e53ebe8252b6f185ee852a50f9b41e8d921cb2bed425652049fbe438723f6cb48a63ca1aa051e948e7e401e093477c99c84eba244f666 + has-bigints: "npm:^1.0.1" + checksum: cc981cf0564c503aaccc1e5f39e994ae16ae2d1a8fcd14721f14ad431809071f39ec568cfceef901cff408045f1a6d6bac90d1b43eeb0b8e3bc34c8eb1bdb4c4 languageName: node linkType: hard @@ -9087,8 +8901,8 @@ __metadata: version: 2.1.0 resolution: "is-binary-path@npm:2.1.0" dependencies: - binary-extensions: ^2.0.0 - checksum: 84192eb88cff70d320426f35ecd63c3d6d495da9d805b19bc65b518984b7c0760280e57dbf119b7e9be6b161784a5a673ab2c6abe83abb5198a432232ad5b35c + binary-extensions: "npm:^2.0.0" + checksum: 078e51b4f956c2c5fd2b26bb2672c3ccf7e1faff38e0ebdba45612265f4e3d9fc3127a1fa8370bbf09eab61339203c3d3b7af5662cbf8be4030f8fac37745b0e languageName: node linkType: hard @@ -9096,23 +8910,16 @@ __metadata: version: 1.1.2 resolution: "is-boolean-object@npm:1.1.2" dependencies: - call-bind: ^1.0.2 - has-tostringtag: ^1.0.0 - checksum: c03b23dbaacadc18940defb12c1c0e3aaece7553ef58b162a0f6bba0c2a7e1551b59f365b91e00d2dbac0522392d576ef322628cb1d036a0fe51eb466db67222 - languageName: node - linkType: hard - -"is-buffer@npm:^1.1.5": - version: 1.1.6 - resolution: "is-buffer@npm:1.1.6" - checksum: 4a186d995d8bbf9153b4bd9ff9fd04ae75068fe695d29025d25e592d9488911eeece84eefbd8fa41b8ddcc0711058a71d4c466dcf6f1f6e1d83830052d8ca707 + call-bind: "npm:^1.0.2" + has-tostringtag: "npm:^1.0.0" + checksum: ba794223b56a49a9f185e945eeeb6b7833b8ea52a335cec087d08196cf27b538940001615d3bb976511287cefe94e5907d55f00bb49580533f9ca9b4515fcc2e languageName: node linkType: hard "is-callable@npm:^1.1.3, is-callable@npm:^1.1.4, is-callable@npm:^1.2.7": version: 1.2.7 resolution: "is-callable@npm:1.2.7" - checksum: 61fd57d03b0d984e2ed3720fb1c7a897827ea174bd44402878e059542ea8c4aeedee0ea0985998aa5cc2736b2fa6e271c08587addb5b3959ac52cf665173d1ac + checksum: 48a9297fb92c99e9df48706241a189da362bff3003354aea4048bd5f7b2eb0d823cd16d0a383cece3d76166ba16d85d9659165ac6fcce1ac12e6c649d66dbdb9 languageName: node linkType: hard @@ -9120,17 +8927,8 @@ __metadata: version: 2.13.1 resolution: "is-core-module@npm:2.13.1" dependencies: - hasown: ^2.0.0 - checksum: 256559ee8a9488af90e4bad16f5583c6d59e92f0742e9e8bb4331e758521ee86b810b93bae44f390766ffbc518a0488b18d9dab7da9a5ff997d499efc9403f7c - languageName: node - linkType: hard - -"is-data-descriptor@npm:^1.0.1": - version: 1.0.1 - resolution: "is-data-descriptor@npm:1.0.1" - dependencies: - hasown: ^2.0.0 - checksum: fc6da5be5177149d554c5612cc382e9549418ed72f2d3ed5a3e6511b03dd119ae1b2258320ca94931df50b7e9ee012894eccd4ca45bbcadf0d5b27da6faeb15a + hasown: "npm:^2.0.0" + checksum: d53bd0cc24b0a0351fb4b206ee3908f71b9bbf1c47e9c9e14e5f06d292af1663704d2abd7e67700d6487b2b7864e0d0f6f10a1edf1892864bdffcb197d1845a2 languageName: node linkType: hard @@ -9138,8 +8936,8 @@ __metadata: version: 1.0.5 resolution: "is-date-object@npm:1.0.5" dependencies: - has-tostringtag: ^1.0.0 - checksum: baa9077cdf15eb7b58c79398604ca57379b2fc4cf9aa7a9b9e295278648f628c9b201400c01c5e0f7afae56507d741185730307cbe7cad3b9f90a77e5ee342fc + has-tostringtag: "npm:^1.0.0" + checksum: cc80b3a4b42238fa0d358b9a6230dae40548b349e64a477cb7c5eff9b176ba194c11f8321daaf6dd157e44073e9b7fd01f87db1f14952a88d5657acdcd3a56e2 languageName: node linkType: hard @@ -9164,26 +8962,6 @@ __metadata: languageName: node linkType: hard -"is-descriptor@npm:^0.1.0": - version: 0.1.7 - resolution: "is-descriptor@npm:0.1.7" - dependencies: - is-accessor-descriptor: ^1.0.1 - is-data-descriptor: ^1.0.1 - checksum: 45743109f0bb03f9fa989c34d31ece87cc15792649f147b896a7c4db2906a02fca685867619f4d312e024d7bbd53b945a47c6830d01f5e73efcc6388ac211963 - languageName: node - linkType: hard - -"is-descriptor@npm:^1.0.0, is-descriptor@npm:^1.0.2": - version: 1.0.3 - resolution: "is-descriptor@npm:1.0.3" - dependencies: - is-accessor-descriptor: ^1.0.1 - is-data-descriptor: ^1.0.1 - checksum: 316153b2fd86ac23b0a2f28b77744ae0a4e3c7a54fe52fa70b125d0971eb0a3bcfb562fa8e74537af0dad5bc405cc606726eb501fc748a241c10910deea89cfb - languageName: node - linkType: hard - "is-docker@npm:^2.0.0, is-docker@npm:^2.1.1": version: 2.2.1 resolution: "is-docker@npm:2.2.1" @@ -9193,22 +8971,6 @@ __metadata: languageName: node linkType: hard -"is-extendable@npm:^0.1.0, is-extendable@npm:^0.1.1": - version: 0.1.1 - resolution: "is-extendable@npm:0.1.1" - checksum: 3875571d20a7563772ecc7a5f36cb03167e9be31ad259041b4a8f73f33f885441f778cee1f1fe0085eb4bc71679b9d8c923690003a36a6a5fdf8023e6e3f0672 - languageName: node - linkType: hard - -"is-extendable@npm:^1.0.1": - version: 1.0.1 - resolution: "is-extendable@npm:1.0.1" - dependencies: - is-plain-object: ^2.0.4 - checksum: db07bc1e9de6170de70eff7001943691f05b9d1547730b11be01c0ebfe67362912ba743cf4be6fd20a5e03b4180c685dad80b7c509fe717037e3eee30ad8e84f - languageName: node - linkType: hard - "is-extglob@npm:^2.1.1": version: 2.1.1 resolution: "is-extglob@npm:2.1.1" @@ -9220,8 +8982,8 @@ __metadata: version: 1.0.2 resolution: "is-finalizationregistry@npm:1.0.2" dependencies: - call-bind: ^1.0.2 - checksum: 4f243a8e06228cd45bdab8608d2cb7abfc20f6f0189c8ac21ea8d603f1f196eabd531ce0bb8e08cbab047e9845ef2c191a3761c9a17ad5cabf8b35499c4ad35d + call-bind: "npm:^1.0.2" + checksum: 1b8e9e1bf2075e862315ef9d38ce6d39c43ca9d81d46f73b34473506992f4b0fbaadb47ec9b420a5e76afe3f564d9f1f0d9b552ef272cc2395e0f21d743c9c29 languageName: node linkType: hard @@ -9243,8 +9005,8 @@ __metadata: version: 1.0.10 resolution: "is-generator-function@npm:1.0.10" dependencies: - has-tostringtag: ^1.0.0 - checksum: d54644e7dbaccef15ceb1e5d91d680eb5068c9ee9f9eb0a9e04173eb5542c9b51b5ab52c5537f5703e48d5fddfd376817c1ca07a84a407b7115b769d4bdde72b + has-tostringtag: "npm:^1.0.0" + checksum: 499a3ce6361064c3bd27fbff5c8000212d48506ebe1977842bbd7b3e708832d0deb1f4cc69186ece3640770e8c4f1287b24d99588a0b8058b2dbdd344bc1f47f languageName: node linkType: hard @@ -9252,8 +9014,8 @@ __metadata: version: 4.0.3 resolution: "is-glob@npm:4.0.3" dependencies: - is-extglob: ^2.1.1 - checksum: d381c1319fcb69d341cc6e6c7cd588e17cd94722d9a32dbd60660b993c4fb7d0f19438674e68dfec686d09b7c73139c9166b47597f846af387450224a8101ab4 + is-extglob: "npm:^2.1.1" + checksum: 3ed74f2b0cdf4f401f38edb0442ddfde3092d79d7d35c9919c86641efdbcbb32e45aa3c0f70ce5eecc946896cd5a0f26e4188b9f2b881876f7cb6c505b82da11 languageName: node linkType: hard @@ -9295,7 +9057,7 @@ __metadata: "is-map@npm:^2.0.1, is-map@npm:^2.0.2": version: 2.0.2 resolution: "is-map@npm:2.0.2" - checksum: ace3d0ecd667bbdefdb1852de601268f67f2db725624b1958f279316e13fecb8fa7df91fd60f690d7417b4ec180712f5a7ee967008e27c65cfd475cc84337728 + checksum: 60ba910f835f2eacb1fdf5b5a6c60fe1c702d012a7673e6546992bcc0c873f62ada6e13d327f9e48f1720d49c152d6cdecae1fa47a261ef3d247c3ce6f0e1d39 languageName: node linkType: hard @@ -9303,16 +9065,16 @@ __metadata: version: 1.3.2 resolution: "is-nan@npm:1.3.2" dependencies: - call-bind: ^1.0.0 - define-properties: ^1.1.3 - checksum: 5dfadcef6ad12d3029d43643d9800adbba21cf3ce2ec849f734b0e14ee8da4070d82b15fdb35138716d02587c6578225b9a22779cab34888a139cc43e4e3610a + call-bind: "npm:^1.0.0" + define-properties: "npm:^1.1.3" + checksum: 1f784d3472c09bc2e47acba7ffd4f6c93b0394479aa613311dc1d70f1bfa72eb0846c81350967722c959ba65811bae222204d6c65856fdce68f31986140c7b0e languageName: node linkType: hard "is-negative-zero@npm:^2.0.2": version: 2.0.3 resolution: "is-negative-zero@npm:2.0.3" - checksum: c1e6b23d2070c0539d7b36022d5a94407132411d01aba39ec549af824231f3804b1aea90b5e4e58e807a65d23ceb538ed6e355ce76b267bdd86edb757ffcbdcd + checksum: 8fe5cffd8d4fb2ec7b49d657e1691889778d037494c6f40f4d1a524cadd658b4b53ad7b6b73a59bcb4b143ae9a3d15829af864b2c0f9d65ac1e678c4c80f17e5 languageName: node linkType: hard @@ -9320,24 +9082,15 @@ __metadata: version: 1.0.7 resolution: "is-number-object@npm:1.0.7" dependencies: - has-tostringtag: ^1.0.0 - checksum: d1e8d01bb0a7134c74649c4e62da0c6118a0bfc6771ea3c560914d52a627873e6920dd0fd0ebc0e12ad2ff4687eac4c308f7e80320b973b2c8a2c8f97a7524f7 - languageName: node - linkType: hard - -"is-number@npm:^3.0.0": - version: 3.0.0 - resolution: "is-number@npm:3.0.0" - dependencies: - kind-of: ^3.0.2 - checksum: 0c62bf8e9d72c4dd203a74d8cfc751c746e75513380fef420cda8237e619a988ee43e678ddb23c87ac24d91ac0fe9f22e4ffb1301a50310c697e9d73ca3994e9 + has-tostringtag: "npm:^1.0.0" + checksum: 8700dcf7f602e0a9625830541345b8615d04953655acbf5c6d379c58eb1af1465e71227e95d501343346e1d49b6f2d53cbc166b1fc686a7ec19151272df582f9 languageName: node linkType: hard "is-number@npm:^7.0.0": version: 7.0.0 resolution: "is-number@npm:7.0.0" - checksum: 456ac6f8e0f3111ed34668a624e45315201dff921e5ac181f8ec24923b99e9f32ca1a194912dc79d539c97d33dba17dc635202ff0b2cf98326f608323276d27a + checksum: 6a6c3383f68afa1e05b286af866017c78f1226d43ac8cb064e115ff9ed85eb33f5c4f7216c96a71e4dfea289ef52c5da3aef5bbfade8ffe47a0465d70c0c8e86 languageName: node linkType: hard @@ -9355,13 +9108,6 @@ __metadata: languageName: node linkType: hard -"is-plain-obj@npm:^1.1": - version: 1.1.0 - resolution: "is-plain-obj@npm:1.1.0" - checksum: 0ee04807797aad50859652a7467481816cbb57e5cc97d813a7dcd8915da8195dc68c436010bf39d195226cde6a2d352f4b815f16f26b7bf486a5754290629931 - languageName: node - linkType: hard - "is-plain-object@npm:5.0.0": version: 5.0.0 resolution: "is-plain-object@npm:5.0.0" @@ -9369,11 +9115,11 @@ __metadata: languageName: node linkType: hard -"is-plain-object@npm:^2.0.3, is-plain-object@npm:^2.0.4": +"is-plain-object@npm:^2.0.4": version: 2.0.4 resolution: "is-plain-object@npm:2.0.4" dependencies: - isobject: ^3.0.1 + isobject: "npm:^3.0.1" checksum: 2a401140cfd86cabe25214956ae2cfee6fbd8186809555cd0e84574f88de7b17abacb2e477a6a658fa54c6083ecbda1e6ae404c7720244cd198903848fca70ca languageName: node linkType: hard @@ -9389,16 +9135,16 @@ __metadata: version: 1.1.4 resolution: "is-regex@npm:1.1.4" dependencies: - call-bind: ^1.0.2 - has-tostringtag: ^1.0.0 - checksum: 362399b33535bc8f386d96c45c9feb04cf7f8b41c182f54174c1a45c9abbbe5e31290bbad09a458583ff6bf3b2048672cdb1881b13289569a7c548370856a652 + call-bind: "npm:^1.0.2" + has-tostringtag: "npm:^1.0.0" + checksum: 36d9174d16d520b489a5e9001d7d8d8624103b387be300c50f860d9414556d0485d74a612fdafc6ebbd5c89213d947dcc6b6bff6b2312093f71ea03cbb19e564 languageName: node linkType: hard "is-set@npm:^2.0.1, is-set@npm:^2.0.2": version: 2.0.2 resolution: "is-set@npm:2.0.2" - checksum: b64343faf45e9387b97a6fd32be632ee7b269bd8183701f3b3f5b71a7cf00d04450ed8669d0bd08753e08b968beda96fca73a10fd0ff56a32603f64deba55a57 + checksum: d89e82acdc7760993474f529e043f9c4a1d63ed4774d21cc2e331d0e401e5c91c27743cd7c889137028f6a742234759a4bd602368fbdbf0b0321994aefd5603f languageName: node linkType: hard @@ -9406,8 +9152,8 @@ __metadata: version: 1.0.3 resolution: "is-shared-array-buffer@npm:1.0.3" dependencies: - call-bind: ^1.0.7 - checksum: a4fff602c309e64ccaa83b859255a43bb011145a42d3f56f67d9268b55bc7e6d98a5981a1d834186ad3105d6739d21547083fe7259c76c0468483fc538e716d8 + call-bind: "npm:^1.0.7" + checksum: bc5402900dc62b96ebb2548bf5b0a0bcfacc2db122236fe3ab3b3e3c884293a0d5eb777e73f059bcbf8dc8563bb65eae972fee0fb97e38a9ae27c8678f62bcfe languageName: node linkType: hard @@ -9429,8 +9175,8 @@ __metadata: version: 1.0.7 resolution: "is-string@npm:1.0.7" dependencies: - has-tostringtag: ^1.0.0 - checksum: 323b3d04622f78d45077cf89aab783b2f49d24dc641aa89b5ad1a72114cfeff2585efc8c12ef42466dff32bde93d839ad321b26884cf75e5a7892a938b089989 + has-tostringtag: "npm:^1.0.0" + checksum: 2bc292fe927493fb6dfc3338c099c3efdc41f635727c6ebccf704aeb2a27bca7acb9ce6fd34d103db78692b10b22111a8891de26e12bfa1c5e11e263c99d1fef languageName: node linkType: hard @@ -9438,8 +9184,8 @@ __metadata: version: 1.0.4 resolution: "is-symbol@npm:1.0.4" dependencies: - has-symbols: ^1.0.2 - checksum: 92805812ef590738d9de49d677cd17dfd486794773fb6fa0032d16452af46e9b91bb43ffe82c983570f015b37136f4b53b28b8523bfb10b0ece7a66c31a54510 + has-symbols: "npm:^1.0.2" + checksum: a47dd899a84322528b71318a89db25c7ecdec73197182dad291df15ffea501e17e3c92c8de0bfb50e63402747399981a687b31c519971b1fa1a27413612be929 languageName: node linkType: hard @@ -9447,8 +9193,8 @@ __metadata: version: 1.1.13 resolution: "is-typed-array@npm:1.1.13" dependencies: - which-typed-array: ^1.1.14 - checksum: 150f9ada183a61554c91e1c4290086d2c100b0dff45f60b028519be72a8db964da403c48760723bf5253979b8dffe7b544246e0e5351dcd05c5fdb1dcc1dc0f0 + which-typed-array: "npm:^1.1.14" + checksum: f850ba08286358b9a11aee6d93d371a45e3c59b5953549ee1c1a9a55ba5c1dd1bd9952488ae194ad8f32a9cf5e79c8fa5f0cc4d78c00720aa0bbcf238b38062d languageName: node linkType: hard @@ -9462,7 +9208,7 @@ __metadata: "is-weakmap@npm:^2.0.1": version: 2.0.1 resolution: "is-weakmap@npm:2.0.1" - checksum: 1222bb7e90c32bdb949226e66d26cb7bce12e1e28e3e1b40bfa6b390ba3e08192a8664a703dff2a00a84825f4e022f9cd58c4599ff9981ab72b1d69479f4f7f6 + checksum: 289fa4e8ba1bdda40ca78481266f6925b7c46a85599e6a41a77010bf91e5a24dfb660db96863bbf655ecdbda0ab517204d6a4e0c151dbec9d022c556321f3776 languageName: node linkType: hard @@ -9470,8 +9216,8 @@ __metadata: version: 1.0.2 resolution: "is-weakref@npm:1.0.2" dependencies: - call-bind: ^1.0.2 - checksum: 95bd9a57cdcb58c63b1c401c60a474b0f45b94719c30f548c891860f051bc2231575c290a6b420c6bc6e7ed99459d424c652bd5bf9a1d5259505dc35b4bf83de + call-bind: "npm:^1.0.2" + checksum: 0023fd0e4bdf9c338438ffbe1eed7ebbbff7e7e18fb7cdc227caaf9d4bd024a2dcdf6a8c9f40c92192022eac8391243bb9e66cccebecbf6fe1d8a366108f8513 languageName: node linkType: hard @@ -9479,16 +9225,9 @@ __metadata: version: 2.0.2 resolution: "is-weakset@npm:2.0.2" dependencies: - call-bind: ^1.0.2 - get-intrinsic: ^1.1.1 - checksum: 5d8698d1fa599a0635d7ca85be9c26d547b317ed8fd83fc75f03efbe75d50001b5eececb1e9971de85fcde84f69ae6f8346bc92d20d55d46201d328e4c74a367 - languageName: node - linkType: hard - -"is-windows@npm:^1.0.2": - version: 1.0.2 - resolution: "is-windows@npm:1.0.2" - checksum: 438b7e52656fe3b9b293b180defb4e448088e7023a523ec21a91a80b9ff8cdb3377ddb5b6e60f7c7de4fa8b63ab56e121b6705fe081b3cf1b828b0a380009ad7 + call-bind: "npm:^1.0.2" + get-intrinsic: "npm:^1.1.1" + checksum: 8f2ddb9639716fd7936784e175ea1183c5c4c05274c34f34f6a53175313cb1c9c35a8b795623306995e2f7cc8f25aa46302f15a2113e51c5052d447be427195c languageName: node linkType: hard @@ -9496,29 +9235,29 @@ __metadata: version: 2.2.0 resolution: "is-wsl@npm:2.2.0" dependencies: - is-docker: ^2.0.0 + is-docker: "npm:^2.0.0" checksum: 20849846ae414997d290b75e16868e5261e86ff5047f104027026fd61d8b5a9b0b3ade16239f35e1a067b3c7cc02f70183cb661010ed16f4b6c7c93dad1b19d8 languageName: node linkType: hard -"isarray@npm:1.0.0, isarray@npm:~1.0.0": - version: 1.0.0 - resolution: "isarray@npm:1.0.0" - checksum: f032df8e02dce8ec565cf2eb605ea939bdccea528dbcf565cdf92bfa2da9110461159d86a537388ef1acef8815a330642d7885b29010e8f7eac967c9993b65ab - languageName: node - linkType: hard - "isarray@npm:^2.0.5": version: 2.0.5 resolution: "isarray@npm:2.0.5" - checksum: bd5bbe4104438c4196ba58a54650116007fa0262eccef13a4c55b2e09a5b36b59f1e75b9fcc49883dd9d4953892e6fc007eef9e9155648ceea036e184b0f930a + checksum: 1d8bc7911e13bb9f105b1b3e0b396c787a9e63046af0b8fe0ab1414488ab06b2b099b87a2d8a9e31d21c9a6fad773c7fc8b257c4880f2d957274479d28ca3414 + languageName: node + linkType: hard + +"isarray@npm:~1.0.0": + version: 1.0.0 + resolution: "isarray@npm:1.0.0" + checksum: f032df8e02dce8ec565cf2eb605ea939bdccea528dbcf565cdf92bfa2da9110461159d86a537388ef1acef8815a330642d7885b29010e8f7eac967c9993b65ab languageName: node linkType: hard "isexe@npm:^2.0.0": version: 2.0.0 resolution: "isexe@npm:2.0.0" - checksum: 26bf6c5480dda5161c820c5b5c751ae1e766c587b1f951ea3fcfc973bafb7831ae5b54a31a69bd670220e42e99ec154475025a468eae58ea262f813fdc8d1c62 + checksum: 7c9f715c03aff08f35e98b1fadae1b9267b38f0615d501824f9743f3aab99ef10e303ce7db3f186763a0b70a19de5791ebfc854ff884d5a8c4d92211f642ec92 languageName: node linkType: hard @@ -9529,16 +9268,7 @@ __metadata: languageName: node linkType: hard -"isobject@npm:^2.0.0, isobject@npm:^2.1.0": - version: 2.1.0 - resolution: "isobject@npm:2.1.0" - dependencies: - isarray: 1.0.0 - checksum: 811c6f5a866877d31f0606a88af4a45f282544de886bf29f6a34c46616a1ae2ed17076cc6bf34c0128f33eecf7e1fcaa2c82cf3770560d3e26810894e96ae79f - languageName: node - linkType: hard - -"isobject@npm:^3.0.0, isobject@npm:^3.0.1": +"isobject@npm:^3.0.1": version: 3.0.1 resolution: "isobject@npm:3.0.1" checksum: db85c4c970ce30693676487cca0e61da2ca34e8d4967c2e1309143ff910c207133a969f9e4ddb2dc6aba670aabce4e0e307146c310350b298e74a31f7d464703 @@ -9548,7 +9278,7 @@ __metadata: "istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0": version: 3.2.2 resolution: "istanbul-lib-coverage@npm:3.2.2" - checksum: 2367407a8d13982d8f7a859a35e7f8dd5d8f75aae4bb5484ede3a9ea1b426dc245aff28b976a2af48ee759fdd9be374ce2bd2669b644f31e76c5f46a2e29a831 + checksum: 40bbdd1e937dfd8c830fa286d0f665e81b7a78bdabcd4565f6d5667c99828bda3db7fb7ac6b96a3e2e8a2461ddbc5452d9f8bc7d00cb00075fa6a3e99f5b6a81 languageName: node linkType: hard @@ -9556,12 +9286,12 @@ __metadata: version: 5.2.1 resolution: "istanbul-lib-instrument@npm:5.2.1" dependencies: - "@babel/core": ^7.12.3 - "@babel/parser": ^7.14.7 - "@istanbuljs/schema": ^0.1.2 - istanbul-lib-coverage: ^3.2.0 - semver: ^6.3.0 - checksum: bf16f1803ba5e51b28bbd49ed955a736488381e09375d830e42ddeb403855b2006f850711d95ad726f2ba3f1ae8e7366de7e51d2b9ac67dc4d80191ef7ddf272 + "@babel/core": "npm:^7.12.3" + "@babel/parser": "npm:^7.14.7" + "@istanbuljs/schema": "npm:^0.1.2" + istanbul-lib-coverage: "npm:^3.2.0" + semver: "npm:^6.3.0" + checksum: bbc4496c2f304d799f8ec22202ab38c010ac265c441947f075c0f7d46bd440b45c00e46017cf9053453d42182d768b1d6ed0e70a142c95ab00df9843aa5ab80e languageName: node linkType: hard @@ -9569,12 +9299,12 @@ __metadata: version: 6.0.2 resolution: "istanbul-lib-instrument@npm:6.0.2" dependencies: - "@babel/core": ^7.23.9 - "@babel/parser": ^7.23.9 - "@istanbuljs/schema": ^0.1.3 - istanbul-lib-coverage: ^3.2.0 - semver: ^7.5.4 - checksum: c10aa1e93a022f9767d7f41e6c07d244cc0a5c090fbb5522d70a5f21fcb98c52b7038850276c6fd1a7a17d1868c14a9d4eb8a24efe58a0ebb9a06f3da68131fe + "@babel/core": "npm:^7.23.9" + "@babel/parser": "npm:^7.23.9" + "@istanbuljs/schema": "npm:^0.1.3" + istanbul-lib-coverage: "npm:^3.2.0" + semver: "npm:^7.5.4" + checksum: 3aee19be199350182827679a137e1df142a306e9d7e20bb5badfd92ecc9023a7d366bc68e7c66e36983654a02a67401d75d8debf29fc6d4b83670fde69a594fc languageName: node linkType: hard @@ -9582,10 +9312,10 @@ __metadata: version: 3.0.1 resolution: "istanbul-lib-report@npm:3.0.1" dependencies: - istanbul-lib-coverage: ^3.0.0 - make-dir: ^4.0.0 - supports-color: ^7.1.0 - checksum: fd17a1b879e7faf9bb1dc8f80b2a16e9f5b7b8498fe6ed580a618c34df0bfe53d2abd35bf8a0a00e628fb7405462576427c7df20bbe4148d19c14b431c974b21 + istanbul-lib-coverage: "npm:^3.0.0" + make-dir: "npm:^4.0.0" + supports-color: "npm:^7.1.0" + checksum: 86a83421ca1cf2109a9f6d193c06c31ef04a45e72a74579b11060b1e7bb9b6337a4e6f04abfb8857e2d569c271273c65e855ee429376a0d7c91ad91db42accd1 languageName: node linkType: hard @@ -9593,10 +9323,10 @@ __metadata: version: 4.0.1 resolution: "istanbul-lib-source-maps@npm:4.0.1" dependencies: - debug: ^4.1.1 - istanbul-lib-coverage: ^3.0.0 - source-map: ^0.6.1 - checksum: 21ad3df45db4b81852b662b8d4161f6446cd250c1ddc70ef96a585e2e85c26ed7cd9c2a396a71533cfb981d1a645508bc9618cae431e55d01a0628e7dec62ef2 + debug: "npm:^4.1.1" + istanbul-lib-coverage: "npm:^3.0.0" + source-map: "npm:^0.6.1" + checksum: 5526983462799aced011d776af166e350191b816821ea7bcf71cab3e5272657b062c47dc30697a22a43656e3ced78893a42de677f9ccf276a28c913190953b82 languageName: node linkType: hard @@ -9604,9 +9334,9 @@ __metadata: version: 3.1.7 resolution: "istanbul-reports@npm:3.1.7" dependencies: - html-escaper: ^2.0.0 - istanbul-lib-report: ^3.0.0 - checksum: 2072db6e07bfbb4d0eb30e2700250636182398c1af811aea5032acb219d2080f7586923c09fa194029efd6b92361afb3dcbe1ebcc3ee6651d13340f7c6c4ed95 + html-escaper: "npm:^2.0.0" + istanbul-lib-report: "npm:^3.0.0" + checksum: f1faaa4684efaf57d64087776018d7426312a59aa6eeb4e0e3a777347d23cd286ad18f427e98f0e3dee666103d7404c9d7abc5f240406a912fa16bd6695437fa languageName: node linkType: hard @@ -9614,12 +9344,12 @@ __metadata: version: 1.1.2 resolution: "iterator.prototype@npm:1.1.2" dependencies: - define-properties: ^1.2.1 - get-intrinsic: ^1.2.1 - has-symbols: ^1.0.3 - reflect.getprototypeof: ^1.0.4 - set-function-name: ^2.0.1 - checksum: d8a507e2ccdc2ce762e8a1d3f4438c5669160ac72b88b648e59a688eec6bc4e64b22338e74000518418d9e693faf2a092d2af21b9ec7dbf7763b037a54701168 + define-properties: "npm:^1.2.1" + get-intrinsic: "npm:^1.2.1" + has-symbols: "npm:^1.0.3" + reflect.getprototypeof: "npm:^1.0.4" + set-function-name: "npm:^2.0.1" + checksum: b5013967ad8f28c9ca1be8e159eb10f591b8e46deae87476fe39d668c04374fe9158c815e8b6d2f45885b0a3fd842a8ba13f497ec762b3a0eff49bec278670b1 languageName: node linkType: hard @@ -9627,12 +9357,12 @@ __metadata: version: 2.3.6 resolution: "jackspeak@npm:2.3.6" dependencies: - "@isaacs/cliui": ^8.0.2 - "@pkgjs/parseargs": ^0.11.0 + "@isaacs/cliui": "npm:^8.0.2" + "@pkgjs/parseargs": "npm:^0.11.0" dependenciesMeta: "@pkgjs/parseargs": optional: true - checksum: 57d43ad11eadc98cdfe7496612f6bbb5255ea69fe51ea431162db302c2a11011642f50cfad57288bd0aea78384a0612b16e131944ad8ecd09d619041c8531b54 + checksum: 6e6490d676af8c94a7b5b29b8fd5629f21346911ebe2e32931c2a54210134408171c24cee1a109df2ec19894ad04a429402a8438cbf5cc2794585d35428ace76 languageName: node linkType: hard @@ -9640,13 +9370,13 @@ __metadata: version: 10.8.7 resolution: "jake@npm:10.8.7" dependencies: - async: ^3.2.3 - chalk: ^4.0.2 - filelist: ^1.0.4 - minimatch: ^3.1.2 + async: "npm:^3.2.3" + chalk: "npm:^4.0.2" + filelist: "npm:^1.0.4" + minimatch: "npm:^3.1.2" bin: jake: bin/cli.js - checksum: a23fd2273fb13f0d0d845502d02c791fd55ef5c6a2d207df72f72d8e1eac6d2b8ffa6caf660bc8006b3242e0daaa88a3ecc600194d72b5c6016ad56e9cd43553 + checksum: ad1cfe398836df4e6962954e5095597c21c5af1ea5a4182f6adf0869df8aca467a2eeca7869bf44f47120f4dd4ea52589d16050d295c87a5906c0d744775acc3 languageName: node linkType: hard @@ -9654,10 +9384,10 @@ __metadata: version: 29.7.0 resolution: "jest-changed-files@npm:29.7.0" dependencies: - execa: ^5.0.0 - jest-util: ^29.7.0 - p-limit: ^3.1.0 - checksum: 963e203893c396c5dfc75e00a49426688efea7361b0f0e040035809cecd2d46b3c01c02be2d9e8d38b1138357d2de7719ea5b5be21f66c10f2e9685a5a73bb99 + execa: "npm:^5.0.0" + jest-util: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + checksum: 3d93742e56b1a73a145d55b66e96711fbf87ef89b96c2fab7cfdfba8ec06612591a982111ca2b712bb853dbc16831ec8b43585a2a96b83862d6767de59cbf83d languageName: node linkType: hard @@ -9665,27 +9395,27 @@ __metadata: version: 29.7.0 resolution: "jest-circus@npm:29.7.0" dependencies: - "@jest/environment": ^29.7.0 - "@jest/expect": ^29.7.0 - "@jest/test-result": ^29.7.0 - "@jest/types": ^29.6.3 - "@types/node": "*" - chalk: ^4.0.0 - co: ^4.6.0 - dedent: ^1.0.0 - is-generator-fn: ^2.0.0 - jest-each: ^29.7.0 - jest-matcher-utils: ^29.7.0 - jest-message-util: ^29.7.0 - jest-runtime: ^29.7.0 - jest-snapshot: ^29.7.0 - jest-util: ^29.7.0 - p-limit: ^3.1.0 - pretty-format: ^29.7.0 - pure-rand: ^6.0.0 - slash: ^3.0.0 - stack-utils: ^2.0.3 - checksum: 349437148924a5a109c9b8aad6d393a9591b4dac1918fc97d81b7fc515bc905af9918495055071404af1fab4e48e4b04ac3593477b1d5dcf48c4e71b527c70a7 + "@jest/environment": "npm:^29.7.0" + "@jest/expect": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + co: "npm:^4.6.0" + dedent: "npm:^1.0.0" + is-generator-fn: "npm:^2.0.0" + jest-each: "npm:^29.7.0" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + pretty-format: "npm:^29.7.0" + pure-rand: "npm:^6.0.0" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.3" + checksum: 716a8e3f40572fd0213bcfc1da90274bf30d856e5133af58089a6ce45089b63f4d679bd44e6be9d320e8390483ebc3ae9921981993986d21639d9019b523123d languageName: node linkType: hard @@ -9693,17 +9423,17 @@ __metadata: version: 29.7.0 resolution: "jest-cli@npm:29.7.0" dependencies: - "@jest/core": ^29.7.0 - "@jest/test-result": ^29.7.0 - "@jest/types": ^29.6.3 - chalk: ^4.0.0 - create-jest: ^29.7.0 - exit: ^0.1.2 - import-local: ^3.0.2 - jest-config: ^29.7.0 - jest-util: ^29.7.0 - jest-validate: ^29.7.0 - yargs: ^17.3.1 + "@jest/core": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + create-jest: "npm:^29.7.0" + exit: "npm:^0.1.2" + import-local: "npm:^3.0.2" + jest-config: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + yargs: "npm:^17.3.1" peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: @@ -9711,7 +9441,7 @@ __metadata: optional: true bin: jest: bin/jest.js - checksum: 664901277a3f5007ea4870632ed6e7889db9da35b2434e7cb488443e6bf5513889b344b7fddf15112135495b9875892b156faeb2d7391ddb9e2a849dcb7b6c36 + checksum: 6cc62b34d002c034203065a31e5e9a19e7c76d9e8ef447a6f70f759c0714cb212c6245f75e270ba458620f9c7b26063cd8cf6cd1f7e3afd659a7cc08add17307 languageName: node linkType: hard @@ -9719,28 +9449,28 @@ __metadata: version: 29.7.0 resolution: "jest-config@npm:29.7.0" dependencies: - "@babel/core": ^7.11.6 - "@jest/test-sequencer": ^29.7.0 - "@jest/types": ^29.6.3 - babel-jest: ^29.7.0 - chalk: ^4.0.0 - ci-info: ^3.2.0 - deepmerge: ^4.2.2 - glob: ^7.1.3 - graceful-fs: ^4.2.9 - jest-circus: ^29.7.0 - jest-environment-node: ^29.7.0 - jest-get-type: ^29.6.3 - jest-regex-util: ^29.6.3 - jest-resolve: ^29.7.0 - jest-runner: ^29.7.0 - jest-util: ^29.7.0 - jest-validate: ^29.7.0 - micromatch: ^4.0.4 - parse-json: ^5.2.0 - pretty-format: ^29.7.0 - slash: ^3.0.0 - strip-json-comments: ^3.1.1 + "@babel/core": "npm:^7.11.6" + "@jest/test-sequencer": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + babel-jest: "npm:^29.7.0" + chalk: "npm:^4.0.0" + ci-info: "npm:^3.2.0" + deepmerge: "npm:^4.2.2" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + jest-circus: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-runner: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + parse-json: "npm:^5.2.0" + pretty-format: "npm:^29.7.0" + slash: "npm:^3.0.0" + strip-json-comments: "npm:^3.1.1" peerDependencies: "@types/node": "*" ts-node: ">=9.0.0" @@ -9749,7 +9479,7 @@ __metadata: optional: true ts-node: optional: true - checksum: 4cabf8f894c180cac80b7df1038912a3fc88f96f2622de33832f4b3314f83e22b08fb751da570c0ab2b7988f21604bdabade95e3c0c041068ac578c085cf7dff + checksum: 6bdf570e9592e7d7dd5124fc0e21f5fe92bd15033513632431b211797e3ab57eaa312f83cc6481b3094b72324e369e876f163579d60016677c117ec4853cf02b languageName: node linkType: hard @@ -9757,11 +9487,11 @@ __metadata: version: 29.7.0 resolution: "jest-diff@npm:29.7.0" dependencies: - chalk: ^4.0.0 - diff-sequences: ^29.6.3 - jest-get-type: ^29.6.3 - pretty-format: ^29.7.0 - checksum: 08e24a9dd43bfba1ef07a6374e5af138f53137b79ec3d5cc71a2303515335898888fa5409959172e1e05de966c9e714368d15e8994b0af7441f0721ee8e1bb77 + chalk: "npm:^4.0.0" + diff-sequences: "npm:^29.6.3" + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 6f3a7eb9cd9de5ea9e5aa94aed535631fa6f80221832952839b3cb59dd419b91c20b73887deb0b62230d06d02d6b6cf34ebb810b88d904bb4fe1e2e4f0905c98 languageName: node linkType: hard @@ -9769,8 +9499,8 @@ __metadata: version: 29.7.0 resolution: "jest-docblock@npm:29.7.0" dependencies: - detect-newline: ^3.0.0 - checksum: 66390c3e9451f8d96c5da62f577a1dad701180cfa9b071c5025acab2f94d7a3efc2515cfa1654ebe707213241541ce9c5530232cdc8017c91ed64eea1bd3b192 + detect-newline: "npm:^3.0.0" + checksum: 8d48818055bc96c9e4ec2e217a5a375623c0d0bfae8d22c26e011074940c202aa2534a3362294c81d981046885c05d304376afba9f2874143025981148f3e96d languageName: node linkType: hard @@ -9778,12 +9508,12 @@ __metadata: version: 29.7.0 resolution: "jest-each@npm:29.7.0" dependencies: - "@jest/types": ^29.6.3 - chalk: ^4.0.0 - jest-get-type: ^29.6.3 - jest-util: ^29.7.0 - pretty-format: ^29.7.0 - checksum: e88f99f0184000fc8813f2a0aa79e29deeb63700a3b9b7928b8a418d7d93cd24933608591dbbdea732b473eb2021c72991b5cc51a17966842841c6e28e6f691c + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + jest-get-type: "npm:^29.6.3" + jest-util: "npm:^29.7.0" + pretty-format: "npm:^29.7.0" + checksum: bd1a077654bdaa013b590deb5f7e7ade68f2e3289180a8c8f53bc8a49f3b40740c0ec2d3a3c1aee906f682775be2bebbac37491d80b634d15276b0aa0f2e3fda languageName: node linkType: hard @@ -9791,20 +9521,20 @@ __metadata: version: 29.7.0 resolution: "jest-environment-jsdom@npm:29.7.0" dependencies: - "@jest/environment": ^29.7.0 - "@jest/fake-timers": ^29.7.0 - "@jest/types": ^29.6.3 - "@types/jsdom": ^20.0.0 - "@types/node": "*" - jest-mock: ^29.7.0 - jest-util: ^29.7.0 - jsdom: ^20.0.0 + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/jsdom": "npm:^20.0.0" + "@types/node": "npm:*" + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jsdom: "npm:^20.0.0" peerDependencies: canvas: ^2.5.0 peerDependenciesMeta: canvas: optional: true - checksum: 559aac134c196fccc1dfc794d8fc87377e9f78e894bb13012b0831d88dec0abd7ece99abec69da564b8073803be4f04a9eb4f4d1bb80e29eec0cb252c254deb8 + checksum: 23bbfc9bca914baef4b654f7983175a4d49b0f515a5094ebcb8f819f28ec186f53c0ba06af1855eac04bab1457f4ea79dae05f70052cf899863e8096daa6e0f5 languageName: node linkType: hard @@ -9812,13 +9542,13 @@ __metadata: version: 29.7.0 resolution: "jest-environment-node@npm:29.7.0" dependencies: - "@jest/environment": ^29.7.0 - "@jest/fake-timers": ^29.7.0 - "@jest/types": ^29.6.3 - "@types/node": "*" - jest-mock: ^29.7.0 - jest-util: ^29.7.0 - checksum: 501a9966292cbe0ca3f40057a37587cb6def25e1e0c5e39ac6c650fe78d3c70a2428304341d084ac0cced5041483acef41c477abac47e9a290d5545fd2f15646 + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 9cf7045adf2307cc93aed2f8488942e39388bff47ec1df149a997c6f714bfc66b2056768973770d3f8b1bf47396c19aa564877eb10ec978b952c6018ed1bd637 languageName: node linkType: hard @@ -9833,22 +9563,22 @@ __metadata: version: 29.7.0 resolution: "jest-haste-map@npm:29.7.0" dependencies: - "@jest/types": ^29.6.3 - "@types/graceful-fs": ^4.1.3 - "@types/node": "*" - anymatch: ^3.0.3 - fb-watchman: ^2.0.0 - fsevents: ^2.3.2 - graceful-fs: ^4.2.9 - jest-regex-util: ^29.6.3 - jest-util: ^29.7.0 - jest-worker: ^29.7.0 - micromatch: ^4.0.4 - walker: ^1.0.8 + "@jest/types": "npm:^29.6.3" + "@types/graceful-fs": "npm:^4.1.3" + "@types/node": "npm:*" + anymatch: "npm:^3.0.3" + fb-watchman: "npm:^2.0.0" + fsevents: "npm:^2.3.2" + graceful-fs: "npm:^4.2.9" + jest-regex-util: "npm:^29.6.3" + jest-util: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + walker: "npm:^1.0.8" dependenciesMeta: fsevents: optional: true - checksum: c2c8f2d3e792a963940fbdfa563ce14ef9e14d4d86da645b96d3cd346b8d35c5ce0b992ee08593939b5f718cf0a1f5a90011a056548a1dbf58397d4356786f01 + checksum: 8531b42003581cb18a69a2774e68c456fb5a5c3280b1b9b77475af9e346b6a457250f9d756bfeeae2fe6cbc9ef28434c205edab9390ee970a919baddfa08bb85 languageName: node linkType: hard @@ -9856,8 +9586,8 @@ __metadata: version: 29.7.0 resolution: "jest-leak-detector@npm:29.7.0" dependencies: - jest-get-type: ^29.6.3 - pretty-format: ^29.7.0 + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" checksum: e3950e3ddd71e1d0c22924c51a300a1c2db6cf69ec1e51f95ccf424bcc070f78664813bef7aed4b16b96dfbdeea53fe358f8aeaaea84346ae15c3735758f1605 languageName: node linkType: hard @@ -9866,11 +9596,11 @@ __metadata: version: 29.7.0 resolution: "jest-matcher-utils@npm:29.7.0" dependencies: - chalk: ^4.0.0 - jest-diff: ^29.7.0 - jest-get-type: ^29.6.3 - pretty-format: ^29.7.0 - checksum: d7259e5f995d915e8a37a8fd494cb7d6af24cd2a287b200f831717ba0d015190375f9f5dc35393b8ba2aae9b2ebd60984635269c7f8cff7d85b077543b7744cd + chalk: "npm:^4.0.0" + jest-diff: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 981904a494299cf1e3baed352f8a3bd8b50a8c13a662c509b6a53c31461f94ea3bfeffa9d5efcfeb248e384e318c87de7e3baa6af0f79674e987482aa189af40 languageName: node linkType: hard @@ -9878,16 +9608,16 @@ __metadata: version: 29.7.0 resolution: "jest-message-util@npm:29.7.0" dependencies: - "@babel/code-frame": ^7.12.13 - "@jest/types": ^29.6.3 - "@types/stack-utils": ^2.0.0 - chalk: ^4.0.0 - graceful-fs: ^4.2.9 - micromatch: ^4.0.4 - pretty-format: ^29.7.0 - slash: ^3.0.0 - stack-utils: ^2.0.3 - checksum: a9d025b1c6726a2ff17d54cc694de088b0489456c69106be6b615db7a51b7beb66788bea7a59991a019d924fbf20f67d085a445aedb9a4d6760363f4d7d09930 + "@babel/code-frame": "npm:^7.12.13" + "@jest/types": "npm:^29.6.3" + "@types/stack-utils": "npm:^2.0.0" + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.9" + micromatch: "npm:^4.0.4" + pretty-format: "npm:^29.7.0" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.3" + checksum: 31d53c6ed22095d86bab9d14c0fa70c4a92c749ea6ceece82cf30c22c9c0e26407acdfbdb0231435dc85a98d6d65ca0d9cbcd25cd1abb377fe945e843fb770b9 languageName: node linkType: hard @@ -9895,9 +9625,9 @@ __metadata: version: 27.5.1 resolution: "jest-mock@npm:27.5.1" dependencies: - "@jest/types": ^27.5.1 - "@types/node": "*" - checksum: f5b5904bb1741b4a1687a5f492535b7b1758dc26534c72a5423305f8711292e96a601dec966df81bb313269fb52d47227e29f9c2e08324d79529172f67311be0 + "@jest/types": "npm:^27.5.1" + "@types/node": "npm:*" + checksum: be9a8777801659227d3bb85317a3aca617542779a290a6a45c9addec8bda29f494a524cb4af96c82b825ecb02171e320dfbfde3e3d9218672f9e38c9fac118f4 languageName: node linkType: hard @@ -9905,10 +9635,10 @@ __metadata: version: 29.7.0 resolution: "jest-mock@npm:29.7.0" dependencies: - "@jest/types": ^29.6.3 - "@types/node": "*" - jest-util: ^29.7.0 - checksum: 81ba9b68689a60be1482212878973700347cb72833c5e5af09895882b9eb5c4e02843a1bbdf23f94c52d42708bab53a30c45a3482952c9eec173d1eaac5b86c5 + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + jest-util: "npm:^29.7.0" + checksum: ae51d1b4f898724be5e0e52b2268a68fcd876d9b20633c864a6dd6b1994cbc48d62402b0f40f3a1b669b30ebd648821f086c26c08ffde192ced951ff4670d51c languageName: node linkType: hard @@ -9935,9 +9665,9 @@ __metadata: version: 29.7.0 resolution: "jest-resolve-dependencies@npm:29.7.0" dependencies: - jest-regex-util: ^29.6.3 - jest-snapshot: ^29.7.0 - checksum: aeb75d8150aaae60ca2bb345a0d198f23496494677cd6aefa26fc005faf354061f073982175daaf32b4b9d86b26ca928586344516e3e6969aa614cb13b883984 + jest-regex-util: "npm:^29.6.3" + jest-snapshot: "npm:^29.7.0" + checksum: 1e206f94a660d81e977bcfb1baae6450cb4a81c92e06fad376cc5ea16b8e8c6ea78c383f39e95591a9eb7f925b6a1021086c38941aa7c1b8a6a813c2f6e93675 languageName: node linkType: hard @@ -9945,16 +9675,16 @@ __metadata: version: 29.7.0 resolution: "jest-resolve@npm:29.7.0" dependencies: - chalk: ^4.0.0 - graceful-fs: ^4.2.9 - jest-haste-map: ^29.7.0 - jest-pnp-resolver: ^1.2.2 - jest-util: ^29.7.0 - jest-validate: ^29.7.0 - resolve: ^1.20.0 - resolve.exports: ^2.0.0 - slash: ^3.0.0 - checksum: 0ca218e10731aa17920526ec39deaec59ab9b966237905ffc4545444481112cd422f01581230eceb7e82d86f44a543d520a71391ec66e1b4ef1a578bd5c73487 + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + jest-pnp-resolver: "npm:^1.2.2" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + resolve: "npm:^1.20.0" + resolve.exports: "npm:^2.0.0" + slash: "npm:^3.0.0" + checksum: faa466fd9bc69ea6c37a545a7c6e808e073c66f46ab7d3d8a6ef084f8708f201b85d5fe1799789578b8b47fa1de47b9ee47b414d1863bc117a49e032ba77b7c7 languageName: node linkType: hard @@ -9962,28 +9692,28 @@ __metadata: version: 29.7.0 resolution: "jest-runner@npm:29.7.0" dependencies: - "@jest/console": ^29.7.0 - "@jest/environment": ^29.7.0 - "@jest/test-result": ^29.7.0 - "@jest/transform": ^29.7.0 - "@jest/types": ^29.6.3 - "@types/node": "*" - chalk: ^4.0.0 - emittery: ^0.13.1 - graceful-fs: ^4.2.9 - jest-docblock: ^29.7.0 - jest-environment-node: ^29.7.0 - jest-haste-map: ^29.7.0 - jest-leak-detector: ^29.7.0 - jest-message-util: ^29.7.0 - jest-resolve: ^29.7.0 - jest-runtime: ^29.7.0 - jest-util: ^29.7.0 - jest-watcher: ^29.7.0 - jest-worker: ^29.7.0 - p-limit: ^3.1.0 - source-map-support: 0.5.13 - checksum: f0405778ea64812bf9b5c50b598850d94ccf95d7ba21f090c64827b41decd680ee19fcbb494007cdd7f5d0d8906bfc9eceddd8fa583e753e736ecd462d4682fb + "@jest/console": "npm:^29.7.0" + "@jest/environment": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + emittery: "npm:^0.13.1" + graceful-fs: "npm:^4.2.9" + jest-docblock: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + jest-haste-map: "npm:^29.7.0" + jest-leak-detector: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-resolve: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-watcher: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + source-map-support: "npm:0.5.13" + checksum: 9d8748a494bd90f5c82acea99be9e99f21358263ce6feae44d3f1b0cd90991b5df5d18d607e73c07be95861ee86d1cbab2a3fc6ca4b21805f07ac29d47c1da1e languageName: node linkType: hard @@ -9991,29 +9721,29 @@ __metadata: version: 29.7.0 resolution: "jest-runtime@npm:29.7.0" dependencies: - "@jest/environment": ^29.7.0 - "@jest/fake-timers": ^29.7.0 - "@jest/globals": ^29.7.0 - "@jest/source-map": ^29.6.3 - "@jest/test-result": ^29.7.0 - "@jest/transform": ^29.7.0 - "@jest/types": ^29.6.3 - "@types/node": "*" - chalk: ^4.0.0 - cjs-module-lexer: ^1.0.0 - collect-v8-coverage: ^1.0.0 - glob: ^7.1.3 - graceful-fs: ^4.2.9 - jest-haste-map: ^29.7.0 - jest-message-util: ^29.7.0 - jest-mock: ^29.7.0 - jest-regex-util: ^29.6.3 - jest-resolve: ^29.7.0 - jest-snapshot: ^29.7.0 - jest-util: ^29.7.0 - slash: ^3.0.0 - strip-bom: ^4.0.0 - checksum: d19f113d013e80691e07047f68e1e3448ef024ff2c6b586ce4f90cd7d4c62a2cd1d460110491019719f3c59bfebe16f0e201ed005ef9f80e2cf798c374eed54e + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/globals": "npm:^29.7.0" + "@jest/source-map": "npm:^29.6.3" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + cjs-module-lexer: "npm:^1.0.0" + collect-v8-coverage: "npm:^1.0.0" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-mock: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + slash: "npm:^3.0.0" + strip-bom: "npm:^4.0.0" + checksum: 59eb58eb7e150e0834a2d0c0d94f2a0b963ae7182cfa6c63f2b49b9c6ef794e5193ef1634e01db41420c36a94cefc512cdd67a055cd3e6fa2f41eaf0f82f5a20 languageName: node linkType: hard @@ -10021,27 +9751,27 @@ __metadata: version: 29.7.0 resolution: "jest-snapshot@npm:29.7.0" dependencies: - "@babel/core": ^7.11.6 - "@babel/generator": ^7.7.2 - "@babel/plugin-syntax-jsx": ^7.7.2 - "@babel/plugin-syntax-typescript": ^7.7.2 - "@babel/types": ^7.3.3 - "@jest/expect-utils": ^29.7.0 - "@jest/transform": ^29.7.0 - "@jest/types": ^29.6.3 - babel-preset-current-node-syntax: ^1.0.0 - chalk: ^4.0.0 - expect: ^29.7.0 - graceful-fs: ^4.2.9 - jest-diff: ^29.7.0 - jest-get-type: ^29.6.3 - jest-matcher-utils: ^29.7.0 - jest-message-util: ^29.7.0 - jest-util: ^29.7.0 - natural-compare: ^1.4.0 - pretty-format: ^29.7.0 - semver: ^7.5.3 - checksum: 86821c3ad0b6899521ce75ee1ae7b01b17e6dfeff9166f2cf17f012e0c5d8c798f30f9e4f8f7f5bed01ea7b55a6bc159f5eda778311162cbfa48785447c237ad + "@babel/core": "npm:^7.11.6" + "@babel/generator": "npm:^7.7.2" + "@babel/plugin-syntax-jsx": "npm:^7.7.2" + "@babel/plugin-syntax-typescript": "npm:^7.7.2" + "@babel/types": "npm:^7.3.3" + "@jest/expect-utils": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + babel-preset-current-node-syntax: "npm:^1.0.0" + chalk: "npm:^4.0.0" + expect: "npm:^29.7.0" + graceful-fs: "npm:^4.2.9" + jest-diff: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + natural-compare: "npm:^1.4.0" + pretty-format: "npm:^29.7.0" + semver: "npm:^7.5.3" + checksum: cb19a3948256de5f922d52f251821f99657339969bf86843bd26cf3332eae94883e8260e3d2fba46129a27c3971c1aa522490e460e16c7fad516e82d10bbf9f8 languageName: node linkType: hard @@ -10049,13 +9779,13 @@ __metadata: version: 29.7.0 resolution: "jest-util@npm:29.7.0" dependencies: - "@jest/types": ^29.6.3 - "@types/node": "*" - chalk: ^4.0.0 - ci-info: ^3.2.0 - graceful-fs: ^4.2.9 - picomatch: ^2.2.3 - checksum: 042ab4980f4ccd4d50226e01e5c7376a8556b472442ca6091a8f102488c0f22e6e8b89ea874111d2328a2080083bf3225c86f3788c52af0bd0345a00eb57a3ca + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + ci-info: "npm:^3.2.0" + graceful-fs: "npm:^4.2.9" + picomatch: "npm:^2.2.3" + checksum: 30d58af6967e7d42bd903ccc098f3b4d3859ed46238fbc88d4add6a3f10bea00c226b93660285f058bc7a65f6f9529cf4eb80f8d4707f79f9e3a23686b4ab8f3 languageName: node linkType: hard @@ -10063,13 +9793,13 @@ __metadata: version: 29.7.0 resolution: "jest-validate@npm:29.7.0" dependencies: - "@jest/types": ^29.6.3 - camelcase: ^6.2.0 - chalk: ^4.0.0 - jest-get-type: ^29.6.3 - leven: ^3.1.0 - pretty-format: ^29.7.0 - checksum: 191fcdc980f8a0de4dbdd879fa276435d00eb157a48683af7b3b1b98b0f7d9de7ffe12689b617779097ff1ed77601b9f7126b0871bba4f776e222c40f62e9dae + "@jest/types": "npm:^29.6.3" + camelcase: "npm:^6.2.0" + chalk: "npm:^4.0.0" + jest-get-type: "npm:^29.6.3" + leven: "npm:^3.1.0" + pretty-format: "npm:^29.7.0" + checksum: 8ee1163666d8eaa16d90a989edba2b4a3c8ab0ffaa95ad91b08ca42b015bfb70e164b247a5b17f9de32d096987cada63ed8491ab82761bfb9a28bc34b27ae161 languageName: node linkType: hard @@ -10077,15 +9807,15 @@ __metadata: version: 29.7.0 resolution: "jest-watcher@npm:29.7.0" dependencies: - "@jest/test-result": ^29.7.0 - "@jest/types": ^29.6.3 - "@types/node": "*" - ansi-escapes: ^4.2.1 - chalk: ^4.0.0 - emittery: ^0.13.1 - jest-util: ^29.7.0 - string-length: ^4.0.1 - checksum: 67e6e7fe695416deff96b93a14a561a6db69389a0667e9489f24485bb85e5b54e12f3b2ba511ec0b777eca1e727235b073e3ebcdd473d68888650489f88df92f + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + ansi-escapes: "npm:^4.2.1" + chalk: "npm:^4.0.0" + emittery: "npm:^0.13.1" + jest-util: "npm:^29.7.0" + string-length: "npm:^4.0.1" + checksum: 4f616e0345676631a7034b1d94971aaa719f0cd4a6041be2aa299be437ea047afd4fe05c48873b7963f5687a2f6c7cbf51244be8b14e313b97bfe32b1e127e55 languageName: node linkType: hard @@ -10093,11 +9823,11 @@ __metadata: version: 29.7.0 resolution: "jest-worker@npm:29.7.0" dependencies: - "@types/node": "*" - jest-util: ^29.7.0 - merge-stream: ^2.0.0 - supports-color: ^8.0.0 - checksum: 30fff60af49675273644d408b650fc2eb4b5dcafc5a0a455f238322a8f9d8a98d847baca9d51ff197b6747f54c7901daa2287799230b856a0f48287d131f8c13 + "@types/node": "npm:*" + jest-util: "npm:^29.7.0" + merge-stream: "npm:^2.0.0" + supports-color: "npm:^8.0.0" + checksum: 364cbaef00d8a2729fc760227ad34b5e60829e0869bd84976bdfbd8c0d0f9c2f22677b3e6dd8afa76ed174765351cd12bae3d4530c62eefb3791055127ca9745 languageName: node linkType: hard @@ -10105,10 +9835,10 @@ __metadata: version: 29.7.0 resolution: "jest@npm:29.7.0" dependencies: - "@jest/core": ^29.7.0 - "@jest/types": ^29.6.3 - import-local: ^3.0.2 - jest-cli: ^29.7.0 + "@jest/core": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + import-local: "npm:^3.0.2" + jest-cli: "npm:^29.7.0" peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: @@ -10116,28 +9846,21 @@ __metadata: optional: true bin: jest: bin/jest.js - checksum: 17ca8d67504a7dbb1998cf3c3077ec9031ba3eb512da8d71cb91bcabb2b8995c4e4b292b740cb9bf1cbff5ce3e110b3f7c777b0cefb6f41ab05445f248d0ee0b + checksum: 97023d78446098c586faaa467fbf2c6b07ff06e2c85a19e3926adb5b0effe9ac60c4913ae03e2719f9c01ae8ffd8d92f6b262cedb9555ceeb5d19263d8c6362a languageName: node linkType: hard "js-base64@npm:3.7.7": version: 3.7.7 resolution: "js-base64@npm:3.7.7" - checksum: d1b02971db9dc0fd35baecfaf6ba499731fb44fe3373e7e1d6681fbd3ba665f29e8d9d17910254ef8104e2cb8b44117fe4202d3dc54c7cafe9ba300fe5433358 - languageName: node - linkType: hard - -"js-base64@npm:^2.1.9": - version: 2.6.4 - resolution: "js-base64@npm:2.6.4" - checksum: 5f4084078d6c46f8529741d110df84b14fac3276b903760c21fa8cc8521370d607325dfe1c1a9fbbeaae1ff8e602665aaeef1362427d8fef704f9e3659472ce8 + checksum: 185e34c536a6b1c4e1ad8bd96d25b49a9ea4e6803e259eaaaca95f1b392a0d590b2933c5ca8580c776f7279507944b81ff1faf889d84baa5e31f026e96d676a5 languageName: node linkType: hard "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" - checksum: 8a95213a5a77deb6cbe94d86340e8d9ace2b93bc367790b260101d2f36a2eaf4e4e22d9fa9cf459b38af3a32fb4190e638024cf82ec95ef708680e405ea7cc78 + checksum: af37d0d913fb56aec6dc0074c163cc71cd23c0b8aad5c2350747b6721d37ba118af35abdd8b33c47ec2800de07dedb16a527ca9c530ee004093e04958bd0cbf2 languageName: node linkType: hard @@ -10145,11 +9868,11 @@ __metadata: version: 3.14.1 resolution: "js-yaml@npm:3.14.1" dependencies: - argparse: ^1.0.7 - esprima: ^4.0.0 + argparse: "npm:^1.0.7" + esprima: "npm:^4.0.0" bin: js-yaml: bin/js-yaml.js - checksum: bef146085f472d44dee30ec34e5cf36bf89164f5d585435a3d3da89e52622dff0b188a580e4ad091c3341889e14cb88cac6e4deb16dc5b1e9623bb0601fc255c + checksum: 9e22d80b4d0105b9899135365f746d47466ed53ef4223c529b3c0f7a39907743fdbd3c4379f94f1106f02755b5e90b2faaf84801a891135544e1ea475d1a1379 languageName: node linkType: hard @@ -10157,17 +9880,17 @@ __metadata: version: 4.1.0 resolution: "js-yaml@npm:4.1.0" dependencies: - argparse: ^2.0.1 + argparse: "npm:^2.0.1" bin: js-yaml: bin/js-yaml.js - checksum: c7830dfd456c3ef2c6e355cc5a92e6700ceafa1d14bba54497b34a99f0376cecbb3e9ac14d3e5849b426d5a5140709a66237a8c991c675431271c4ce5504151a + checksum: c138a34a3fd0d08ebaf71273ad4465569a483b8a639e0b118ff65698d257c2791d3199e3f303631f2cb98213fa7b5f5d6a4621fd0fff819421b990d30d967140 languageName: node linkType: hard "jsbn@npm:1.1.0": version: 1.1.0 resolution: "jsbn@npm:1.1.0" - checksum: 944f924f2bd67ad533b3850eee47603eed0f6ae425fd1ee8c760f477e8c34a05f144c1bd4f5a5dd1963141dc79a2c55f89ccc5ab77d039e7077f3ad196b64965 + checksum: bebe7ae829bbd586ce8cbe83501dd8cb8c282c8902a8aeeed0a073a89dc37e8103b1244f3c6acd60278bcbfe12d93a3f83c9ac396868a3b3bbc3c5e5e3b648ef languageName: node linkType: hard @@ -10175,26 +9898,26 @@ __metadata: version: 0.15.2 resolution: "jscodeshift@npm:0.15.2" dependencies: - "@babel/core": ^7.23.0 - "@babel/parser": ^7.23.0 - "@babel/plugin-transform-class-properties": ^7.22.5 - "@babel/plugin-transform-modules-commonjs": ^7.23.0 - "@babel/plugin-transform-nullish-coalescing-operator": ^7.22.11 - "@babel/plugin-transform-optional-chaining": ^7.23.0 - "@babel/plugin-transform-private-methods": ^7.22.5 - "@babel/preset-flow": ^7.22.15 - "@babel/preset-typescript": ^7.23.0 - "@babel/register": ^7.22.15 - babel-core: ^7.0.0-bridge.0 - chalk: ^4.1.2 - flow-parser: 0.* - graceful-fs: ^4.2.4 - micromatch: ^4.0.4 - neo-async: ^2.5.0 - node-dir: ^0.1.17 - recast: ^0.23.3 - temp: ^0.8.4 - write-file-atomic: ^2.3.0 + "@babel/core": "npm:^7.23.0" + "@babel/parser": "npm:^7.23.0" + "@babel/plugin-transform-class-properties": "npm:^7.22.5" + "@babel/plugin-transform-modules-commonjs": "npm:^7.23.0" + "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.22.11" + "@babel/plugin-transform-optional-chaining": "npm:^7.23.0" + "@babel/plugin-transform-private-methods": "npm:^7.22.5" + "@babel/preset-flow": "npm:^7.22.15" + "@babel/preset-typescript": "npm:^7.23.0" + "@babel/register": "npm:^7.22.15" + babel-core: "npm:^7.0.0-bridge.0" + chalk: "npm:^4.1.2" + flow-parser: "npm:0.*" + graceful-fs: "npm:^4.2.4" + micromatch: "npm:^4.0.4" + neo-async: "npm:^2.5.0" + node-dir: "npm:^0.1.17" + recast: "npm:^0.23.3" + temp: "npm:^0.8.4" + write-file-atomic: "npm:^2.3.0" peerDependencies: "@babel/preset-env": ^7.1.6 peerDependenciesMeta: @@ -10202,7 +9925,7 @@ __metadata: optional: true bin: jscodeshift: bin/jscodeshift.js - checksum: e3fa018bfd0ee5b65da1b98797a2536ae8ff0185f0c0d11f9be11e27e1f25ab33a4e17cecc8b73ef520e5d9d8dade98abc49bc0835c024a0f1ff14b48288528b + checksum: 5f4354d80a95de4dba5dd402e97e5bba8c6b31261f426719cb184099ac83c57c47e4160923b7c035a5da4113e56c39eb68233e3b55a910372013d66d3b1f1c64 languageName: node linkType: hard @@ -10210,38 +9933,38 @@ __metadata: version: 20.0.3 resolution: "jsdom@npm:20.0.3" dependencies: - abab: ^2.0.6 - acorn: ^8.8.1 - acorn-globals: ^7.0.0 - cssom: ^0.5.0 - cssstyle: ^2.3.0 - data-urls: ^3.0.2 - decimal.js: ^10.4.2 - domexception: ^4.0.0 - escodegen: ^2.0.0 - form-data: ^4.0.0 - html-encoding-sniffer: ^3.0.0 - http-proxy-agent: ^5.0.0 - https-proxy-agent: ^5.0.1 - is-potential-custom-element-name: ^1.0.1 - nwsapi: ^2.2.2 - parse5: ^7.1.1 - saxes: ^6.0.0 - symbol-tree: ^3.2.4 - tough-cookie: ^4.1.2 - w3c-xmlserializer: ^4.0.0 - webidl-conversions: ^7.0.0 - whatwg-encoding: ^2.0.0 - whatwg-mimetype: ^3.0.0 - whatwg-url: ^11.0.0 - ws: ^8.11.0 - xml-name-validator: ^4.0.0 + abab: "npm:^2.0.6" + acorn: "npm:^8.8.1" + acorn-globals: "npm:^7.0.0" + cssom: "npm:^0.5.0" + cssstyle: "npm:^2.3.0" + data-urls: "npm:^3.0.2" + decimal.js: "npm:^10.4.2" + domexception: "npm:^4.0.0" + escodegen: "npm:^2.0.0" + form-data: "npm:^4.0.0" + html-encoding-sniffer: "npm:^3.0.0" + http-proxy-agent: "npm:^5.0.0" + https-proxy-agent: "npm:^5.0.1" + is-potential-custom-element-name: "npm:^1.0.1" + nwsapi: "npm:^2.2.2" + parse5: "npm:^7.1.1" + saxes: "npm:^6.0.0" + symbol-tree: "npm:^3.2.4" + tough-cookie: "npm:^4.1.2" + w3c-xmlserializer: "npm:^4.0.0" + webidl-conversions: "npm:^7.0.0" + whatwg-encoding: "npm:^2.0.0" + whatwg-mimetype: "npm:^3.0.0" + whatwg-url: "npm:^11.0.0" + ws: "npm:^8.11.0" + xml-name-validator: "npm:^4.0.0" peerDependencies: canvas: ^2.5.0 peerDependenciesMeta: canvas: optional: true - checksum: 6e2ae21db397133a061b270c26d2dbc0b9051733ea3b896a7ece78d79f475ff0974f766a413c1198a79c793159119169f2335ddb23150348fbfdcfa6f3105536 + checksum: a4cdcff5b07eed87da90b146b82936321533b5efe8124492acf7160ebd5b9cf2b3c2435683592bf1cffb479615245756efb6c173effc1906f845a86ed22af985 languageName: node linkType: hard @@ -10250,7 +9973,7 @@ __metadata: resolution: "jsesc@npm:2.5.2" bin: jsesc: bin/jsesc - checksum: 4dc190771129e12023f729ce20e1e0bfceac84d73a85bc3119f7f938843fe25a4aeccb54b6494dce26fcf263d815f5f31acdefac7cc9329efb8422a4f4d9fa9d + checksum: d2096abdcdec56969764b40ffc91d4a23408aa2f351b4d1c13f736f25476643238c43fdbaf38a191c26b1b78fd856d965f5d4d0dde7b89459cd94025190cdf13 languageName: node linkType: hard @@ -10259,21 +9982,21 @@ __metadata: resolution: "jsesc@npm:0.5.0" bin: jsesc: bin/jsesc - checksum: b8b44cbfc92f198ad972fba706ee6a1dfa7485321ee8c0b25f5cedd538dcb20cde3197de16a7265430fce8277a12db066219369e3d51055038946039f6e20e17 + checksum: fab949f585c71e169c5cbe00f049f20de74f067081bbd64a55443bad1c71e1b5a5b448f2359bf2fe06f5ed7c07e2e4a9101843b01c823c30b6afc11f5bfaf724 languageName: node linkType: hard "json-buffer@npm:3.0.1": version: 3.0.1 resolution: "json-buffer@npm:3.0.1" - checksum: 9026b03edc2847eefa2e37646c579300a1f3a4586cfb62bf857832b60c852042d0d6ae55d1afb8926163fa54c2b01d83ae24705f34990348bdac6273a29d4581 + checksum: 82876154521b7b68ba71c4f969b91572d1beabadd87bd3a6b236f85fbc7dc4695089191ed60bb59f9340993c51b33d479f45b6ba9f3548beb519705281c32c3c languageName: node linkType: hard "json-parse-even-better-errors@npm:^2.3.0": version: 2.3.1 resolution: "json-parse-even-better-errors@npm:2.3.1" - checksum: 798ed4cf3354a2d9ccd78e86d2169515a0097a5c133337807cdf7f1fc32e1391d207ccfc276518cc1d7d8d4db93288b8a50ba4293d212ad1336e52a8ec0a941f + checksum: 5f3a99009ed5f2a5a67d06e2f298cc97bc86d462034173308156f15b43a6e850be8511dc204b9b94566305da2947f7d90289657237d210351a39059ff9d666cf languageName: node linkType: hard @@ -10294,25 +10017,25 @@ __metadata: "json-schema@npm:0.4.0": version: 0.4.0 resolution: "json-schema@npm:0.4.0" - checksum: 66389434c3469e698da0df2e7ac5a3281bcff75e797a5c127db7c5b56270e01ae13d9afa3c03344f76e32e81678337a8c912bdbb75101c62e487dc3778461d72 + checksum: 8b3b64eff4a807dc2a3045b104ed1b9335cd8d57aa74c58718f07f0f48b8baa3293b00af4dcfbdc9144c3aafea1e97982cc27cc8e150fc5d93c540649507a458 languageName: node linkType: hard "json-stable-stringify-without-jsonify@npm:^1.0.1": version: 1.0.1 resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" - checksum: cff44156ddce9c67c44386ad5cddf91925fe06b1d217f2da9c4910d01f358c6e3989c4d5a02683c7a5667f9727ff05831f7aa8ae66c8ff691c556f0884d49215 + checksum: 12786c2e2f22c27439e6db0532ba321f1d0617c27ad8cb1c352a0e9249a50182fd1ba8b52a18899291604b0c32eafa8afd09e51203f19109a0537f68db2b652d languageName: node linkType: hard -"json5@npm:^1.0.1, json5@npm:^1.0.2": +"json5@npm:^1.0.2": version: 1.0.2 resolution: "json5@npm:1.0.2" dependencies: - minimist: ^1.2.0 + minimist: "npm:^1.2.0" bin: json5: lib/cli.js - checksum: 866458a8c58a95a49bef3adba929c625e82532bcff1fe93f01d29cb02cac7c3fe1f4b79951b7792c2da9de0b32871a8401a6e3c5b36778ad852bf5b8a61165d7 + checksum: a78d812dbbd5642c4f637dd130954acfd231b074965871c3e28a5bbd571f099d623ecf9161f1960c4ddf68e0cc98dee8bebfdb94a71ad4551f85a1afc94b63f6 languageName: node linkType: hard @@ -10321,7 +10044,7 @@ __metadata: resolution: "json5@npm:2.2.3" bin: json5: lib/cli.js - checksum: 2a7436a93393830bce797d4626275152e37e877b265e94ca69c99e3d20c2b9dab021279146a39cdb700e71b2dd32a4cebd1514cd57cee102b1af906ce5040349 + checksum: 1db67b853ff0de3534085d630691d3247de53a2ed1390ba0ddff681ea43e9b3e30ecbdb65c5e9aab49435e44059c23dbd6fee8ee619419ba37465bb0dd7135da languageName: node linkType: hard @@ -10329,12 +10052,12 @@ __metadata: version: 6.1.0 resolution: "jsonfile@npm:6.1.0" dependencies: - graceful-fs: ^4.1.6 - universalify: ^2.0.0 + graceful-fs: "npm:^4.1.6" + universalify: "npm:^2.0.0" dependenciesMeta: graceful-fs: optional: true - checksum: 7af3b8e1ac8fe7f1eccc6263c6ca14e1966fcbc74b618d3c78a0a2075579487547b94f72b7a1114e844a1e15bb00d440e5d1720bfc4612d790a6f285d5ea8354 + checksum: 03014769e7dc77d4cf05fa0b534907270b60890085dd5e4d60a382ff09328580651da0b8b4cdf44d91e4c8ae64d91791d965f05707beff000ed494a38b6fec85 languageName: node linkType: hard @@ -10342,11 +10065,11 @@ __metadata: version: 3.3.5 resolution: "jsx-ast-utils@npm:3.3.5" dependencies: - array-includes: ^3.1.6 - array.prototype.flat: ^1.3.1 - object.assign: ^4.1.4 - object.values: ^1.1.6 - checksum: f4b05fa4d7b5234230c905cfa88d36dc8a58a6666975a3891429b1a8cdc8a140bca76c297225cb7a499fad25a2c052ac93934449a2c31a44fc9edd06c773780a + array-includes: "npm:^3.1.6" + array.prototype.flat: "npm:^1.3.1" + object.assign: "npm:^4.1.4" + object.values: "npm:^1.1.6" + checksum: b61d44613687dfe4cc8ad4b4fbf3711bf26c60b8d5ed1f494d723e0808415c59b24a7c0ed8ab10736a40ff84eef38cbbfb68b395e05d31117b44ffc59d31edfc languageName: node linkType: hard @@ -10354,54 +10077,36 @@ __metadata: version: 4.5.4 resolution: "keyv@npm:4.5.4" dependencies: - json-buffer: 3.0.1 - checksum: 74a24395b1c34bd44ad5cb2b49140d087553e170625240b86755a6604cd65aa16efdbdeae5cdb17ba1284a0fbb25ad06263755dbc71b8d8b06f74232ce3cdd72 - languageName: node - linkType: hard - -"kind-of@npm:^3.0.2, kind-of@npm:^3.0.3, kind-of@npm:^3.2.0": - version: 3.2.2 - resolution: "kind-of@npm:3.2.2" - dependencies: - is-buffer: ^1.1.5 - checksum: e898df8ca2f31038f27d24f0b8080da7be274f986bc6ed176f37c77c454d76627619e1681f6f9d2e8d2fd7557a18ecc419a6bb54e422abcbb8da8f1a75e4b386 - languageName: node - linkType: hard - -"kind-of@npm:^4.0.0": - version: 4.0.0 - resolution: "kind-of@npm:4.0.0" - dependencies: - is-buffer: ^1.1.5 - checksum: 1b9e7624a8771b5a2489026e820f3bbbcc67893e1345804a56b23a91e9069965854d2a223a7c6ee563c45be9d8c6ff1ef87f28ed5f0d1a8d00d9dcbb067c529f - languageName: node - linkType: hard - -"kind-of@npm:^5.0.2": - version: 5.1.0 - resolution: "kind-of@npm:5.1.0" - checksum: f2a0102ae0cf19c4a953397e552571bad2b588b53282874f25fca7236396e650e2db50d41f9f516bd402536e4df968dbb51b8e69e4d5d4a7173def78448f7bab + json-buffer: "npm:3.0.1" + checksum: 167eb6ef64cc84b6fa0780ee50c9de456b422a1e18802209234f7c2cf7eae648c7741f32e50d7e24ccb22b24c13154070b01563d642755b156c357431a191e75 languageName: node linkType: hard "kind-of@npm:^6.0.2": version: 6.0.3 resolution: "kind-of@npm:6.0.3" - checksum: 3ab01e7b1d440b22fe4c31f23d8d38b4d9b91d9f291df683476576493d5dfd2e03848a8b05813dd0c3f0e835bc63f433007ddeceb71f05cb25c45ae1b19c6d3b + checksum: 5873d303fb36aad875b7538798867da2ae5c9e328d67194b0162a3659a627d22f742fc9c4ae95cd1704132a24b00cae5041fc00c0f6ef937dc17080dc4dbb962 languageName: node linkType: hard "kleur@npm:^3.0.3": version: 3.0.3 resolution: "kleur@npm:3.0.3" - checksum: df82cd1e172f957bae9c536286265a5cdbd5eeca487cb0a3b2a7b41ef959fc61f8e7c0e9aeea9c114ccf2c166b6a8dd45a46fd619c1c569d210ecd2765ad5169 + checksum: 0c0ecaf00a5c6173d25059c7db2113850b5457016dfa1d0e3ef26da4704fbb186b4938d7611246d86f0ddf1bccf26828daa5877b1f232a65e7373d0122a83e7f + languageName: node + linkType: hard + +"kuler@npm:^2.0.0": + version: 2.0.0 + resolution: "kuler@npm:2.0.0" + checksum: 9e10b5a1659f9ed8761d38df3c35effabffbd19fc6107324095238e4ef0ff044392cae9ac64a1c2dda26e532426485342226b93806bd97504b174b0dcf04ed81 languageName: node linkType: hard "language-subtag-registry@npm:^0.3.20": version: 0.3.22 resolution: "language-subtag-registry@npm:0.3.22" - checksum: 8ab70a7e0e055fe977ac16ea4c261faec7205ac43db5e806f72e5b59606939a3b972c4bd1e10e323b35d6ffa97c3e1c4c99f6553069dad2dfdd22020fa3eb56a + checksum: 5591f4abd775d1ab5945355a5ba894327d2d94c900607bdb69aac1bc5bb921dbeeeb5f616df95e8c0ae875501d19c1cfa0e852ece822121e95048deb34f2b4d2 languageName: node linkType: hard @@ -10409,8 +10114,8 @@ __metadata: version: 1.0.9 resolution: "language-tags@npm:1.0.9" dependencies: - language-subtag-registry: ^0.3.20 - checksum: 57c530796dc7179914dee71bc94f3747fd694612480241d0453a063777265dfe3a951037f7acb48f456bf167d6eb419d4c00263745326b3ba1cdcf4657070e78 + language-subtag-registry: "npm:^0.3.20" + checksum: d3a7c14b694e67f519153d6df6cb200681648d38d623c3bfa9d6a66a5ec5493628acb88e9df5aceef3cf1902ab263a205e7d59ee4cf1d6bb67e707b83538bd6d languageName: node linkType: hard @@ -10418,10 +10123,10 @@ __metadata: version: 4.0.0 resolution: "lazy-universal-dotenv@npm:4.0.0" dependencies: - app-root-dir: ^1.0.2 - dotenv: ^16.0.0 - dotenv-expand: ^10.0.0 - checksum: 196e0d701100144fbfe078d604a477573413ebf38dfe8d543748605e6a7074978508a3bb9f8135acd319db4fa947eef78836497163617d15a22163c59a00996b + app-root-dir: "npm:^1.0.2" + dotenv: "npm:^16.0.0" + dotenv-expand: "npm:^10.0.0" + checksum: 5aa4d1a01d108d1f4a565576b58e728be949ceccecef894d6a9de56cb2b8e2e033abd47424190d0a546cb22b4b4a3ab553346b9710c3294870660d4a3555dd34 languageName: node linkType: hard @@ -10436,9 +10141,9 @@ __metadata: version: 0.4.1 resolution: "levn@npm:0.4.1" dependencies: - prelude-ls: ^1.2.1 - type-check: ~0.4.0 - checksum: 12c5021c859bd0f5248561bf139121f0358285ec545ebf48bb3d346820d5c61a4309535c7f387ed7d84361cf821e124ce346c6b7cef8ee09a67c1473b46d0fc4 + prelude-ls: "npm:^1.2.1" + type-check: "npm:~0.4.0" + checksum: 2e4720ff79f21ae08d42374b0a5c2f664c5be8b6c8f565bb4e1315c96ed3a8acaa9de788ffed82d7f2378cf36958573de07ef92336cb5255ed74d08b8318c9ee languageName: node linkType: hard @@ -10449,23 +10154,12 @@ __metadata: languageName: node linkType: hard -"loader-utils@npm:^1.1.0": - version: 1.4.2 - resolution: "loader-utils@npm:1.4.2" - dependencies: - big.js: ^5.2.2 - emojis-list: ^3.0.0 - json5: ^1.0.1 - checksum: eb6fb622efc0ffd1abdf68a2022f9eac62bef8ec599cf8adb75e94d1d338381780be6278534170e99edc03380a6d29bc7eb1563c89ce17c5fed3a0b17f1ad804 - languageName: node - linkType: hard - "locate-path@npm:^3.0.0": version: 3.0.0 resolution: "locate-path@npm:3.0.0" dependencies: - p-locate: ^3.0.0 - path-exists: ^3.0.0 + p-locate: "npm:^3.0.0" + path-exists: "npm:^3.0.0" checksum: 53db3996672f21f8b0bf2a2c645ae2c13ffdae1eeecfcd399a583bce8516c0b88dcb4222ca6efbbbeb6949df7e46860895be2c02e8d3219abd373ace3bfb4e11 languageName: node linkType: hard @@ -10474,7 +10168,7 @@ __metadata: version: 5.0.0 resolution: "locate-path@npm:5.0.0" dependencies: - p-locate: ^4.1.0 + p-locate: "npm:^4.1.0" checksum: 83e51725e67517287d73e1ded92b28602e3ae5580b301fe54bfb76c0c723e3f285b19252e375712316774cf52006cb236aed5704692c32db0d5d089b69696e30 languageName: node linkType: hard @@ -10483,7 +10177,7 @@ __metadata: version: 6.0.0 resolution: "locate-path@npm:6.0.0" dependencies: - p-locate: ^5.0.0 + p-locate: "npm:^5.0.0" checksum: 72eb661788a0368c099a184c59d2fee760b3831c9c1c33955e8a19ae4a21b4116e53fa736dc086cdeb9fce9f7cc508f2f92d2d3aae516f133e16a2bb59a39f5a languageName: node linkType: hard @@ -10491,21 +10185,28 @@ __metadata: "lodash.debounce@npm:^4.0.8": version: 4.0.8 resolution: "lodash.debounce@npm:4.0.8" - checksum: a3f527d22c548f43ae31c861ada88b2637eb48ac6aa3eb56e82d44917971b8aa96fbb37aa60efea674dc4ee8c42074f90f7b1f772e9db375435f6c83a19b3bc6 + checksum: cd0b2819786e6e80cb9f5cda26b1a8fc073daaf04e48d4cb462fa4663ec9adb3a5387aa22d7129e48eed1afa05b482e2a6b79bfc99b86886364449500cbb00fd + languageName: node + linkType: hard + +"lodash.escape@npm:^4.0.1": + version: 4.0.1 + resolution: "lodash.escape@npm:4.0.1" + checksum: ba1effab9aea7e20ee69b26cbfeb41c73da2eb4d2ab1c261aaf53dd0902ce1afc2f0b34fb24bc69c1d2dd201c332e1d1eb696092fc844a2c5c8e7ccd1ca32014 languageName: node linkType: hard "lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" - checksum: ad580b4bdbb7ca1f7abf7e1bce63a9a0b98e370cf40194b03380a46b4ed799c9573029599caebc1b14e3f24b111aef72b96674a56cfa105e0f5ac70546cdc005 + checksum: d0ea2dd0097e6201be083865d50c3fb54fbfbdb247d9cc5950e086c991f448b7ab0cdab0d57eacccb43473d3f2acd21e134db39f22dac2d6c9ba6bf26978e3d6 languageName: node linkType: hard "lodash@npm:^4.17.15, lodash@npm:^4.17.21": version: 4.17.21 resolution: "lodash@npm:4.17.21" - checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7 + checksum: c08619c038846ea6ac754abd6dd29d2568aa705feb69339e836dfa8d8b09abbb2f859371e86863eda41848221f9af43714491467b5b0299122431e202bb0c532 languageName: node linkType: hard @@ -10513,17 +10214,31 @@ __metadata: version: 4.1.0 resolution: "log-symbols@npm:4.1.0" dependencies: - chalk: ^4.1.0 - is-unicode-supported: ^0.1.0 + chalk: "npm:^4.1.0" + is-unicode-supported: "npm:^0.1.0" checksum: fce1497b3135a0198803f9f07464165e9eb83ed02ceb2273930a6f8a508951178d8cf4f0378e9d28300a2ed2bc49050995d2bd5f53ab716bb15ac84d58c6ef74 languageName: node linkType: hard +"logform@npm:^2.3.2, logform@npm:^2.4.0": + version: 2.6.0 + resolution: "logform@npm:2.6.0" + dependencies: + "@colors/colors": "npm:1.6.0" + "@types/triple-beam": "npm:^1.3.2" + fecha: "npm:^4.2.0" + ms: "npm:^2.1.1" + safe-stable-stringify: "npm:^2.3.1" + triple-beam: "npm:^1.3.0" + checksum: 92de5696a529a7ccf4359fe65a21fce2398ba20c4b4e5769cba187b8fde01d590a22d3c83f797d31b436f49770fb1b2f28646e7c881d30b8d1f4080a05ae7006 + languageName: node + linkType: hard + "loose-envify@npm:^1.0.0, loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" dependencies: - js-tokens: ^3.0.0 || ^4.0.0 + js-tokens: "npm:^3.0.0 || ^4.0.0" bin: loose-envify: cli.js checksum: 6517e24e0cad87ec9888f500c5b5947032cdfe6ef65e1c1936a0c48a524b81e65542c9c3edc91c97d5bddc806ee2a985dbc79be89215d613b1de5db6d1cfe6f4 @@ -10534,16 +10249,16 @@ __metadata: version: 1.20.0 resolution: "lowlight@npm:1.20.0" dependencies: - fault: ^1.0.0 - highlight.js: ~10.7.0 - checksum: 14a1815d6bae202ddee313fc60f06d46e5235c02fa483a77950b401d85b4c1e12290145ccd17a716b07f9328bd5864aa2d402b6a819ff3be7c833d9748ff8ba7 + fault: "npm:^1.0.0" + highlight.js: "npm:~10.7.0" + checksum: 3294677be15bbc256556f097d9b675f23f14309aceeada7880473c57bdbdd7761f200d903fe26d8fa5e82259f70a39465d1d40754c4c049ad2bbd33d77e2c06f languageName: node linkType: hard "lru-cache@npm:^10.0.1, lru-cache@npm:^9.1.1 || ^10.0.0": version: 10.2.0 resolution: "lru-cache@npm:10.2.0" - checksum: eee7ddda4a7475deac51ac81d7dd78709095c6fa46e8350dc2d22462559a1faa3b81ed931d5464b13d48cbd7e08b46100b6f768c76833912bc444b99c37e25db + checksum: 502ec42c3309c0eae1ce41afca471f831c278566d45a5273a0c51102dee31e0e250a62fa9029c3370988df33a14188a38e682c16143b794de78668de3643e302 languageName: node linkType: hard @@ -10551,8 +10266,8 @@ __metadata: version: 5.1.1 resolution: "lru-cache@npm:5.1.1" dependencies: - yallist: ^3.0.2 - checksum: c154ae1cbb0c2206d1501a0e94df349653c92c8cbb25236d7e85190bcaf4567a03ac6eb43166fabfa36fd35623694da7233e88d9601fbf411a9a481d85dbd2cb + yallist: "npm:^3.0.2" + checksum: 951d2673dcc64a7fb888bf3d13bc2fdf923faca97d89cdb405ba3dfff77e2b26e5798d405e78fcd7094c9e7b8b4dab2ddc5a4f8a11928af24a207b7c738ca3f8 languageName: node linkType: hard @@ -10560,8 +10275,8 @@ __metadata: version: 6.0.0 resolution: "lru-cache@npm:6.0.0" dependencies: - yallist: ^4.0.0 - checksum: f97f499f898f23e4585742138a22f22526254fdba6d75d41a1c2526b3b6cc5747ef59c5612ba7375f42aca4f8461950e925ba08c991ead0651b4918b7c978297 + yallist: "npm:^4.0.0" + checksum: fc1fe2ee205f7c8855fa0f34c1ab0bcf14b6229e35579ec1fd1079f31d6fc8ef8eb6fd17f2f4d99788d7e339f50e047555551ebd5e434dda503696e7c6591825 languageName: node linkType: hard @@ -10570,7 +10285,7 @@ __metadata: resolution: "lz-string@npm:1.5.0" bin: lz-string: bin/bin.js - checksum: 1ee98b4580246fd90dd54da6e346fb1caefcf05f677c686d9af237a157fdea3fd7c83a4bc58f858cd5b10a34d27afe0fdcbd0505a47e0590726a873dc8b8f65d + checksum: e86f0280e99a8d8cd4eef24d8601ddae15ce54e43ac9990dfcb79e1e081c255ad24424a30d78d2ad8e51a8ce82a66a930047fed4b4aa38c6f0b392ff9300edfc languageName: node linkType: hard @@ -10578,8 +10293,8 @@ __metadata: version: 0.27.0 resolution: "magic-string@npm:0.27.0" dependencies: - "@jridgewell/sourcemap-codec": ^1.4.13 - checksum: 273faaa50baadb7a2df6e442eac34ad611304fc08fe16e24fe2e472fd944bfcb73ffb50d2dc972dc04e92784222002af46868cb9698b1be181c81830fd95a13e + "@jridgewell/sourcemap-codec": "npm:^1.4.13" + checksum: 10a18a48d22fb14467d6cb4204aba58d6790ae7ba023835dc7a65e310cf216f042a17fab1155ba43e47117310a9b7c3fd3bb79f40be40f5124d6b1af9e96399b languageName: node linkType: hard @@ -10587,8 +10302,8 @@ __metadata: version: 0.30.7 resolution: "magic-string@npm:0.30.7" dependencies: - "@jridgewell/sourcemap-codec": ^1.4.15 - checksum: bdf102e36a44d1728ec61b69d655caba3f66ca58898e292f6debe57dc30896bd37908bfe3464a7464a435831a9e44aa905cebd681e21c2f44bbe4dddf225619f + "@jridgewell/sourcemap-codec": "npm:^1.4.15" + checksum: 883eaaf6792a3263e44f4bcdcd35ace272268e4b98ed5a770ad711947958d2f9fc683e474945e306e2bdc152b7e44d369ee312690d87025b9879fc63fbe1409c languageName: node linkType: hard @@ -10596,8 +10311,8 @@ __metadata: version: 2.1.0 resolution: "make-dir@npm:2.1.0" dependencies: - pify: ^4.0.1 - semver: ^5.6.0 + pify: "npm:^4.0.1" + semver: "npm:^5.6.0" checksum: 043548886bfaf1820323c6a2997e6d2fa51ccc2586ac14e6f14634f7458b4db2daf15f8c310e2a0abd3e0cddc64df1890d8fc7263033602c47bb12cbfcf86aab languageName: node linkType: hard @@ -10606,7 +10321,7 @@ __metadata: version: 3.1.0 resolution: "make-dir@npm:3.1.0" dependencies: - semver: ^6.0.0 + semver: "npm:^6.0.0" checksum: 484200020ab5a1fdf12f393fe5f385fc8e4378824c940fba1729dcd198ae4ff24867bc7a5646331e50cead8abff5d9270c456314386e629acec6dff4b8016b78 languageName: node linkType: hard @@ -10615,7 +10330,7 @@ __metadata: version: 4.0.0 resolution: "make-dir@npm:4.0.0" dependencies: - semver: ^7.5.3 + semver: "npm:^7.5.3" checksum: bf0731a2dd3aab4db6f3de1585cea0b746bb73eb5a02e3d8d72757e376e64e6ada190b1eddcde5b2f24a81b688a9897efd5018737d05e02e2a671dda9cff8a8a languageName: node linkType: hard @@ -10624,18 +10339,18 @@ __metadata: version: 13.0.0 resolution: "make-fetch-happen@npm:13.0.0" dependencies: - "@npmcli/agent": ^2.0.0 - cacache: ^18.0.0 - http-cache-semantics: ^4.1.1 - is-lambda: ^1.0.1 - minipass: ^7.0.2 - minipass-fetch: ^3.0.0 - minipass-flush: ^1.0.5 - minipass-pipeline: ^1.2.4 - negotiator: ^0.6.3 - promise-retry: ^2.0.1 - ssri: ^10.0.0 - checksum: 7c7a6d381ce919dd83af398b66459a10e2fe8f4504f340d1d090d3fa3d1b0c93750220e1d898114c64467223504bd258612ba83efbc16f31b075cd56de24b4af + "@npmcli/agent": "npm:^2.0.0" + cacache: "npm:^18.0.0" + http-cache-semantics: "npm:^4.1.1" + is-lambda: "npm:^1.0.1" + minipass: "npm:^7.0.2" + minipass-fetch: "npm:^3.0.0" + minipass-flush: "npm:^1.0.5" + minipass-pipeline: "npm:^1.2.4" + negotiator: "npm:^0.6.3" + promise-retry: "npm:^2.0.1" + ssri: "npm:^10.0.0" + checksum: ded5a91a02b76381b06a4ec4d5c1d23ebbde15d402b3c3e4533b371dac7e2f7ca071ae71ae6dae72aa261182557b7b1b3fd3a705b39252dc17f74fa509d3e76f languageName: node linkType: hard @@ -10643,31 +10358,15 @@ __metadata: version: 1.0.12 resolution: "makeerror@npm:1.0.12" dependencies: - tmpl: 1.0.5 - checksum: b38a025a12c8146d6eeea5a7f2bf27d51d8ad6064da8ca9405fcf7bf9b54acd43e3b30ddd7abb9b1bfa4ddb266019133313482570ddb207de568f71ecfcf6060 - languageName: node - linkType: hard - -"map-cache@npm:^0.2.2": - version: 0.2.2 - resolution: "map-cache@npm:0.2.2" - checksum: 3067cea54285c43848bb4539f978a15dedc63c03022abeec6ef05c8cb6829f920f13b94bcaf04142fc6a088318e564c4785704072910d120d55dbc2e0c421969 + tmpl: "npm:1.0.5" + checksum: 4c66ddfc654537333da952c084f507fa4c30c707b1635344eb35be894d797ba44c901a9cebe914aa29a7f61357543ba09b09dddbd7f65b4aee756b450f169f40 languageName: node linkType: hard "map-or-similar@npm:^1.5.0": version: 1.5.0 resolution: "map-or-similar@npm:1.5.0" - checksum: f65c0d420e272d0fce4e24db35f6a08109218480bca1d61eaa442cbe6cf46270b840218d3b5e94e4bfcc2595f1d0a1fa5885df750b52aac9ab1d437b29dcce38 - languageName: node - linkType: hard - -"map-visit@npm:^1.0.0": - version: 1.0.0 - resolution: "map-visit@npm:1.0.0" - dependencies: - object-visit: ^1.0.0 - checksum: c27045a5021c344fc19b9132eb30313e441863b2951029f8f8b66f79d3d8c1e7e5091578075a996f74e417479506fe9ede28c44ca7bc351a61c9d8073daec36a + checksum: 3cf43bcd0e7af41d7bade5f8b5be6bb9d021cc47e6008ad545d071cf3a709ba782884002f9eec6ccd51f572fc17841e07bf74628e0bc3694c33f4622b03e4b4c languageName: node linkType: hard @@ -10676,7 +10375,7 @@ __metadata: resolution: "markdown-to-jsx@npm:7.4.1" peerDependencies: react: ">= 0.14.0" - checksum: 2888cb2389cb810ab35454a59d0623474a60a78e28f281ae0081f87053f6c59b033232a2cd269cc383a5edcaa1eab8ca4b3cf639fe4e1aa3fb418648d14bcc7d + checksum: 7f68a6f3ae0855c13d2d54881c1c1e2c1776c4f4149e84e41ce35a76a007b4deb9784fd19018eebf1bba31d7dfd6a92c30ad6815d481dcb38b74da7a20d4cb44 languageName: node linkType: hard @@ -10684,8 +10383,8 @@ __metadata: version: 4.0.0 resolution: "mdast-util-definitions@npm:4.0.0" dependencies: - unist-util-visit: ^2.0.0 - checksum: 2325f20b82b3fb8cb5fda77038ee0bbdd44f82cfca7c48a854724b58bc1fe5919630a3ce7c45e210726df59d46c881d020b2da7a493bfd1ee36eb2bbfef5d78e + unist-util-visit: "npm:^2.0.0" + checksum: c76da4b4f1e28f8e7c85bf664ab65060f5aa7e0fd0392a24482980984d4ba878b7635a08bcaccca060d6602f478ac6cadaffbbe65f910f75ce332fd67d0ade69 languageName: node linkType: hard @@ -10699,14 +10398,14 @@ __metadata: "mdn-data@npm:2.0.14": version: 2.0.14 resolution: "mdn-data@npm:2.0.14" - checksum: 9d0128ed425a89f4cba8f787dca27ad9408b5cb1b220af2d938e2a0629d17d879a34d2cb19318bdb26c3f14c77dd5dfbae67211f5caaf07b61b1f2c5c8c7dc16 + checksum: 64c629fcf14807e30d6dc79f97cbcafa16db066f53a294299f3932b3beb0eb0d1386d3a7fe408fc67348c449a4e0999360c894ba4c81eb209d7be4e36503de0e languageName: node linkType: hard "media-typer@npm:0.3.0": version: 0.3.0 resolution: "media-typer@npm:0.3.0" - checksum: af1b38516c28ec95d6b0826f6c8f276c58aec391f76be42aa07646b4e39d317723e869700933ca6995b056db4b09a78c92d5440dc23657e6764be5d28874bba1 + checksum: 38e0984db39139604756903a01397e29e17dcb04207bb3e081412ce725ab17338ecc47220c1b186b6bbe79a658aad1b0d41142884f5a481f36290cdefbe6aa46 languageName: node linkType: hard @@ -10714,8 +10413,8 @@ __metadata: version: 1.11.3 resolution: "memoizerific@npm:1.11.3" dependencies: - map-or-similar: ^1.5.0 - checksum: d51bdc3ed8c39b4b73845c90eb62d243ddf21899914352d0c303f5e1d477abcb192f4c605e008caa4a31d823225eeb22a99ba5ee825fb88d0c33382db3aee95a + map-or-similar: "npm:^1.5.0" + checksum: 72b6b80699777d000f03db6e15fdabcd4afe77feb45be51fe195cb230c64a368fcfcfbb976375eac3283bd8193d6b1a67ac3081cae07f64fca73f1aa568d59e3 languageName: node linkType: hard @@ -10726,15 +10425,6 @@ __metadata: languageName: node linkType: hard -"merge-options@npm:1.0.1": - version: 1.0.1 - resolution: "merge-options@npm:1.0.1" - dependencies: - is-plain-obj: ^1.1 - checksum: 7e3d5d658879038cdc225107205dacd68fd8e22cf4f54fb37fd9e0687f7eb9efd7f0f2163577675325a3a72c9df0566e23911d0d8a2448ca8f83eee5199dd990 - languageName: node - linkType: hard - "merge-stream@npm:^2.0.0": version: 2.0.0 resolution: "merge-stream@npm:2.0.0" @@ -10752,45 +10442,24 @@ __metadata: "methods@npm:~1.1.2": version: 1.1.2 resolution: "methods@npm:1.1.2" - checksum: 0917ff4041fa8e2f2fda5425a955fe16ca411591fbd123c0d722fcf02b73971ed6f764d85f0a6f547ce49ee0221ce2c19a5fa692157931cecb422984f1dcd13a + checksum: a385dd974faa34b5dd021b2bbf78c722881bf6f003bfe6d391d7da3ea1ed625d1ff10ddd13c57531f628b3e785be38d3eed10ad03cebd90b76932413df9a1820 languageName: node linkType: hard -"micromatch@npm:3.1.0": - version: 3.1.0 - resolution: "micromatch@npm:3.1.0" - dependencies: - arr-diff: ^4.0.0 - array-unique: ^0.3.2 - braces: ^2.2.2 - define-property: ^1.0.0 - extend-shallow: ^2.0.1 - extglob: ^2.0.2 - fragment-cache: ^0.2.1 - kind-of: ^5.0.2 - nanomatch: ^1.2.1 - object.pick: ^1.3.0 - regex-not: ^1.0.0 - snapdragon: ^0.8.1 - to-regex: ^3.0.1 - checksum: 4c28b7c9e49a510f62ced8ec70dde03871931bfdae8a594762404dddd7666f3acdf1d14cadddda609d8114648a702738a0f9672a31ac4e0f4896b9e4962c6bd6 - languageName: node - linkType: hard - -"micromatch@npm:^4.0.2, micromatch@npm:^4.0.4": +"micromatch@npm:^4.0.4": version: 4.0.5 resolution: "micromatch@npm:4.0.5" dependencies: - braces: ^3.0.2 - picomatch: ^2.3.1 - checksum: 02a17b671c06e8fefeeb6ef996119c1e597c942e632a21ef589154f23898c9c6a9858526246abb14f8bca6e77734aa9dcf65476fca47cedfb80d9577d52843fc + braces: "npm:^3.0.2" + picomatch: "npm:^2.3.1" + checksum: a749888789fc15cac0e03273844dbd749f9f8e8d64e70c564bcf06a033129554c789bb9e30d7566d7ff6596611a08e58ac12cf2a05f6e3c9c47c50c4c7e12fa2 languageName: node linkType: hard "mime-db@npm:1.52.0, mime-db@npm:>= 1.43.0 < 2": version: 1.52.0 resolution: "mime-db@npm:1.52.0" - checksum: 0d99a03585f8b39d68182803b12ac601d9c01abfa28ec56204fa330bc9f3d1c5e14beb049bafadb3dbdf646dfb94b87e24d4ec7b31b7279ef906a8ea9b6a513f + checksum: 54bb60bf39e6f8689f6622784e668a3d7f8bed6b0d886f5c3c446cb3284be28b30bf707ed05d0fe44a036f8469976b2629bbea182684977b084de9da274694d7 languageName: node linkType: hard @@ -10798,8 +10467,8 @@ __metadata: version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: - mime-db: 1.52.0 - checksum: 89a5b7f1def9f3af5dad6496c5ed50191ae4331cc5389d7c521c8ad28d5fdad2d06fd81baf38fed813dc4e46bb55c8145bb0ff406330818c9cf712fb2e9b3836 + mime-db: "npm:1.52.0" + checksum: 89aa9651b67644035de2784a6e665fc685d79aba61857e02b9c8758da874a754aed4a9aced9265f5ed1171fd934331e5516b84a7f0218031b6fa0270eca1e51a languageName: node linkType: hard @@ -10808,7 +10477,7 @@ __metadata: resolution: "mime@npm:1.6.0" bin: mime: cli.js - checksum: fef25e39263e6d207580bdc629f8872a3f9772c923c7f8c7e793175cee22777bbe8bba95e5d509a40aaa292d8974514ce634ae35769faa45f22d17edda5e8557 + checksum: b7d98bb1e006c0e63e2c91b590fe1163b872abf8f7ef224d53dd31499c2197278a6d3d0864c45239b1a93d22feaf6f9477e9fc847eef945838150b8c02d03170 languageName: node linkType: hard @@ -10817,7 +10486,7 @@ __metadata: resolution: "mime@npm:2.6.0" bin: mime: cli.js - checksum: 1497ba7b9f6960694268a557eae24b743fd2923da46ec392b042469f4b901721ba0adcf8b0d3c2677839d0e243b209d76e5edcbd09cfdeffa2dfb6bb4df4b862 + checksum: 7da117808b5cd0203bb1b5e33445c330fe213f4d8ee2402a84d62adbde9716ca4fb90dd6d9ab4e77a4128c6c5c24a9c4c9f6a4d720b095b1b342132d02dba58d languageName: node linkType: hard @@ -10846,8 +10515,8 @@ __metadata: version: 9.0.3 resolution: "minimatch@npm:9.0.3" dependencies: - brace-expansion: ^2.0.1 - checksum: 253487976bf485b612f16bf57463520a14f512662e592e95c571afdab1442a6a6864b6c88f248ce6fc4ff0b6de04ac7aa6c8bb51e868e99d1d65eb0658a708b5 + brace-expansion: "npm:^2.0.1" + checksum: c81b47d28153e77521877649f4bab48348d10938df9e8147a58111fe00ef89559a2938de9f6632910c4f7bf7bb5cd81191a546167e58d357f0cfb1e18cecc1c5 languageName: node linkType: hard @@ -10855,8 +10524,8 @@ __metadata: version: 3.1.2 resolution: "minimatch@npm:3.1.2" dependencies: - brace-expansion: ^1.1.7 - checksum: c154e566406683e7bcb746e000b84d74465b3a832c45d59912b9b55cd50dee66e5c4b1e5566dba26154040e51672f9aa450a9aef0c97cfc7336b78b7afb9540a + brace-expansion: "npm:^1.1.7" + checksum: e0b25b04cd4ec6732830344e5739b13f8690f8a012d73445a4a19fbc623f5dd481ef7a5827fde25954cd6026fede7574cc54dc4643c99d6c6b653d6203f94634 languageName: node linkType: hard @@ -10864,15 +10533,15 @@ __metadata: version: 5.1.6 resolution: "minimatch@npm:5.1.6" dependencies: - brace-expansion: ^2.0.1 - checksum: 7564208ef81d7065a370f788d337cd80a689e981042cb9a1d0e6580b6c6a8c9279eba80010516e258835a988363f99f54a6f711a315089b8b42694f5da9d0d77 + brace-expansion: "npm:^2.0.1" + checksum: 126b36485b821daf96d33b5c821dac600cc1ab36c87e7a532594f9b1652b1fa89a1eebcaad4dff17c764dce1a7ac1531327f190fed5f97d8f6e5f889c116c429 languageName: node linkType: hard "minimist@npm:^1.2.0, minimist@npm:^1.2.5, minimist@npm:^1.2.6": version: 1.2.8 resolution: "minimist@npm:1.2.8" - checksum: 75a6d645fb122dad29c06a7597bddea977258957ed88d7a6df59b5cd3fe4a527e253e9bbf2e783e4b73657f9098b96a5fe96ab8a113655d4109108577ecf85b0 + checksum: 908491b6cc15a6c440ba5b22780a0ba89b9810e1aea684e253e43c4e3b8d56ec1dcdd7ea96dde119c29df59c936cde16062159eae4225c691e19c70b432b6e6f languageName: node linkType: hard @@ -10880,7 +10549,7 @@ __metadata: version: 2.0.1 resolution: "minipass-collect@npm:2.0.1" dependencies: - minipass: ^7.0.3 + minipass: "npm:^7.0.3" checksum: b251bceea62090f67a6cced7a446a36f4cd61ee2d5cea9aee7fff79ba8030e416327a1c5aa2908dc22629d06214b46d88fdab8c51ac76bacbf5703851b5ad342 languageName: node linkType: hard @@ -10889,14 +10558,14 @@ __metadata: version: 3.0.4 resolution: "minipass-fetch@npm:3.0.4" dependencies: - encoding: ^0.1.13 - minipass: ^7.0.3 - minipass-sized: ^1.0.3 - minizlib: ^2.1.2 + encoding: "npm:^0.1.13" + minipass: "npm:^7.0.3" + minipass-sized: "npm:^1.0.3" + minizlib: "npm:^2.1.2" dependenciesMeta: encoding: optional: true - checksum: af7aad15d5c128ab1ebe52e043bdf7d62c3c6f0cecb9285b40d7b395e1375b45dcdfd40e63e93d26a0e8249c9efd5c325c65575aceee192883970ff8cb11364a + checksum: 3edf72b900e30598567eafe96c30374432a8709e61bb06b87198fa3192d466777e2ec21c52985a0999044fa6567bd6f04651585983a1cbb27e2c1770a07ed2a2 languageName: node linkType: hard @@ -10904,7 +10573,7 @@ __metadata: version: 1.0.5 resolution: "minipass-flush@npm:1.0.5" dependencies: - minipass: ^3.0.0 + minipass: "npm:^3.0.0" checksum: 56269a0b22bad756a08a94b1ffc36b7c9c5de0735a4dd1ab2b06c066d795cfd1f0ac44a0fcae13eece5589b908ecddc867f04c745c7009be0b566421ea0944cf languageName: node linkType: hard @@ -10913,7 +10582,7 @@ __metadata: version: 1.2.4 resolution: "minipass-pipeline@npm:1.2.4" dependencies: - minipass: ^3.0.0 + minipass: "npm:^3.0.0" checksum: b14240dac0d29823c3d5911c286069e36d0b81173d7bdf07a7e4a91ecdef92cdff4baaf31ea3746f1c61e0957f652e641223970870e2353593f382112257971b languageName: node linkType: hard @@ -10922,8 +10591,8 @@ __metadata: version: 1.0.3 resolution: "minipass-sized@npm:1.0.3" dependencies: - minipass: ^3.0.0 - checksum: 79076749fcacf21b5d16dd596d32c3b6bf4d6e62abb43868fac21674078505c8b15eaca4e47ed844985a4514854f917d78f588fcd029693709417d8f98b2bd60 + minipass: "npm:^3.0.0" + checksum: 40982d8d836a52b0f37049a0a7e5d0f089637298e6d9b45df9c115d4f0520682a78258905e5c8b180fb41b593b0a82cc1361d2c74b45f7ada66334f84d1ecfdd languageName: node linkType: hard @@ -10931,22 +10600,22 @@ __metadata: version: 3.3.6 resolution: "minipass@npm:3.3.6" dependencies: - yallist: ^4.0.0 - checksum: a30d083c8054cee83cdcdc97f97e4641a3f58ae743970457b1489ce38ee1167b3aaf7d815cd39ec7a99b9c40397fd4f686e83750e73e652b21cb516f6d845e48 + yallist: "npm:^4.0.0" + checksum: a5c6ef069f70d9a524d3428af39f2b117ff8cd84172e19b754e7264a33df460873e6eb3d6e55758531580970de50ae950c496256bb4ad3691a2974cddff189f0 languageName: node linkType: hard "minipass@npm:^5.0.0": version: 5.0.0 resolution: "minipass@npm:5.0.0" - checksum: 425dab288738853fded43da3314a0b5c035844d6f3097a8e3b5b29b328da8f3c1af6fc70618b32c29ff906284cf6406b6841376f21caaadd0793c1d5a6a620ea + checksum: 61682162d29f45d3152b78b08bab7fb32ca10899bc5991ffe98afc18c9e9543bd1e3be94f8b8373ba6262497db63607079dc242ea62e43e7b2270837b7347c93 languageName: node linkType: hard "minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3": version: 7.0.4 resolution: "minipass@npm:7.0.4" - checksum: 87585e258b9488caf2e7acea242fd7856bbe9a2c84a7807643513a338d66f368c7d518200ad7b70a508664d408aa000517647b2930c259a8b1f9f0984f344a21 + checksum: e864bd02ceb5e0707696d58f7ce3a0b89233f0d686ef0d447a66db705c0846a8dc6f34865cd85256c1472ff623665f616b90b8ff58058b2ad996c5de747d2d18 languageName: node linkType: hard @@ -10954,19 +10623,9 @@ __metadata: version: 2.1.2 resolution: "minizlib@npm:2.1.2" dependencies: - minipass: ^3.0.0 - yallist: ^4.0.0 - checksum: f1fdeac0b07cf8f30fcf12f4b586795b97be856edea22b5e9072707be51fc95d41487faec3f265b42973a304fe3a64acd91a44a3826a963e37b37bafde0212c3 - languageName: node - linkType: hard - -"mixin-deep@npm:^1.2.0": - version: 1.3.2 - resolution: "mixin-deep@npm:1.3.2" - dependencies: - for-in: ^1.0.2 - is-extendable: ^1.0.1 - checksum: 820d5a51fcb7479f2926b97f2c3bb223546bc915e6b3a3eb5d906dda871bba569863595424a76682f2b15718252954644f3891437cb7e3f220949bed54b1750d + minipass: "npm:^3.0.0" + yallist: "npm:^4.0.0" + checksum: ae0f45436fb51344dcb87938446a32fbebb540d0e191d63b35e1c773d47512e17307bf54aa88326cc6d176594d00e4423563a091f7266c2f9a6872cdc1e234d1 languageName: node linkType: hard @@ -10981,7 +10640,7 @@ __metadata: version: 0.5.6 resolution: "mkdirp@npm:0.5.6" dependencies: - minimist: ^1.2.6 + minimist: "npm:^1.2.6" bin: mkdirp: bin/cmd.js checksum: 0c91b721bb12c3f9af4b77ebf73604baf350e64d80df91754dc509491ae93bf238581e59c7188360cec7cb62fc4100959245a42cfe01834efedc5e9d068376c2 @@ -10993,7 +10652,7 @@ __metadata: resolution: "mkdirp@npm:1.0.4" bin: mkdirp: bin/cmd.js - checksum: a96865108c6c3b1b8e1d5e9f11843de1e077e57737602de1b82030815f311be11f96f09cce59bd5b903d0b29834733e5313f9301e3ed6d6f6fba2eae0df4298f + checksum: d71b8dcd4b5af2fe13ecf3bd24070263489404fe216488c5ba7e38ece1f54daf219e72a833a3a2dc404331e870e9f44963a33399589490956bff003a3404d3b2 languageName: node linkType: hard @@ -11018,31 +10677,21 @@ __metadata: languageName: node linkType: hard +"mustache@npm:^4.2.0": + version: 4.2.0 + resolution: "mustache@npm:4.2.0" + bin: + mustache: bin/mustache + checksum: 6e668bd5803255ab0779c3983b9412b5c4f4f90e822230e0e8f414f5449ed7a137eed29430e835aa689886f663385cfe05f808eb34b16e1f3a95525889b05cd3 + languageName: node + linkType: hard + "nanoid@npm:^3.3.7": version: 3.3.7 resolution: "nanoid@npm:3.3.7" bin: nanoid: bin/nanoid.cjs - checksum: d36c427e530713e4ac6567d488b489a36582ef89da1d6d4e3b87eded11eb10d7042a877958c6f104929809b2ab0bafa17652b076cdf84324aa75b30b722204f2 - languageName: node - linkType: hard - -"nanomatch@npm:^1.2.1": - version: 1.2.13 - resolution: "nanomatch@npm:1.2.13" - dependencies: - arr-diff: ^4.0.0 - array-unique: ^0.3.2 - define-property: ^2.0.2 - extend-shallow: ^3.0.2 - fragment-cache: ^0.2.1 - is-windows: ^1.0.2 - kind-of: ^6.0.2 - object.pick: ^1.3.0 - regex-not: ^1.0.0 - snapdragon: ^0.8.1 - to-regex: ^3.0.1 - checksum: 54d4166d6ef08db41252eb4e96d4109ebcb8029f0374f9db873bd91a1f896c32ec780d2a2ea65c0b2d7caf1f28d5e1ea33746a470f32146ac8bba821d80d38d8 + checksum: ac1eb60f615b272bccb0e2b9cd933720dad30bf9708424f691b8113826bb91aca7e9d14ef5d9415a6ba15c266b37817256f58d8ce980c82b0ba3185352565679 languageName: node linkType: hard @@ -11056,14 +10705,14 @@ __metadata: "negotiator@npm:0.6.3, negotiator@npm:^0.6.3": version: 0.6.3 resolution: "negotiator@npm:0.6.3" - checksum: b8ffeb1e262eff7968fc90a2b6767b04cfd9842582a9d0ece0af7049537266e7b2506dfb1d107a32f06dd849ab2aea834d5830f7f4d0e5cb7d36e1ae55d021d9 + checksum: 2723fb822a17ad55c93a588a4bc44d53b22855bf4be5499916ca0cab1e7165409d0b288ba2577d7b029f10ce18cf2ed8e703e5af31c984e1e2304277ef979837 languageName: node linkType: hard "neo-async@npm:^2.5.0, neo-async@npm:^2.6.2": version: 2.6.2 resolution: "neo-async@npm:2.6.2" - checksum: deac9f8d00eda7b2e5cd1b2549e26e10a0faa70adaa6fdadca701cc55f49ee9018e427f424bac0c790b7c7e2d3068db97f3093f1093975f2acb8f8818b936ed9 + checksum: 1a7948fea86f2b33ec766bc899c88796a51ba76a4afc9026764aedc6e7cde692a09067031e4a1bf6db4f978ccd99e7f5b6c03fe47ad9865c3d4f99050d67e002 languageName: node linkType: hard @@ -11071,15 +10720,15 @@ __metadata: version: 0.1.17 resolution: "node-dir@npm:0.1.17" dependencies: - minimatch: ^3.0.2 - checksum: 29de9560e52cdac8d3f794d38d782f6799e13d4d11aaf96d3da8c28458e1c5e33bb5f8edfb42dc34172ec5516c50c5b8850c9e1526542616757a969267263328 + minimatch: "npm:^3.0.2" + checksum: 281fdea12d9c080a7250e5b5afefa3ab39426d40753ec8126a2d1e67f189b8824723abfed74f5d8549c5d78352d8c489fe08d0b067d7684c87c07283d38374a5 languageName: node linkType: hard "node-fetch-native@npm:^1.6.1": version: 1.6.2 resolution: "node-fetch-native@npm:1.6.2" - checksum: a6e7b9bf2f671895421441177ebd1d12d2a6f18bc1afc29b8d413f65716faebb6c03adab332eff6392e538da8f40e862c67402bfb8a12c6b54b6a84a1a267377 + checksum: 85a3c8fb853d2abbd7e4235742ee0ff5d8ac15f982209989f7150407203dc65ad45e0c11a0f7416c3685e3cdd3d3f9ee2922e7558f201dd6a7e9c9dde3b612fd languageName: node linkType: hard @@ -11087,13 +10736,13 @@ __metadata: version: 2.7.0 resolution: "node-fetch@npm:2.7.0" dependencies: - whatwg-url: ^5.0.0 + whatwg-url: "npm:^5.0.0" peerDependencies: encoding: ^0.1.0 peerDependenciesMeta: encoding: optional: true - checksum: d76d2f5edb451a3f05b15115ec89fc6be39de37c6089f1b6368df03b91e1633fd379a7e01b7ab05089a25034b2023d959b47e59759cb38d88341b2459e89d6e5 + checksum: b24f8a3dc937f388192e59bcf9d0857d7b6940a2496f328381641cb616efccc9866e89ec43f2ec956bbd6c3d3ee05524ce77fe7b29ccd34692b3a16f237d6676 languageName: node linkType: hard @@ -11101,33 +10750,33 @@ __metadata: version: 10.0.1 resolution: "node-gyp@npm:10.0.1" dependencies: - env-paths: ^2.2.0 - exponential-backoff: ^3.1.1 - glob: ^10.3.10 - graceful-fs: ^4.2.6 - make-fetch-happen: ^13.0.0 - nopt: ^7.0.0 - proc-log: ^3.0.0 - semver: ^7.3.5 - tar: ^6.1.2 - which: ^4.0.0 + env-paths: "npm:^2.2.0" + exponential-backoff: "npm:^3.1.1" + glob: "npm:^10.3.10" + graceful-fs: "npm:^4.2.6" + make-fetch-happen: "npm:^13.0.0" + nopt: "npm:^7.0.0" + proc-log: "npm:^3.0.0" + semver: "npm:^7.3.5" + tar: "npm:^6.1.2" + which: "npm:^4.0.0" bin: node-gyp: bin/node-gyp.js - checksum: 60a74e66d364903ce02049966303a57f898521d139860ac82744a5fdd9f7b7b3b61f75f284f3bfe6e6add3b8f1871ce305a1d41f775c7482de837b50c792223f + checksum: 578cf0c821f258ce4b6ebce4461eca4c991a4df2dee163c0624f2fe09c7d6d37240be4942285a0048d307230248ee0b18382d6623b9a0136ce9533486deddfa8 languageName: node linkType: hard "node-int64@npm:^0.4.0": version: 0.4.0 resolution: "node-int64@npm:0.4.0" - checksum: d0b30b1ee6d961851c60d5eaa745d30b5c95d94bc0e74b81e5292f7c42a49e3af87f1eb9e89f59456f80645d679202537de751b7d72e9e40ceea40c5e449057e + checksum: b7afc2b65e56f7035b1a2eec57ae0fbdee7d742b1cdcd0f4387562b6527a011ab1cbe9f64cc8b3cca61e3297c9637c8bf61cec2e6b8d3a711d4b5267dfafbe02 languageName: node linkType: hard "node-releases@npm:^2.0.14": version: 2.0.14 resolution: "node-releases@npm:2.0.14" - checksum: 59443a2f77acac854c42d321bf1b43dea0aef55cd544c6a686e9816a697300458d4e82239e2d794ea05f7bbbc8a94500332e2d3ac3f11f52e4b16cbe638b3c41 + checksum: 0f7607ec7db5ef1dc616899a5f24ae90c869b6a54c2d4f36ff6d84a282ab9343c7ff3ca3670fe4669171bb1e8a9b3e286e1ef1c131f09a83d70554f855d54f24 languageName: node linkType: hard @@ -11135,10 +10784,10 @@ __metadata: version: 7.2.0 resolution: "nopt@npm:7.2.0" dependencies: - abbrev: ^2.0.0 + abbrev: "npm:^2.0.0" bin: nopt: bin/nopt.js - checksum: a9c0f57fb8cb9cc82ae47192ca2b7ef00e199b9480eed202482c962d61b59a7fbe7541920b2a5839a97b42ee39e288c0aed770e38057a608d7f579389dfde410 + checksum: 1e7489f17cbda452c8acaf596a8defb4ae477d2a9953b76eb96f4ec3f62c6b421cd5174eaa742f88279871fde9586d8a1d38fb3f53fa0c405585453be31dff4c languageName: node linkType: hard @@ -11146,11 +10795,11 @@ __metadata: version: 2.5.0 resolution: "normalize-package-data@npm:2.5.0" dependencies: - hosted-git-info: ^2.1.4 - resolve: ^1.10.0 - semver: 2 || 3 || 4 || 5 - validate-npm-package-license: ^3.0.1 - checksum: 7999112efc35a6259bc22db460540cae06564aa65d0271e3bdfa86876d08b0e578b7b5b0028ee61b23f1cae9fc0e7847e4edc0948d3068a39a2a82853efc8499 + hosted-git-info: "npm:^2.1.4" + resolve: "npm:^1.10.0" + semver: "npm:2 || 3 || 4 || 5" + validate-npm-package-license: "npm:^3.0.1" + checksum: 644f830a8bb9b7cc9bf2f6150618727659ee27cdd0840d1c1f97e8e6cab0803a098a2c19f31c6247ad9d3a0792e61521a13a6e8cd87cc6bb676e3150612c03d4 languageName: node linkType: hard @@ -11165,7 +10814,7 @@ __metadata: version: 4.0.1 resolution: "npm-run-path@npm:4.0.1" dependencies: - path-key: ^3.0.0 + path-key: "npm:^3.0.0" checksum: 5374c0cea4b0bbfdfae62da7bbdf1e1558d338335f4cacf2515c282ff358ff27b2ecb91ffa5330a8b14390ac66a1e146e10700440c1ab868208430f56b5f4d23 languageName: node linkType: hard @@ -11174,7 +10823,7 @@ __metadata: version: 5.3.0 resolution: "npm-run-path@npm:5.3.0" dependencies: - path-key: ^4.0.0 + path-key: "npm:^4.0.0" checksum: ae8e7a89da9594fb9c308f6555c73f618152340dcaae423e5fb3620026fefbec463618a8b761920382d666fa7a2d8d240b6fe320e8a6cdd54dc3687e2b659d25 languageName: node linkType: hard @@ -11183,7 +10832,7 @@ __metadata: version: 2.1.1 resolution: "nth-check@npm:2.1.1" dependencies: - boolbase: ^1.0.0 + boolbase: "npm:^1.0.0" checksum: 5afc3dafcd1573b08877ca8e6148c52abd565f1d06b1eb08caf982e3fa289a82f2cae697ffb55b5021e146d60443f1590a5d6b944844e944714a5b549675bcd3 languageName: node linkType: hard @@ -11191,7 +10840,7 @@ __metadata: "nwsapi@npm:^2.2.2": version: 2.2.7 resolution: "nwsapi@npm:2.2.7" - checksum: cab25f7983acec7e23490fec3ef7be608041b460504229770e3bfcf9977c41d6fe58f518994d3bd9aa3a101f501089a3d4a63536f4ff8ae4b8c4ca23bdbfda4e + checksum: 22c002080f0297121ad138aba5a6509e724774d6701fe2c4777627bd939064ecd9e1b6dc1c2c716bb7ca0b9f16247892ff2f664285202ac7eff6ec9543725320 languageName: node linkType: hard @@ -11199,38 +10848,27 @@ __metadata: version: 0.3.6 resolution: "nypm@npm:0.3.6" dependencies: - citty: ^0.1.5 - execa: ^8.0.1 - pathe: ^1.1.2 - ufo: ^1.3.2 + citty: "npm:^0.1.5" + execa: "npm:^8.0.1" + pathe: "npm:^1.1.2" + ufo: "npm:^1.3.2" bin: nypm: dist/cli.mjs - checksum: 75b7fa3ae0a2ff0f615a3791ec274e59df247f03967c5bedfd1ad930235cc31d7038f344ef82c4cab1b61fb2499dfac18c1e6321d4f61dfcea66e06d469af732 + checksum: 1cdd1f9476bbd66ffe97de4afadfdf53dc4b273a1be8b49f7cb52470c5331c67bc0dee4952498b96af752831cbd4051fe5ca376e83401b440786ab1f6ef93837 languageName: node linkType: hard -"object-assign@npm:^4.1.0, object-assign@npm:^4.1.1": +"object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" checksum: fcc6e4ea8c7fe48abfbb552578b1c53e0d194086e2e6bbbf59e0a536381a292f39943c6e9628af05b5528aa5e3318bb30d6b2e53cadaf5b8fe9e12c4b69af23f languageName: node linkType: hard -"object-copy@npm:^0.1.0": - version: 0.1.0 - resolution: "object-copy@npm:0.1.0" - dependencies: - copy-descriptor: ^0.1.0 - define-property: ^0.2.5 - kind-of: ^3.0.3 - checksum: a9e35f07e3a2c882a7e979090360d1a20ab51d1fa19dfdac3aa8873b328a7c4c7683946ee97c824ae40079d848d6740a3788fa14f2185155dab7ed970a72c783 - languageName: node - linkType: hard - "object-inspect@npm:^1.13.1": version: 1.13.1 resolution: "object-inspect@npm:1.13.1" - checksum: 7d9fa9221de3311dcb5c7c307ee5dc011cdd31dc43624b7c184b3840514e118e05ef0002be5388304c416c0eb592feb46e983db12577fc47e47d5752fbbfb61f + checksum: 92f4989ed83422d56431bc39656d4c780348eb15d397ce352ade6b7fec08f973b53744bd41b94af021901e61acaf78fcc19e65bf464ecc0df958586a672700f0 languageName: node linkType: hard @@ -11238,25 +10876,16 @@ __metadata: version: 1.1.6 resolution: "object-is@npm:1.1.6" dependencies: - call-bind: ^1.0.7 - define-properties: ^1.2.1 - checksum: 3ea22759967e6f2380a2cbbd0f737b42dc9ddb2dfefdb159a1b927fea57335e1b058b564bfa94417db8ad58cddab33621a035de6f5e5ad56d89f2dd03e66c6a1 + call-bind: "npm:^1.0.7" + define-properties: "npm:^1.2.1" + checksum: 4f6f544773a595da21c69a7531e0e1d6250670f4e09c55f47eb02c516035cfcb1b46ceb744edfd3ecb362309dbccb6d7f88e43bf42e4d4595ac10a329061053a languageName: node linkType: hard "object-keys@npm:^1.1.1": version: 1.1.1 resolution: "object-keys@npm:1.1.1" - checksum: b363c5e7644b1e1b04aa507e88dcb8e3a2f52b6ffd0ea801e4c7a62d5aa559affe21c55a07fd4b1fd55fc03a33c610d73426664b20032405d7b92a1414c34d6a - languageName: node - linkType: hard - -"object-visit@npm:^1.0.0": - version: 1.0.1 - resolution: "object-visit@npm:1.0.1" - dependencies: - isobject: ^3.0.0 - checksum: b0ee07f5bf3bb881b881ff53b467ebbde2b37ebb38649d6944a6cd7681b32eedd99da9bd1e01c55facf81f54ed06b13af61aba6ad87f0052982995e09333f790 + checksum: 3d81d02674115973df0b7117628ea4110d56042e5326413e4b4313f0bcdf7dd78d4a3acef2c831463fa3796a66762c49daef306f4a0ea1af44877d7086d73bde languageName: node linkType: hard @@ -11264,11 +10893,11 @@ __metadata: version: 4.1.5 resolution: "object.assign@npm:4.1.5" dependencies: - call-bind: ^1.0.5 - define-properties: ^1.2.1 - has-symbols: ^1.0.3 - object-keys: ^1.1.1 - checksum: f9aeac0541661370a1fc86e6a8065eb1668d3e771f7dbb33ee54578201336c057b21ee61207a186dd42db0c62201d91aac703d20d12a79fc79c353eed44d4e25 + call-bind: "npm:^1.0.5" + define-properties: "npm:^1.2.1" + has-symbols: "npm:^1.0.3" + object-keys: "npm:^1.1.1" + checksum: dbb22da4cda82e1658349ea62b80815f587b47131b3dd7a4ab7f84190ab31d206bbd8fe7e26ae3220c55b65725ac4529825f6142154211220302aa6b1518045d languageName: node linkType: hard @@ -11276,10 +10905,10 @@ __metadata: version: 1.1.7 resolution: "object.entries@npm:1.1.7" dependencies: - call-bind: ^1.0.2 - define-properties: ^1.2.0 - es-abstract: ^1.22.1 - checksum: da287d434e7e32989586cd734382364ba826a2527f2bc82e6acbf9f9bfafa35d51018b66ec02543ffdfa2a5ba4af2b6f1ca6e588c65030cb4fd9c67d6ced594c + call-bind: "npm:^1.0.2" + define-properties: "npm:^1.2.0" + es-abstract: "npm:^1.22.1" + checksum: 03f0bd0f23a8626c94429d15abf26ccda7723f08cd26be2c09c72d436765f8c7468605b5476ca58d4a7cec1ec7eca5be496dbd938fd4236b77ed6d05a8680048 languageName: node linkType: hard @@ -11287,10 +10916,10 @@ __metadata: version: 2.0.7 resolution: "object.fromentries@npm:2.0.7" dependencies: - call-bind: ^1.0.2 - define-properties: ^1.2.0 - es-abstract: ^1.22.1 - checksum: 7341ce246e248b39a431b87a9ddd331ff52a454deb79afebc95609f94b1f8238966cf21f52188f2a353f0fdf83294f32f1ebf1f7826aae915ebad21fd0678065 + call-bind: "npm:^1.0.2" + define-properties: "npm:^1.2.0" + es-abstract: "npm:^1.22.1" + checksum: 1bfbe42a51f8d84e417d193fae78e4b8eebb134514cdd44406480f8e8a0e075071e0717635d8e3eccd50fec08c1d555fe505c38804cbac0808397187653edd59 languageName: node linkType: hard @@ -11298,12 +10927,12 @@ __metadata: version: 1.0.2 resolution: "object.groupby@npm:1.0.2" dependencies: - array.prototype.filter: ^1.0.3 - call-bind: ^1.0.5 - define-properties: ^1.2.1 - es-abstract: ^1.22.3 - es-errors: ^1.0.0 - checksum: 5f95c2a3a5f60a1a8c05fdd71455110bd3d5e6af0350a20b133d8cd70f9c3385d5c7fceb6a17b940c3c61752d9c202d10d5e2eb5ce73b89002656a87e7bf767a + array.prototype.filter: "npm:^1.0.3" + call-bind: "npm:^1.0.5" + define-properties: "npm:^1.2.1" + es-abstract: "npm:^1.22.3" + es-errors: "npm:^1.0.0" + checksum: 07c1bea1772c45f7967a63358a683ef7b0bd99cabe0563e6fee3e8acc061cc5984d2f01a46472ebf10b2cb439298c46776b2134550dce457fd7240baaaa4f592 languageName: node linkType: hard @@ -11311,18 +10940,9 @@ __metadata: version: 1.1.3 resolution: "object.hasown@npm:1.1.3" dependencies: - define-properties: ^1.2.0 - es-abstract: ^1.22.1 - checksum: 76bc17356f6124542fb47e5d0e78d531eafa4bba3fc2d6fc4b1a8ce8b6878912366c0d99f37ce5c84ada8fd79df7aa6ea1214fddf721f43e093ad2df51f27da1 - languageName: node - linkType: hard - -"object.pick@npm:^1.3.0": - version: 1.3.0 - resolution: "object.pick@npm:1.3.0" - dependencies: - isobject: ^3.0.1 - checksum: 77fb6eed57c67adf75e9901187e37af39f052ef601cb4480386436561357eb9e459e820762f01fd02c5c1b42ece839ad393717a6d1850d848ee11fbabb3e580a + define-properties: "npm:^1.2.0" + es-abstract: "npm:^1.22.1" + checksum: 735679729c25a4e0d3713adf5df9861d862f0453e87ada4d991b75cd4225365dec61a08435e1127f42c9cc1adfc8e952fa5dca75364ebda6539dadf4721dc9c4 languageName: node linkType: hard @@ -11330,17 +10950,17 @@ __metadata: version: 1.1.7 resolution: "object.values@npm:1.1.7" dependencies: - call-bind: ^1.0.2 - define-properties: ^1.2.0 - es-abstract: ^1.22.1 - checksum: f3e4ae4f21eb1cc7cebb6ce036d4c67b36e1c750428d7b7623c56a0db90edced63d08af8a316d81dfb7c41a3a5fa81b05b7cc9426e98d7da986b1682460f0777 + call-bind: "npm:^1.0.2" + define-properties: "npm:^1.2.0" + es-abstract: "npm:^1.22.1" + checksum: 20ab42c0bbf984405c80e060114b18cf5d629a40a132c7eac4fb79c5d06deb97496311c19297dcf9c61f45c2539cd4c7f7c5d6230e51db360ff297bbc9910162 languageName: node linkType: hard "ohash@npm:^1.1.3": version: 1.1.3 resolution: "ohash@npm:1.1.3" - checksum: 44c7321cb950ce6e87d46584fd5cc8dd3dd15fcd4ade0ac2995d0497dc6b6b1ae9bd844c59af185d63923da5cfe9b37ae37a9dbd9ac455f3ad0cdfb5a73d5ef6 + checksum: 80a3528285f61588600c8c4f091a67f55fbc141f4eec4b3c30182468053042eef5a9684780e963f98a71ec068f3de56d42920c6417bf8f79ab14aeb75ac0bb39 languageName: node linkType: hard @@ -11348,15 +10968,15 @@ __metadata: version: 2.4.1 resolution: "on-finished@npm:2.4.1" dependencies: - ee-first: 1.1.1 - checksum: d20929a25e7f0bb62f937a425b5edeb4e4cde0540d77ba146ec9357f00b0d497cdb3b9b05b9c8e46222407d1548d08166bff69cc56dfa55ba0e4469228920ff0 + ee-first: "npm:1.1.1" + checksum: 8e81472c5028125c8c39044ac4ab8ba51a7cdc19a9fbd4710f5d524a74c6d8c9ded4dd0eed83f28d3d33ac1d7a6a439ba948ccb765ac6ce87f30450a26bfe2ea languageName: node linkType: hard "on-headers@npm:~1.0.2": version: 1.0.2 resolution: "on-headers@npm:1.0.2" - checksum: 2bf13467215d1e540a62a75021e8b318a6cfc5d4fc53af8e8f84ad98dbcea02d506c6d24180cd62e1d769c44721ba542f3154effc1f7579a8288c9f7873ed8e5 + checksum: 870766c16345855e2012e9422ba1ab110c7e44ad5891a67790f84610bd70a72b67fdd71baf497295f1d1bf38dd4c92248f825d48729c53c0eae5262fb69fa171 languageName: node linkType: hard @@ -11364,17 +10984,26 @@ __metadata: version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: - wrappy: 1 + wrappy: "npm:1" checksum: cd0a88501333edd640d95f0d2700fbde6bff20b3d4d9bdc521bdd31af0656b5706570d6c6afe532045a20bb8dc0849f8332d6f2a416e0ba6d3d3b98806c7db68 languageName: node linkType: hard +"one-time@npm:^1.0.0": + version: 1.0.0 + resolution: "one-time@npm:1.0.0" + dependencies: + fn.name: "npm:1.x.x" + checksum: 64d0160480eeae4e3b2a6fc0a02f452e05bb0cc8373a4ed56a4fc08c3939dcb91bc20075003ed499655bd16919feb63ca56f86eee7932c5251f7d629b55dfc90 + languageName: node + linkType: hard + "onetime@npm:^5.1.0, onetime@npm:^5.1.2": version: 5.1.2 resolution: "onetime@npm:5.1.2" dependencies: - mimic-fn: ^2.1.0 - checksum: 2478859ef817fc5d4e9c2f9e5728512ddd1dbc9fb7829ad263765bb6d3b91ce699d6e2332eef6b7dff183c2f490bd3349f1666427eaba4469fba0ac38dfd0d34 + mimic-fn: "npm:^2.1.0" + checksum: e9fd0695a01cf226652f0385bf16b7a24153dbbb2039f764c8ba6d2306a8506b0e4ce570de6ad99c7a6eb49520743afdb66edd95ee979c1a342554ed49a9aadd languageName: node linkType: hard @@ -11382,7 +11011,7 @@ __metadata: version: 6.0.0 resolution: "onetime@npm:6.0.0" dependencies: - mimic-fn: ^4.0.0 + mimic-fn: "npm:^4.0.0" checksum: 0846ce78e440841335d4e9182ef69d5762e9f38aa7499b19f42ea1c4cd40f0b4446094c455c713f9adac3f4ae86f613bb5e30c99e52652764d06a89f709b3788 languageName: node linkType: hard @@ -11391,10 +11020,10 @@ __metadata: version: 8.4.2 resolution: "open@npm:8.4.2" dependencies: - define-lazy-prop: ^2.0.0 - is-docker: ^2.1.1 - is-wsl: ^2.2.0 - checksum: 6388bfff21b40cb9bd8f913f9130d107f2ed4724ea81a8fd29798ee322b361ca31fa2cdfb491a5c31e43a3996cfe9566741238c7a741ada8d7af1cb78d85cf26 + define-lazy-prop: "npm:^2.0.0" + is-docker: "npm:^2.1.1" + is-wsl: "npm:^2.2.0" + checksum: acd81a1d19879c818acb3af2d2e8e9d81d17b5367561e623248133deb7dd3aefaed527531df2677d3e6aaf0199f84df57b6b2262babff8bf46ea0029aac536c9 languageName: node linkType: hard @@ -11402,13 +11031,13 @@ __metadata: version: 0.9.3 resolution: "optionator@npm:0.9.3" dependencies: - "@aashutoshrathi/word-wrap": ^1.2.3 - deep-is: ^0.1.3 - fast-levenshtein: ^2.0.6 - levn: ^0.4.1 - prelude-ls: ^1.2.1 - type-check: ^0.4.0 - checksum: 09281999441f2fe9c33a5eeab76700795365a061563d66b098923eb719251a42bdbe432790d35064d0816ead9296dbeb1ad51a733edf4167c96bd5d0882e428a + "@aashutoshrathi/word-wrap": "npm:^1.2.3" + deep-is: "npm:^0.1.3" + fast-levenshtein: "npm:^2.0.6" + levn: "npm:^0.4.1" + prelude-ls: "npm:^1.2.1" + type-check: "npm:^0.4.0" + checksum: fa28d3016395974f7fc087d6bbf0ac7f58ac3489f4f202a377e9c194969f329a7b88c75f8152b33fb08794a30dcd5c079db6bb465c28151357f113d80bbf67da languageName: node linkType: hard @@ -11416,16 +11045,16 @@ __metadata: version: 5.4.1 resolution: "ora@npm:5.4.1" dependencies: - bl: ^4.1.0 - chalk: ^4.1.0 - cli-cursor: ^3.1.0 - cli-spinners: ^2.5.0 - is-interactive: ^1.0.0 - is-unicode-supported: ^0.1.0 - log-symbols: ^4.1.0 - strip-ansi: ^6.0.0 - wcwidth: ^1.0.1 - checksum: 28d476ee6c1049d68368c0dc922e7225e3b5600c3ede88fade8052837f9ed342625fdaa84a6209302587c8ddd9b664f71f0759833cbdb3a4cf81344057e63c63 + bl: "npm:^4.1.0" + chalk: "npm:^4.1.0" + cli-cursor: "npm:^3.1.0" + cli-spinners: "npm:^2.5.0" + is-interactive: "npm:^1.0.0" + is-unicode-supported: "npm:^0.1.0" + log-symbols: "npm:^4.1.0" + strip-ansi: "npm:^6.0.0" + wcwidth: "npm:^1.0.1" + checksum: 8d071828f40090a8e1c6e8f350c6eb065808e9ab2b3e57fa37e0d5ae78cb46dac00117c8f12c3c8b8da2923454afbd8265e08c10b69881170c5b269f451e7fef languageName: node linkType: hard @@ -11433,7 +11062,7 @@ __metadata: version: 2.3.0 resolution: "p-limit@npm:2.3.0" dependencies: - p-try: ^2.0.0 + p-try: "npm:^2.0.0" checksum: 84ff17f1a38126c3314e91ecfe56aecbf36430940e2873dadaa773ffe072dc23b7af8e46d4b6485d302a11673fe94c6b67ca2cfbb60c989848b02100d0594ac1 languageName: node linkType: hard @@ -11442,7 +11071,7 @@ __metadata: version: 3.1.0 resolution: "p-limit@npm:3.1.0" dependencies: - yocto-queue: ^0.1.0 + yocto-queue: "npm:^0.1.0" checksum: 7c3690c4dbf62ef625671e20b7bdf1cbc9534e83352a2780f165b0d3ceba21907e77ad63401708145ca4e25bfc51636588d89a8c0aeb715e6c37d1c066430360 languageName: node linkType: hard @@ -11451,7 +11080,7 @@ __metadata: version: 3.0.0 resolution: "p-locate@npm:3.0.0" dependencies: - p-limit: ^2.0.0 + p-limit: "npm:^2.0.0" checksum: 83991734a9854a05fe9dbb29f707ea8a0599391f52daac32b86f08e21415e857ffa60f0e120bfe7ce0cc4faf9274a50239c7895fc0d0579d08411e513b83a4ae languageName: node linkType: hard @@ -11460,7 +11089,7 @@ __metadata: version: 4.1.0 resolution: "p-locate@npm:4.1.0" dependencies: - p-limit: ^2.2.0 + p-limit: "npm:^2.2.0" checksum: 513bd14a455f5da4ebfcb819ef706c54adb09097703de6aeaa5d26fe5ea16df92b48d1ac45e01e3944ce1e6aa2a66f7f8894742b8c9d6e276e16cd2049a2b870 languageName: node linkType: hard @@ -11469,7 +11098,7 @@ __metadata: version: 5.0.0 resolution: "p-locate@npm:5.0.0" dependencies: - p-limit: ^3.0.2 + p-limit: "npm:^3.0.2" checksum: 1623088f36cf1cbca58e9b61c4e62bf0c60a07af5ae1ca99a720837356b5b6c5ba3eb1b2127e47a06865fee59dd0453cad7cc844cda9d5a62ac1a5a51b7c86d3 languageName: node linkType: hard @@ -11478,8 +11107,8 @@ __metadata: version: 4.0.0 resolution: "p-map@npm:4.0.0" dependencies: - aggregate-error: ^3.0.0 - checksum: cb0ab21ec0f32ddffd31dfc250e3afa61e103ef43d957cc45497afe37513634589316de4eb88abdfd969fe6410c22c0b93ab24328833b8eb1ccc087fc0442a1c + aggregate-error: "npm:^3.0.0" + checksum: 7ba4a2b1e24c05e1fc14bbaea0fc6d85cf005ae7e9c9425d4575550f37e2e584b1af97bcde78eacd7559208f20995988d52881334db16cf77bc1bcf68e48ed7c languageName: node linkType: hard @@ -11493,7 +11122,7 @@ __metadata: "pako@npm:~0.2.0": version: 0.2.9 resolution: "pako@npm:0.2.9" - checksum: 055f9487cd57fbb78df84315873bbdd089ba286f3499daed47d2effdc6253e981f5db6898c23486de76d4a781559f890d643bd3a49f70f1b4a18019c98aa5125 + checksum: 627c6842e90af0b3a9ee47345bd66485a589aff9514266f4fa9318557ad819c46fedf97510f2cef9b6224c57913777966a05cb46caf6a9b31177a5401a06fe15 languageName: node linkType: hard @@ -11501,7 +11130,7 @@ __metadata: version: 1.0.1 resolution: "parent-module@npm:1.0.1" dependencies: - callsites: ^3.0.0 + callsites: "npm:^3.0.0" checksum: 6ba8b255145cae9470cf5551eb74be2d22281587af787a2626683a6c20fbb464978784661478dd2a3f1dad74d1e802d403e1b03c1a31fab310259eec8ac560ff languageName: node linkType: hard @@ -11510,13 +11139,13 @@ __metadata: version: 2.0.0 resolution: "parse-entities@npm:2.0.0" dependencies: - character-entities: ^1.0.0 - character-entities-legacy: ^1.0.0 - character-reference-invalid: ^1.0.0 - is-alphanumerical: ^1.0.0 - is-decimal: ^1.0.0 - is-hexadecimal: ^1.0.0 - checksum: 7addfd3e7d747521afac33c8121a5f23043c6973809756920d37e806639b4898385d386fcf4b3c8e2ecf1bc28aac5ae97df0b112d5042034efbe80f44081ebce + character-entities: "npm:^1.0.0" + character-entities-legacy: "npm:^1.0.0" + character-reference-invalid: "npm:^1.0.0" + is-alphanumerical: "npm:^1.0.0" + is-decimal: "npm:^1.0.0" + is-hexadecimal: "npm:^1.0.0" + checksum: feb46b516722474797d72331421f3e62856750cfb4f70ba098b36447bf0b169e819cc4fdee53e022874d5f0c81b605d86e1912b9842a70e59a54de2fee81589d languageName: node linkType: hard @@ -11524,15 +11153,15 @@ __metadata: version: 4.0.1 resolution: "parse-entities@npm:4.0.1" dependencies: - "@types/unist": ^2.0.0 - character-entities: ^2.0.0 - character-entities-legacy: ^3.0.0 - character-reference-invalid: ^2.0.0 - decode-named-character-reference: ^1.0.0 - is-alphanumerical: ^2.0.0 - is-decimal: ^2.0.0 - is-hexadecimal: ^2.0.0 - checksum: 32a6ff5b9acb9d2c4d71537308521fd265e685b9215691df73feedd9edfe041bb6da9f89bd0c35c4a2bc7d58e3e76e399bb6078c2fd7d2a343ff1dd46edbf1bd + "@types/unist": "npm:^2.0.0" + character-entities: "npm:^2.0.0" + character-entities-legacy: "npm:^3.0.0" + character-reference-invalid: "npm:^2.0.0" + decode-named-character-reference: "npm:^1.0.0" + is-alphanumerical: "npm:^2.0.0" + is-decimal: "npm:^2.0.0" + is-hexadecimal: "npm:^2.0.0" + checksum: 71314312d2482422fcf0b6675e020643bab424b11f64c654b7843652cae03842a7802eda1fed194ec435debb5db47a33513eb6b1176888e9e998a0368f01f5c8 languageName: node linkType: hard @@ -11540,10 +11169,10 @@ __metadata: version: 5.2.0 resolution: "parse-json@npm:5.2.0" dependencies: - "@babel/code-frame": ^7.0.0 - error-ex: ^1.3.1 - json-parse-even-better-errors: ^2.3.0 - lines-and-columns: ^1.1.6 + "@babel/code-frame": "npm:^7.0.0" + error-ex: "npm:^1.3.1" + json-parse-even-better-errors: "npm:^2.3.0" + lines-and-columns: "npm:^1.1.6" checksum: 62085b17d64da57f40f6afc2ac1f4d95def18c4323577e1eced571db75d9ab59b297d1d10582920f84b15985cbfc6b6d450ccbf317644cfa176f3ed982ad87e2 languageName: node linkType: hard @@ -11552,8 +11181,8 @@ __metadata: version: 7.1.2 resolution: "parse5@npm:7.1.2" dependencies: - entities: ^4.4.0 - checksum: 59465dd05eb4c5ec87b76173d1c596e152a10e290b7abcda1aecf0f33be49646ea74840c69af975d7887543ea45564801736356c568d6b5e71792fd0f4055713 + entities: "npm:^4.4.0" + checksum: 3c86806bb0fb1e9a999ff3a4c883b1ca243d99f45a619a0898dbf021a95a0189ed955c31b07fe49d342b54e814f33f2c9d7489198e8630dacd5477d413ec5782 languageName: node linkType: hard @@ -11564,13 +11193,6 @@ __metadata: languageName: node linkType: hard -"pascalcase@npm:^0.1.1": - version: 0.1.1 - resolution: "pascalcase@npm:0.1.1" - checksum: f83681c3c8ff75fa473a2bb2b113289952f802ff895d435edd717e7cb898b0408cbdb247117a938edcbc5d141020909846cc2b92c47213d764e2a94d2ad2b925 - languageName: node - linkType: hard - "path-exists@npm:^3.0.0": version: 3.0.0 resolution: "path-exists@npm:3.0.0" @@ -11617,16 +11239,16 @@ __metadata: version: 1.10.1 resolution: "path-scurry@npm:1.10.1" dependencies: - lru-cache: ^9.1.1 || ^10.0.0 - minipass: ^5.0.0 || ^6.0.2 || ^7.0.0 - checksum: e2557cff3a8fb8bc07afdd6ab163a92587884f9969b05bbbaf6fe7379348bfb09af9ed292af12ed32398b15fb443e81692047b786d1eeb6d898a51eb17ed7d90 + lru-cache: "npm:^9.1.1 || ^10.0.0" + minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0" + checksum: eebfb8304fef1d4f7e1486df987e4fd77413de4fce16508dea69fcf8eb318c09a6b15a7a2f4c22877cec1cb7ecbd3071d18ca9de79eeece0df874a00f1f0bdc8 languageName: node linkType: hard "path-to-regexp@npm:0.1.7": version: 0.1.7 resolution: "path-to-regexp@npm:0.1.7" - checksum: 69a14ea24db543e8b0f4353305c5eac6907917031340e5a8b37df688e52accd09e3cebfe1660b70d76b6bd89152f52183f28c74813dbf454ba1a01c82a38abce + checksum: 701c99e1f08e3400bea4d701cf6f03517474bb1b608da71c78b1eb261415b645c5670dfae49808c89e12cea2dccd113b069f040a80de012da0400191c6dbd1c8 languageName: node linkType: hard @@ -11640,7 +11262,7 @@ __metadata: "pathe@npm:^1.1.1, pathe@npm:^1.1.2": version: 1.1.2 resolution: "pathe@npm:1.1.2" - checksum: ec5f778d9790e7b9ffc3e4c1df39a5bb1ce94657a4e3ad830c1276491ca9d79f189f47609884671db173400256b005f4955f7952f52a2aeb5834ad5fb4faf134 + checksum: f201d796351bf7433d147b92c20eb154a4e0ea83512017bf4ec4e492a5d6e738fb45798be4259a61aa81270179fce11026f6ff0d3fa04173041de044defe9d80 languageName: node linkType: hard @@ -11648,9 +11270,9 @@ __metadata: version: 1.1.3 resolution: "peek-stream@npm:1.1.3" dependencies: - buffer-from: ^1.0.0 - duplexify: ^3.5.0 - through2: ^2.0.3 + buffer-from: "npm:^1.0.0" + duplexify: "npm:^3.5.0" + through2: "npm:^2.0.3" checksum: a0e09d6d1a8a01158a3334f20d6b1cdd91747eba24eb06a1d742eefb620385593121a76d4378cc81f77cdce6a66df0575a41041b1189c510254aec91878afc99 languageName: node linkType: hard @@ -11672,21 +11294,21 @@ __metadata: "picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.2, picomatch@npm:^2.2.3, picomatch@npm:^2.3.0, picomatch@npm:^2.3.1": version: 2.3.1 resolution: "picomatch@npm:2.3.1" - checksum: 050c865ce81119c4822c45d3c84f1ced46f93a0126febae20737bd05ca20589c564d6e9226977df859ed5e03dc73f02584a2b0faad36e896936238238b0446cf + checksum: 60c2595003b05e4535394d1da94850f5372c9427ca4413b71210f437f7b2ca091dbd611c45e8b37d10036fa8eade25c1b8951654f9d3973bfa66a2ff4d3b08bc languageName: node linkType: hard "pify@npm:^4.0.1": version: 4.0.1 resolution: "pify@npm:4.0.1" - checksum: 9c4e34278cb09987685fa5ef81499c82546c033713518f6441778fbec623fc708777fe8ac633097c72d88470d5963094076c7305cafc7ad340aae27cfacd856b + checksum: 8b97cbf9dc6d4c1320cc238a2db0fc67547f9dc77011729ff353faf34f1936ea1a4d7f3c63b2f4980b253be77bcc72ea1e9e76ee3fd53cce2aafb6a8854d07ec languageName: node linkType: hard "pirates@npm:^4.0.4, pirates@npm:^4.0.6": version: 4.0.6 resolution: "pirates@npm:4.0.6" - checksum: 46a65fefaf19c6f57460388a5af9ab81e3d7fd0e7bc44ca59d753cb5c4d0df97c6c6e583674869762101836d68675f027d60f841c105d72734df9dfca97cbcc6 + checksum: d02dda76f4fec1cbdf395c36c11cf26f76a644f9f9a1bfa84d3167d0d3154d5289aacc72677aa20d599bb4a6937a471de1b65c995e2aea2d8687cbcd7e43ea5f languageName: node linkType: hard @@ -11694,7 +11316,7 @@ __metadata: version: 3.0.0 resolution: "pkg-dir@npm:3.0.0" dependencies: - find-up: ^3.0.0 + find-up: "npm:^3.0.0" checksum: 70c9476ffefc77552cc6b1880176b71ad70bfac4f367604b2b04efd19337309a4eec985e94823271c7c0e83946fa5aeb18cd360d15d10a5d7533e19344bfa808 languageName: node linkType: hard @@ -11703,7 +11325,7 @@ __metadata: version: 4.2.0 resolution: "pkg-dir@npm:4.2.0" dependencies: - find-up: ^4.0.0 + find-up: "npm:^4.0.0" checksum: 9863e3f35132bf99ae1636d31ff1e1e3501251d480336edb1c211133c8d58906bed80f154a1d723652df1fda91e01c7442c2eeaf9dc83157c7ae89087e43c8d6 languageName: node linkType: hard @@ -11712,7 +11334,7 @@ __metadata: version: 5.0.0 resolution: "pkg-dir@npm:5.0.0" dependencies: - find-up: ^5.0.0 + find-up: "npm:^5.0.0" checksum: b167bb8dac7bbf22b1d5e30ec223e6b064b84b63010c9d49384619a36734caf95ed23ad23d4f9bd975e8e8082b60a83395f43a89bb192df53a7c25a38ecb57d9 languageName: node linkType: hard @@ -11721,43 +11343,15 @@ __metadata: version: 4.3.1 resolution: "polished@npm:4.3.1" dependencies: - "@babel/runtime": ^7.17.8 - checksum: a6f863c23f1d2f3f5cda3427b5885c9fb9e83b036d681e24820b143c7df40d2685bebb01c0939767120a28e1183671ae17c93db82ac30b3c20942180bb153bc7 - languageName: node - linkType: hard - -"posix-character-classes@npm:^0.1.0": - version: 0.1.1 - resolution: "posix-character-classes@npm:0.1.1" - checksum: dedb99913c60625a16050cfed2fb5c017648fc075be41ac18474e1c6c3549ef4ada201c8bd9bd006d36827e289c571b6092e1ef6e756cdbab2fd7046b25c6442 + "@babel/runtime": "npm:^7.17.8" + checksum: 0902fe2eb16aecde1587a00efee7db8081b1331ac7bcfb6e61214d266388723a84858d732ad9395028e0aecd2bb8d0c39cc03d14b4c24c22329a0e40c38141eb languageName: node linkType: hard "possible-typed-array-names@npm:^1.0.0": version: 1.0.0 resolution: "possible-typed-array-names@npm:1.0.0" - checksum: b32d403ece71e042385cc7856385cecf1cd8e144fa74d2f1de40d1e16035dba097bc189715925e79b67bdd1472796ff168d3a90d296356c9c94d272d5b95f3ae - languageName: node - linkType: hard - -"postcss-prefix-selector@npm:^1.6.0": - version: 1.16.0 - resolution: "postcss-prefix-selector@npm:1.16.0" - peerDependencies: - postcss: ">4 <9" - checksum: 8bdf10628ec8b1679a4dbb9cd736a4742c5d9b8a878c35cebaad43d67e50a18ffeb34d15860374f18a89fe4c43f818f3386bdb3321f92bb35eec9ef640a46a76 - languageName: node - linkType: hard - -"postcss@npm:^5.2.17": - version: 5.2.18 - resolution: "postcss@npm:5.2.18" - dependencies: - chalk: ^1.1.3 - js-base64: ^2.1.9 - source-map: ^0.5.6 - supports-color: ^3.2.3 - checksum: 0cb88e7c887b9b55d0362159846ec9fbf330892c5853b0e346929e723d215295ffae48d9a0f219f64f74767f9114802dc1b5cd21c327184f958b7efaa93dd629 + checksum: 8ed3e96dfeea1c5880c1f4c9cb707e5fb26e8be22f14f82ef92df20fd2004e635c62ba47fbe8f2bb63bfd80dac1474be2fb39798da8c2feba2815435d1f749af languageName: node linkType: hard @@ -11765,65 +11359,17 @@ __metadata: version: 8.4.35 resolution: "postcss@npm:8.4.35" dependencies: - nanoid: ^3.3.7 - picocolors: ^1.0.0 - source-map-js: ^1.0.2 - checksum: cf3c3124d3912a507603f6d9a49b3783f741075e9aa73eb592a6dd9194f9edab9d20a8875d16d137d4f779fe7b6fbd1f5727e39bfd1c3003724980ee4995e1da - languageName: node - linkType: hard - -"posthtml-parser@npm:^0.2.0, posthtml-parser@npm:^0.2.1": - version: 0.2.1 - resolution: "posthtml-parser@npm:0.2.1" - dependencies: - htmlparser2: ^3.8.3 - isobject: ^2.1.0 - checksum: 1111cced3ea995de4f72bedace828b733e7eefa953573202e596cac7c82b3ced6cae2849c00f2ed1bb801ff544f4cf85a7b94f5f23392727dc4e0a0b26a8b15f - languageName: node - linkType: hard - -"posthtml-rename-id@npm:^1.0": - version: 1.0.12 - resolution: "posthtml-rename-id@npm:1.0.12" - dependencies: - escape-string-regexp: 1.0.5 - checksum: 5bfb88f9063e1057c6f5342d7100584cdcb55f4344ed3cfd68db8249fb25cc06f89b048fbf170cfb64c9a771994a2c3e79457f3bcc49988611a59769fc0a3a6b - languageName: node - linkType: hard - -"posthtml-render@npm:^1.0.5, posthtml-render@npm:^1.0.6": - version: 1.4.0 - resolution: "posthtml-render@npm:1.4.0" - checksum: 68c5c85834d57d54bb797ae81a4ab74ad1d87f55e6a327dac9804fbed96214b57d437d7e255e9396184ab976ab7e77aed6efda9315c156ab25ef8ab2c095c16b - languageName: node - linkType: hard - -"posthtml-svg-mode@npm:^1.0.3": - version: 1.0.3 - resolution: "posthtml-svg-mode@npm:1.0.3" - dependencies: - merge-options: 1.0.1 - posthtml: ^0.9.2 - posthtml-parser: ^0.2.1 - posthtml-render: ^1.0.6 - checksum: a9f88294dd7fe862a360a04d5e003fc250175bcb43f6fbd80f384f9daa6f39877a16026d00b39107a6201abe237fbfb591a0deea3bda19c606d493c96deff640 - languageName: node - linkType: hard - -"posthtml@npm:^0.9.2": - version: 0.9.2 - resolution: "posthtml@npm:0.9.2" - dependencies: - posthtml-parser: ^0.2.0 - posthtml-render: ^1.0.5 - checksum: 1464440239cc8ab745b6682142f509acc3a8837ef01e0398d7f482221030cd06c39f396feb301c4d337c920ce3281788782870c35a11349551c3a418cdc55487 + nanoid: "npm:^3.3.7" + picocolors: "npm:^1.0.0" + source-map-js: "npm:^1.0.2" + checksum: 93a7ce50cd6188f5f486a9ca98950ad27c19dfed996c45c414fa242944497e4d084a8760d3537f078630226f2bd3c6ab84b813b488740f4432e7c7039cd73a20 languageName: node linkType: hard "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" - checksum: cd192ec0d0a8e4c6da3bb80e4f62afe336df3f76271ac6deb0e6a36187133b6073a19e9727a1ff108cd8b9982e4768850d413baa71214dd80c7979617dca827a + checksum: 0b9d2c76801ca652a7f64892dd37b7e3fab149a37d2424920099bf894acccc62abb4424af2155ab36dea8744843060a2d8ddc983518d0b1e22265a22324b72ed languageName: node linkType: hard @@ -11831,7 +11377,7 @@ __metadata: version: 1.0.0 resolution: "prettier-linter-helpers@npm:1.0.0" dependencies: - fast-diff: ^1.1.2 + fast-diff: "npm:^1.1.2" checksum: 00ce8011cf6430158d27f9c92cfea0a7699405633f7f1d4a45f07e21bf78e99895911cbcdc3853db3a824201a7c745bd49bfea8abd5fb9883e765a90f74f8392 languageName: node linkType: hard @@ -11841,7 +11387,7 @@ __metadata: resolution: "prettier@npm:3.2.5" bin: prettier: bin/prettier.cjs - checksum: 2ee4e1417572372afb7a13bb446b34f20f1bf1747db77cf6ccaf57a9be005f2f15c40f903d41a6b79eec3f57fff14d32a20fb6dee1f126da48908926fe43c311 + checksum: d509f9da0b70e8cacc561a1911c0d99ec75117faed27b95cc8534cb2349667dee6351b0ca83fa9d5703f14127faa52b798de40f5705f02d843da133fc3aa416a languageName: node linkType: hard @@ -11850,7 +11396,7 @@ __metadata: resolution: "prettier@npm:2.8.8" bin: prettier: bin-prettier.js - checksum: b49e409431bf129dd89238d64299ba80717b57ff5a6d1c1a8b1a28b590d998a34e083fa13573bc732bb8d2305becb4c9a4407f8486c81fa7d55100eb08263cf8 + checksum: 00cdb6ab0281f98306cd1847425c24cbaaa48a5ff03633945ab4c701901b8e96ad558eb0777364ffc312f437af9b5a07d0f45346266e8245beaf6247b9c62b24 languageName: node linkType: hard @@ -11858,10 +11404,10 @@ __metadata: version: 27.5.1 resolution: "pretty-format@npm:27.5.1" dependencies: - ansi-regex: ^5.0.1 - ansi-styles: ^5.0.0 - react-is: ^17.0.1 - checksum: cf610cffcb793885d16f184a62162f2dd0df31642d9a18edf4ca298e909a8fe80bdbf556d5c9573992c102ce8bf948691da91bf9739bee0ffb6e79c8a8a6e088 + ansi-regex: "npm:^5.0.1" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^17.0.1" + checksum: 248990cbef9e96fb36a3e1ae6b903c551ca4ddd733f8d0912b9cc5141d3d0b3f9f8dfb4d799fb1c6723382c9c2083ffbfa4ad43ff9a0e7535d32d41fd5f01da6 languageName: node linkType: hard @@ -11869,31 +11415,38 @@ __metadata: version: 29.7.0 resolution: "pretty-format@npm:29.7.0" dependencies: - "@jest/schemas": ^29.6.3 - ansi-styles: ^5.0.0 - react-is: ^18.0.0 - checksum: 032c1602383e71e9c0c02a01bbd25d6759d60e9c7cf21937dde8357aa753da348fcec5def5d1002c9678a8524d5fe099ad98861286550ef44de8808cc61e43b6 + "@jest/schemas": "npm:^29.6.3" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^18.0.0" + checksum: dea96bc83c83cd91b2bfc55757b6b2747edcaac45b568e46de29deee80742f17bc76fe8898135a70d904f4928eafd8bb693cd1da4896e8bdd3c5e82cadf1d2bb languageName: node linkType: hard "pretty-hrtime@npm:^1.0.3": version: 1.0.3 resolution: "pretty-hrtime@npm:1.0.3" - checksum: bae0e6832fe13c3de43d1a3d43df52bf6090499d74dc65a17f5552cb1a94f1f8019a23284ddf988c3c408a09678d743901e1d8f5b7a71bec31eeeac445bef371 + checksum: 0a462e88a0a3fd3320288fd8307f488974326ae8e13eea8c27f590f8ee767ccb59cf35bcae1cadff241cd8b72f3e373fc76ff1be95243649899bf8c816874af9 + languageName: node + linkType: hard + +"prettysize@npm:^2.0.0": + version: 2.0.0 + resolution: "prettysize@npm:2.0.0" + checksum: 2f9b3129c307d276d924565cf0766c3b660ea1156c63261331b1a88d5e6a4e0757254706826c36a0e9d14ffcce53feb667fe2aa558ce142aa07ce18b315e7a3a languageName: node linkType: hard "prismjs@npm:^1.27.0": version: 1.29.0 resolution: "prismjs@npm:1.29.0" - checksum: 007a8869d4456ff8049dc59404e32d5666a07d99c3b0e30a18bd3b7676dfa07d1daae9d0f407f20983865fd8da56de91d09cb08e6aa61f5bc420a27c0beeaf93 + checksum: 2080db382c2dde0cfc7693769e89b501ef1bfc8ff4f8d25c07fd4c37ca31bc443f6133d5b7c145a73309dc396e829ddb7cc18560026d862a887ae08864ef6b07 languageName: node linkType: hard "prismjs@npm:~1.27.0": version: 1.27.0 resolution: "prismjs@npm:1.27.0" - checksum: 85c7f4a3e999073502cc9e1882af01e3709706369ec254b60bff1149eda701f40d02512acab956012dc7e61cfd61743a3a34c1bd0737e8dbacd79141e5698bbc + checksum: dc83e2e09170b53526182f5435fae056fc200b109cac39faa88eb48d992311c7f59b94990318962fa93299190a9b33a404920ed150e5b364ce48c897f2ba1e8e languageName: node linkType: hard @@ -11904,7 +11457,7 @@ __metadata: languageName: node linkType: hard -"process-nextick-args@npm:~2.0.0": +"process-nextick-args@npm:^2.0.0, process-nextick-args@npm:~2.0.0": version: 2.0.1 resolution: "process-nextick-args@npm:2.0.1" checksum: 1d38588e520dab7cea67cbbe2efdd86a10cc7a074c09657635e34f035277b59fbb57d09d8638346bf7090f8e8ebc070c96fa5fd183b777fff4f5edff5e9466cf @@ -11914,14 +11467,14 @@ __metadata: "process@npm:^0.11.10": version: 0.11.10 resolution: "process@npm:0.11.10" - checksum: bfcce49814f7d172a6e6a14d5fa3ac92cc3d0c3b9feb1279774708a719e19acd673995226351a082a9ae99978254e320ccda4240ddc474ba31a76c79491ca7c3 + checksum: dbaa7e8d1d5cf375c36963ff43116772a989ef2bb47c9bdee20f38fd8fc061119cf38140631cf90c781aca4d3f0f0d2c834711952b728953f04fd7d238f59f5b languageName: node linkType: hard "progress@npm:^2.0.1": version: 2.0.3 resolution: "progress@npm:2.0.3" - checksum: f67403fe7b34912148d9252cb7481266a354bd99ce82c835f79070643bb3c6583d10dbcfda4d41e04bbc1d8437e9af0fb1e1f2135727878f5308682a579429b7 + checksum: e6f0bcb71f716eee9dfac0fe8a2606e3704d6a64dd93baaf49fbadbc8499989a610fe14cf1bc6f61b6d6653c49408d94f4a94e124538084efd8e4cf525e0293d languageName: node linkType: hard @@ -11929,9 +11482,9 @@ __metadata: version: 2.0.1 resolution: "promise-retry@npm:2.0.1" dependencies: - err-code: ^2.0.2 - retry: ^0.12.0 - checksum: f96a3f6d90b92b568a26f71e966cbbc0f63ab85ea6ff6c81284dc869b41510e6cdef99b6b65f9030f0db422bf7c96652a3fff9f2e8fb4a0f069d8f4430359429 + err-code: "npm:^2.0.2" + retry: "npm:^0.12.0" + checksum: 96e1a82453c6c96eef53a37a1d6134c9f2482f94068f98a59145d0986ca4e497bf110a410adf73857e588165eab3899f0ebcf7b3890c1b3ce802abc0d65967d4 languageName: node linkType: hard @@ -11939,9 +11492,9 @@ __metadata: version: 2.4.2 resolution: "prompts@npm:2.4.2" dependencies: - kleur: ^3.0.3 - sisteransi: ^1.0.5 - checksum: d8fd1fe63820be2412c13bfc5d0a01909acc1f0367e32396962e737cb2fc52d004f3302475d5ce7d18a1e8a79985f93ff04ee03007d091029c3f9104bffc007d + kleur: "npm:^3.0.3" + sisteransi: "npm:^1.0.5" + checksum: c52536521a4d21eff4f2f2aa4572446cad227464066365a7167e52ccf8d9839c099f9afec1aba0eed3d5a2514b3e79e0b3e7a1dc326b9acde6b75d27ed74b1a9 languageName: node linkType: hard @@ -11949,10 +11502,10 @@ __metadata: version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: - loose-envify: ^1.4.0 - object-assign: ^4.1.1 - react-is: ^16.13.1 - checksum: c056d3f1c057cb7ff8344c645450e14f088a915d078dcda795041765047fa080d38e5d626560ccaac94a4e16e3aa15f3557c1a9a8d1174530955e992c675e459 + loose-envify: "npm:^1.4.0" + object-assign: "npm:^4.1.1" + react-is: "npm:^16.13.1" + checksum: 7d959caec002bc964c86cdc461ec93108b27337dabe6192fb97d69e16a0c799a03462713868b40749bfc1caf5f57ef80ac3e4ffad3effa636ee667582a75e2c0 languageName: node linkType: hard @@ -11960,15 +11513,15 @@ __metadata: version: 5.6.0 resolution: "property-information@npm:5.6.0" dependencies: - xtend: ^4.0.0 - checksum: fcf87c6542e59a8bbe31ca0b3255a4a63ac1059b01b04469680288998bcfa97f341ca989566adbb63975f4d85339030b82320c324a511532d390910d1c583893 + xtend: "npm:^4.0.0" + checksum: e4f45b100fec5968126b08102f9567f1b5fc3442aecbb5b4cdeca401f1f447672e7638a08c81c05dd3979c62d084e0cc6acbe2d8b053c05280ac5abaaf666a68 languageName: node linkType: hard "property-information@npm:^6.0.0": version: 6.4.1 resolution: "property-information@npm:6.4.1" - checksum: d9eece5f14b6fea9e6a1fa65fba88554956a58825eb9a5c8327bffee06bcc265117eaeae901871e8e8a5caec8d5e05ce39ab6872d5cef3b49a6f07815b6ef285 + checksum: 6aa680371ed55b73b0859b2ab9626444a2c201bb52a77a420ce3660293ed6c17256b2be0f1d8672856553fc68c92a47060e1816153790f1b22883f7b3d8db88f languageName: node linkType: hard @@ -11976,23 +11529,23 @@ __metadata: version: 2.0.7 resolution: "proxy-addr@npm:2.0.7" dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - checksum: 29c6990ce9364648255454842f06f8c46fcd124d3e6d7c5066df44662de63cdc0bad032e9bf5a3d653ff72141cc7b6019873d685708ac8210c30458ad99f2b74 + forwarded: "npm:0.2.0" + ipaddr.js: "npm:1.9.1" + checksum: f24a0c80af0e75d31e3451398670d73406ec642914da11a2965b80b1898ca6f66a0e3e091a11a4327079b2b268795f6fa06691923fef91887215c3d0e8ea3f68 languageName: node linkType: hard "proxy-from-env@npm:^1.0.0, proxy-from-env@npm:^1.1.0": version: 1.1.0 resolution: "proxy-from-env@npm:1.1.0" - checksum: ed7fcc2ba0a33404958e34d95d18638249a68c430e30fcb6c478497d72739ba64ce9810a24f53a7d921d0c065e5b78e3822759800698167256b04659366ca4d4 + checksum: f0bb4a87cfd18f77bc2fba23ae49c3b378fb35143af16cc478171c623eebe181678f09439707ad80081d340d1593cd54a33a0113f3ccb3f4bc9451488780ee23 languageName: node linkType: hard "psl@npm:^1.1.33": version: 1.9.0 resolution: "psl@npm:1.9.0" - checksum: 20c4277f640c93d393130673f392618e9a8044c6c7bf61c53917a0fddb4952790f5f362c6c730a9c32b124813e173733f9895add8d26f566ed0ea0654b2e711d + checksum: d07879d4bfd0ac74796306a8e5a36a93cfb9c4f4e8ee8e63fbb909066c192fe1008cd8f12abd8ba2f62ca28247949a20c8fb32e1d18831d9e71285a1569720f9 languageName: node linkType: hard @@ -12000,8 +11553,8 @@ __metadata: version: 2.0.1 resolution: "pump@npm:2.0.1" dependencies: - end-of-stream: ^1.1.0 - once: ^1.3.1 + end-of-stream: "npm:^1.1.0" + once: "npm:^1.3.1" checksum: e9f26a17be00810bff37ad0171edb35f58b242487b0444f92fb7d78bc7d61442fa9b9c5bd93a43fd8fd8ddd3cc75f1221f5e04c790f42907e5baab7cf5e2b931 languageName: node linkType: hard @@ -12010,8 +11563,8 @@ __metadata: version: 3.0.0 resolution: "pump@npm:3.0.0" dependencies: - end-of-stream: ^1.1.0 - once: ^1.3.1 + end-of-stream: "npm:^1.1.0" + once: "npm:^1.3.1" checksum: e42e9229fba14732593a718b04cb5e1cfef8254544870997e0ecd9732b189a48e1256e4e5478148ecb47c8511dca2b09eae56b4d0aad8009e6fac8072923cfc9 languageName: node linkType: hard @@ -12020,17 +11573,17 @@ __metadata: version: 1.5.1 resolution: "pumpify@npm:1.5.1" dependencies: - duplexify: ^3.6.0 - inherits: ^2.0.3 - pump: ^2.0.0 - checksum: 26ca412ec8d665bd0d5e185c1b8f627728eff603440d75d22a58e421e3c66eaf86ec6fc6a6efc54808ecef65979279fa8e99b109a23ec1fa8d79f37e6978c9bd + duplexify: "npm:^3.6.0" + inherits: "npm:^2.0.3" + pump: "npm:^2.0.0" + checksum: 5d11a99f320dc2a052610399bac6d03db0a23bc23b23aa2a7d0adf879da3065a55134b975db66dc46bc79f54af3dd575d8119113a0a5b311a00580e1f053896b languageName: node linkType: hard "punycode@npm:^2.1.0, punycode@npm:^2.1.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" - checksum: bb0a0ceedca4c3c57a9b981b90601579058903c62be23c5e8e843d2c2d4148a3ecf029d5133486fb0e1822b098ba8bba09e89d6b21742d02fa26bda6441a6fb2 + checksum: febdc4362bead22f9e2608ff0171713230b57aff9dddc1c273aa2a651fbd366f94b7d6a71d78342a7c0819906750351ca7f2edd26ea41b626d87d6a13d1bd059 languageName: node linkType: hard @@ -12038,24 +11591,24 @@ __metadata: version: 2.1.1 resolution: "puppeteer-core@npm:2.1.1" dependencies: - "@types/mime-types": ^2.1.0 - debug: ^4.1.0 - extract-zip: ^1.6.6 - https-proxy-agent: ^4.0.0 - mime: ^2.0.3 - mime-types: ^2.1.25 - progress: ^2.0.1 - proxy-from-env: ^1.0.0 - rimraf: ^2.6.1 - ws: ^6.1.0 - checksum: 2ddb597ef1b2d162b4aa49833b977734129edf7c8fa558fc38c59d273e79aa1bd079481c642de87f7163665f7f37aa52683da2716bafb7d3cab68c262c36ec28 + "@types/mime-types": "npm:^2.1.0" + debug: "npm:^4.1.0" + extract-zip: "npm:^1.6.6" + https-proxy-agent: "npm:^4.0.0" + mime: "npm:^2.0.3" + mime-types: "npm:^2.1.25" + progress: "npm:^2.0.1" + proxy-from-env: "npm:^1.0.0" + rimraf: "npm:^2.6.1" + ws: "npm:^6.1.0" + checksum: fcbf80c954f9562f88b53886dc377595bf478abbb47c005f9131a56b6704cdd0a26b60f2646d2340866ed9f5059aae2b9f06a0f04310f5f14520ec94a687fbe6 languageName: node linkType: hard "pure-rand@npm:^6.0.0": version: 6.0.4 resolution: "pure-rand@npm:6.0.4" - checksum: e1c4e69f8bf7303e5252756d67c3c7551385cd34d94a1f511fe099727ccbab74c898c03a06d4c4a24a89b51858781057b83ebbfe740d984240cdc04fead36068 + checksum: 34fed0abe99d3db7ddc459c12e1eda6bff05db6a17f2017a1ae12202271ccf276fb223b442653518c719671c1b339bbf97f27ba9276dba0997c89e45c4e6a3bf languageName: node linkType: hard @@ -12063,8 +11616,8 @@ __metadata: version: 6.11.0 resolution: "qs@npm:6.11.0" dependencies: - side-channel: ^1.0.4 - checksum: 6e1f29dd5385f7488ec74ac7b6c92f4d09a90408882d0c208414a34dd33badc1a621019d4c799a3df15ab9b1d0292f97c1dd71dc7c045e69f81a8064e5af7297 + side-channel: "npm:^1.0.4" + checksum: 5a3bfea3e2f359ede1bfa5d2f0dbe54001aa55e40e27dc3e60fab814362d83a9b30758db057c2011b6f53a2d4e4e5150194b5bac45372652aecb3e3c0d4b256e languageName: node linkType: hard @@ -12072,46 +11625,36 @@ __metadata: version: 6.11.2 resolution: "qs@npm:6.11.2" dependencies: - side-channel: ^1.0.4 - checksum: e812f3c590b2262548647d62f1637b6989cc56656dc960b893fe2098d96e1bd633f36576f4cd7564dfbff9db42e17775884db96d846bebe4f37420d073ecdc0b - languageName: node - linkType: hard - -"query-string@npm:^4.3.2": - version: 4.3.4 - resolution: "query-string@npm:4.3.4" - dependencies: - object-assign: ^4.1.0 - strict-uri-encode: ^1.0.0 - checksum: 3b2bae6a8454cf0edf11cf1aa4d1f920398bbdabc1c39222b9bb92147e746fcd97faf00e56f494728fb66b2961b495ba0fde699d5d3bd06b11472d664b36c6cf + side-channel: "npm:^1.0.4" + checksum: f2321d0796664d0f94e92447ccd3bdfd6b6f3a50b6b762aa79d7f5b1ea3a7a9f94063ba896b82bc2a877ed6a7426d4081e4f16568fdb04f0ee188cca9d8505b4 languageName: node linkType: hard "querystringify@npm:^2.1.1": version: 2.2.0 resolution: "querystringify@npm:2.2.0" - checksum: 5641ea231bad7ef6d64d9998faca95611ed4b11c2591a8cae741e178a974f6a8e0ebde008475259abe1621cb15e692404e6b6626e927f7b849d5c09392604b15 + checksum: 46ab16f252fd892fc29d6af60966d338cdfeea68a231e9457631ffd22d67cec1e00141e0a5236a2eb16c0d7d74175d9ec1d6f963660c6f2b1c2fc85b194c5680 languageName: node linkType: hard "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3" - checksum: b676f8c040cdc5b12723ad2f91414d267605b26419d5c821ff03befa817ddd10e238d22b25d604920340fd73efd8ba795465a0377c4adf45a4a41e4234e42dc4 + checksum: 72900df0616e473e824202113c3df6abae59150dfb73ed13273503127235320e9c8ca4aaaaccfd58cf417c6ca92a6e68ee9a5c3182886ae949a768639b388a7b languageName: node linkType: hard "ramda@npm:0.29.0": version: 0.29.0 resolution: "ramda@npm:0.29.0" - checksum: 9ab26c06eb7545cbb7eebcf75526d6ee2fcaae19e338f165b2bf32772121e7b28192d6664d1ba222ff76188ba26ab307342d66e805dbb02c860560adc4d5dd57 + checksum: b156660f2c58b4a13bcc4f1a0eabc1145d8db11d33d26a2fb03cd6adf3983a1c1f2bbaaf708c421029e9b09684262d056752623f7e62b79a503fb9217dec69d4 languageName: node linkType: hard "range-parser@npm:~1.2.1": version: 1.2.1 resolution: "range-parser@npm:1.2.1" - checksum: 0a268d4fea508661cf5743dfe3d5f47ce214fd6b7dec1de0da4d669dd4ef3d2144468ebe4179049eff253d9d27e719c88dae55be64f954e80135a0cada804ec9 + checksum: ce21ef2a2dd40506893157970dc76e835c78cf56437e26e19189c48d5291e7279314477b06ac38abd6a401b661a6840f7b03bd0b1249da9b691deeaa15872c26 languageName: node linkType: hard @@ -12119,11 +11662,11 @@ __metadata: version: 2.5.1 resolution: "raw-body@npm:2.5.1" dependencies: - bytes: 3.1.2 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - checksum: 5362adff1575d691bb3f75998803a0ffed8c64eabeaa06e54b4ada25a0cd1b2ae7f4f5ec46565d1bec337e08b5ac90c76eaa0758de6f72a633f025d754dec29e + bytes: "npm:3.1.2" + http-errors: "npm:2.0.0" + iconv-lite: "npm:0.4.24" + unpipe: "npm:1.0.0" + checksum: 280bedc12db3490ecd06f740bdcf66093a07535374b51331242382c0e130bb273ebb611b7bc4cba1b4b4e016cc7b1f4b05a6df885a6af39c2bc3b94c02291c84 languageName: node linkType: hard @@ -12131,11 +11674,11 @@ __metadata: version: 4.0.2 resolution: "react-collapsed@npm:4.0.2" dependencies: - tiny-warning: ^1.0.3 + tiny-warning: "npm:^1.0.3" peerDependencies: react: ^16.9.0 || ^17 || ^18 react-dom: ^16.9.0 || ^17 || ^18 - checksum: 77648dd51d746842326a5268cfb48290df225e11b13de587313603fb8452703ba34ea4b32afa8bf386067e179212ce670adca09c5d29943ea74d8c0e43b20430 + checksum: f3cdf4bc00739196bad3ba7aa4837688a446cba3cd11c9ac115c86f19f42e4db68a84c0fe2fc740973669afd88aa7f6da39fea7093ddf34a0a24ccdaaf29fe73 languageName: node linkType: hard @@ -12145,7 +11688,7 @@ __metadata: peerDependencies: react: ">=16.8.0" react-dom: ">=16.8.0" - checksum: e432b7cb0df57e8f0bcdc3b012d2e93fcbcb6092c9e0f85654788d5ebfc4442536d8cc35b2418061ba3c4afb8b7788cc101c606d86a1732407921de7a9244c8d + checksum: 3e02ba013454818d0c323949bd961fb2c19ac18130dfc67a4032aa5b03787c5ffe7ff159c4b97dc3475072d576828ca0c4b8e8ce85b55eaf484180596cdf0403 languageName: node linkType: hard @@ -12153,11 +11696,11 @@ __metadata: version: 5.1.0 resolution: "react-copy-to-clipboard@npm:5.1.0" dependencies: - copy-to-clipboard: ^3.3.1 - prop-types: ^15.8.1 + copy-to-clipboard: "npm:^3.3.1" + prop-types: "npm:^15.8.1" peerDependencies: react: ^15.3.0 || 16 || 17 || 18 - checksum: f00a4551b9b63c944a041a6ab46af5ef20ba1106b3bc25173e7ef9bffbfba17a613368682ab8820cfe8d4b3acc5335cd9ce20229145bcc1e6aa8d1db04c512e5 + checksum: 56a8b11a268a19d4e4ec409327f1c17d68c4f13a54330b9c0e3271acb44bb6886b72e04d77399c9945968851e8532dd192bbccffd1b2f8b010f4bb47e5743b3b languageName: node linkType: hard @@ -12166,7 +11709,7 @@ __metadata: resolution: "react-docgen-typescript@npm:2.2.2" peerDependencies: typescript: ">= 4.3.x" - checksum: a9826459ea44e818f21402728dd47f5cae60bd936574cefd4f90ad101ff3eebacd67b6e017b793309734ce62c037aa3072dbc855d2b0e29bad1a38cbf5bac115 + checksum: 081fc3a876f53b9eeffcff357e5b6c190db799d50edcf11b187857d8cb8cce28000ed777ed16dd52a1c955f332612ef6b1f02cf8adcbcb084b8da9ff1ae5fd13 languageName: node linkType: hard @@ -12174,17 +11717,17 @@ __metadata: version: 7.0.3 resolution: "react-docgen@npm:7.0.3" dependencies: - "@babel/core": ^7.18.9 - "@babel/traverse": ^7.18.9 - "@babel/types": ^7.18.9 - "@types/babel__core": ^7.18.0 - "@types/babel__traverse": ^7.18.0 - "@types/doctrine": ^0.0.9 - "@types/resolve": ^1.20.2 - doctrine: ^3.0.0 - resolve: ^1.22.1 - strip-indent: ^4.0.0 - checksum: f5dbabd16a25b3c424c4962df4b4073d03ca124c3a5c99871f8436e30468854de115f959d0d5f03df77ad8dbe54f21e679fb48ba47bc125d61ae527bc5bcf0bf + "@babel/core": "npm:^7.18.9" + "@babel/traverse": "npm:^7.18.9" + "@babel/types": "npm:^7.18.9" + "@types/babel__core": "npm:^7.18.0" + "@types/babel__traverse": "npm:^7.18.0" + "@types/doctrine": "npm:^0.0.9" + "@types/resolve": "npm:^1.20.2" + doctrine: "npm:^3.0.0" + resolve: "npm:^1.22.1" + strip-indent: "npm:^4.0.0" + checksum: 53eaed76cceb55606584c6ab603f04ec78c066cfb9ed983e1f7b388a75bfb8c2fc9c6b7ab299bac311b3daeca95adb8076b58ca96b41907b33c518299268831f languageName: node linkType: hard @@ -12192,11 +11735,11 @@ __metadata: version: 18.2.0 resolution: "react-dom@npm:18.2.0" dependencies: - loose-envify: ^1.1.0 - scheduler: ^0.23.0 + loose-envify: "npm:^1.1.0" + scheduler: "npm:^0.23.0" peerDependencies: react: ^18.2.0 - checksum: 7d323310bea3a91be2965f9468d552f201b1c27891e45ddc2d6b8f717680c95a75ae0bc1e3f5cf41472446a2589a75aed4483aee8169287909fcd59ad149e8cc + checksum: ca5e7762ec8c17a472a3605b6f111895c9f87ac7d43a610ab7024f68cd833d08eda0625ce02ec7178cc1f3c957cf0b9273cdc17aa2cd02da87544331c43b1d21 languageName: node linkType: hard @@ -12204,41 +11747,41 @@ __metadata: version: 15.0.0 resolution: "react-element-to-jsx-string@npm:15.0.0" dependencies: - "@base2/pretty-print-object": 1.0.1 - is-plain-object: 5.0.0 - react-is: 18.1.0 + "@base2/pretty-print-object": "npm:1.0.1" + is-plain-object: "npm:5.0.0" + react-is: "npm:18.1.0" peerDependencies: react: ^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0 react-dom: ^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0 - checksum: c3907cc4c1d3e9ecc8ca7727058ebcba6ec89848d9e07bfd2c77ee8f28f1ad99bf55e38359dec8a1125de83d41ac09a2874f53c41415edc86ffa9840fa1b7856 + checksum: 9a874b2f16b4624a72c4b766b096d693a382b9dc7f2264f802395852ae3435ccde8e9e47bbe45cf5f30eba70f8126af6aca832190e285b0096af3ecade994df1 languageName: node linkType: hard "react-is@npm:18.1.0": version: 18.1.0 resolution: "react-is@npm:18.1.0" - checksum: d206a0fe6790851bff168727bfb896de02c5591695afb0c441163e8630136a3e13ee1a7ddd59fdccddcc93968b4721ae112c10f790b194b03b35a3dc13a355ef + checksum: fe09c86d5e12a8531bf3e748660f3dffbe900a6da0b488c7efaf0a866e16b74ecc1b0011b0960b13594f8719f39f87a987c0c85edff0b2d3e2f14b87e7230ad2 languageName: node linkType: hard "react-is@npm:^16.13.1, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" - checksum: f7a19ac3496de32ca9ae12aa030f00f14a3d45374f1ceca0af707c831b2a6098ef0d6bdae51bd437b0a306d7f01d4677fcc8de7c0d331eb47ad0f46130e53c5f + checksum: 5aa564a1cde7d391ac980bedee21202fc90bdea3b399952117f54fb71a932af1e5902020144fb354b4690b2414a0c7aafe798eb617b76a3d441d956db7726fdf languageName: node linkType: hard "react-is@npm:^17.0.1": version: 17.0.2 resolution: "react-is@npm:17.0.2" - checksum: 9d6d111d8990dc98bc5402c1266a808b0459b5d54830bbea24c12d908b536df7883f268a7868cfaedde3dd9d4e0d574db456f84d2e6df9c4526f99bb4b5344d8 + checksum: 73b36281e58eeb27c9cc6031301b6ae19ecdc9f18ae2d518bdb39b0ac564e65c5779405d623f1df9abf378a13858b79442480244bd579968afc1faf9a2ce5e05 languageName: node linkType: hard "react-is@npm:^18.0.0": version: 18.2.0 resolution: "react-is@npm:18.2.0" - checksum: e72d0ba81b5922759e4aff17e0252bd29988f9642ed817f56b25a3e217e13eea8a7f2322af99a06edb779da12d5d636e9fda473d620df9a3da0df2a74141d53e + checksum: 200cd65bf2e0be7ba6055f647091b725a45dd2a6abef03bf2380ce701fd5edccee40b49b9d15edab7ac08a762bf83cb4081e31ec2673a5bfb549a36ba21570df languageName: node linkType: hard @@ -12246,11 +11789,11 @@ __metadata: version: 2.0.2 resolution: "react-json-view-compare@npm:2.0.2" dependencies: - react-json-viewer-cool: ^2.0.0 + react-json-viewer-cool: "npm:^2.0.0" peerDependencies: react: ^16.14.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 - checksum: 8f411d6dbdac8b902e4e3f33063b9956b4dbdbab344085c1758864e62c45603f3f971de2ca20016100b55b040e284b3653e186961bd12d10bdbfdf1f2a409dee + checksum: 44066948d662e3aab9c9bf581e0f9310e7b2379e63e6b0d2e96ff8e663af6da6ca5152def56892dc15c9c6b9aa596ef93e755c21ee8f7e380b9f9c33092802da languageName: node linkType: hard @@ -12260,7 +11803,7 @@ __metadata: peerDependencies: react: ^16.14.0 || ^17.0.0 react-dom: ^16.14.0 || ^17.0.0 - checksum: a62d3e3afaa9fb103a0f5927c909ccfa7b95cadcc9ca648daefec261c6876d1e1af1831dbcb5128a3f27f1a99143acc2899a994cefb5271ef0a0d91c1f303016 + checksum: da2a7786a2014e99669b99de05cd9e7deb46f8bce04c3c193bf7a2c927a656d03735e50c7310a4f59d853abfc9fa1b76f8965c9afe82f4943fe672a619c766de languageName: node linkType: hard @@ -12274,7 +11817,7 @@ __metadata: "react-property@npm:2.0.2": version: 2.0.2 resolution: "react-property@npm:2.0.2" - checksum: c67fb2590dad9102239b2e574fbc078b472e227b66c3d64e1949426e33889d78c2ac5369cc4c740bb8fe61665505940e39a78d113261ac52410f7538089fe367 + checksum: 3a4bc1951b2b7992cb8a2d3f12016dd0920d1c06eb58b456204a6ae1210401d62baece098d3200ed8a0513dde247a5d96ffdb24f354e32ce5a9b26fbd8552668 languageName: node linkType: hard @@ -12282,12 +11825,12 @@ __metadata: version: 8.1.1 resolution: "react-redux@npm:8.1.1" dependencies: - "@babel/runtime": ^7.12.1 - "@types/hoist-non-react-statics": ^3.3.1 - "@types/use-sync-external-store": ^0.0.3 - hoist-non-react-statics: ^3.3.2 - react-is: ^18.0.0 - use-sync-external-store: ^1.0.0 + "@babel/runtime": "npm:^7.12.1" + "@types/hoist-non-react-statics": "npm:^3.3.1" + "@types/use-sync-external-store": "npm:^0.0.3" + hoist-non-react-statics: "npm:^3.3.2" + react-is: "npm:^18.0.0" + use-sync-external-store: "npm:^1.0.0" peerDependencies: "@types/react": ^16.8 || ^17.0 || ^18.0 "@types/react-dom": ^16.8 || ^17.0 || ^18.0 @@ -12306,14 +11849,14 @@ __metadata: optional: true redux: optional: true - checksum: 370676330727764d78f35e9c5a0ed0591d79482fe9b70fffcab4aa6bcccc6194e4f1ebd818b4b390351dea5557e70d3bd4d95d7a0ac9baa1f45d6bf2230ee713 + checksum: 2998af1870dadc1a5c39566712481cc087af259198c419840b6b966d311ba23bb95b31441440ff4c61ac710024914ebb9c71fbd4290e6fa25d255e6f20ae737a languageName: node linkType: hard "react-refresh@npm:^0.14.0": version: 0.14.0 resolution: "react-refresh@npm:0.14.0" - checksum: dc69fa8c993df512f42dd0f1b604978ae89bd747c0ed5ec595c0cc50d535fb2696619ccd98ae28775cc01d0a7c146a532f0f7fb81dc22e1977c242a4912312f4 + checksum: 75941262ce3ed4fc79b52492943fd59692f29b84f30f3822713b7e920f28e85c62a4386f85cbfbaea95ed62d3e74209f0a0bb065904b7ab2f166a74ac3812e2a languageName: node linkType: hard @@ -12321,15 +11864,15 @@ __metadata: version: 2.3.5 resolution: "react-remove-scroll-bar@npm:2.3.5" dependencies: - react-style-singleton: ^2.2.1 - tslib: ^2.0.0 + react-style-singleton: "npm:^2.2.1" + tslib: "npm:^2.0.0" peerDependencies: "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 peerDependenciesMeta: "@types/react": optional: true - checksum: 0b6eee6d338085f0c766dc6d780100041a39377bc1a2a1b99a13444832b91885fc632b7521636a29d26710cf8bb0f9f4177123abe088a358597ac0f0e8e42f45 + checksum: 6d05e74ee8049b322ba0aeb398e092ae284a5b04013bc07f0c1f283824b088fd5c1b1f1514a0e0e501c063a9c3b5899373039329d0266a21121222c814052053 languageName: node linkType: hard @@ -12337,18 +11880,18 @@ __metadata: version: 2.5.5 resolution: "react-remove-scroll@npm:2.5.5" dependencies: - react-remove-scroll-bar: ^2.3.3 - react-style-singleton: ^2.2.1 - tslib: ^2.1.0 - use-callback-ref: ^1.3.0 - use-sidecar: ^1.1.2 + react-remove-scroll-bar: "npm:^2.3.3" + react-style-singleton: "npm:^2.2.1" + tslib: "npm:^2.1.0" + use-callback-ref: "npm:^1.3.0" + use-sidecar: "npm:^1.1.2" peerDependencies: "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 peerDependenciesMeta: "@types/react": optional: true - checksum: 2c7fe9cbd766f5e54beb4bec2e2efb2de3583037b23fef8fa511ab426ed7f1ae992382db5acd8ab5bfb030a4b93a06a2ebca41377d6eeaf0e6791bb0a59616a4 + checksum: f0646ac384ce3852d1f41e30a9f9e251b11cf3b430d1d114c937c8fa7f90a895c06378d0d6b6ff0b2d00cbccf15e845921944fd6074ae67a0fb347a718106d88 languageName: node linkType: hard @@ -12356,12 +11899,12 @@ __metadata: version: 6.11.2 resolution: "react-router-dom@npm:6.11.2" dependencies: - "@remix-run/router": 1.6.2 - react-router: 6.11.2 + "@remix-run/router": "npm:1.6.2" + react-router: "npm:6.11.2" peerDependencies: react: ">=16.8" react-dom: ">=16.8" - checksum: ba44ff37f2956bc18991f2eedb32a3fa46d35832f61ded6c5d167e853ca289868fca6635467866280c73bc3da00dce8437dbbec57da100c0a3e3e3850af00b83 + checksum: 85575793cbdb84b05e9c33fef6f81e6b09e9f2606d2ba03392f83689dbb240212e5b22634b95049fc19364e9b44d45a519387d1bff4eba8a163548aa3376bc0f languageName: node linkType: hard @@ -12369,10 +11912,10 @@ __metadata: version: 6.11.2 resolution: "react-router@npm:6.11.2" dependencies: - "@remix-run/router": 1.6.2 + "@remix-run/router": "npm:1.6.2" peerDependencies: react: ">=16.8" - checksum: e47f875dca70033a3b42704cb5ec076b60f9629a5cdc3be613707f3d5a5706123fb80301037455c285c6d5a1011b443e1784e0103969ebfac7071648d360c413 + checksum: a40d1ea78e3b5b3167ed6cbaf74b2e60592fd1822b9f94a2499933bf699130a81f669bc06bdf34f38489a96d31510848c21254a48e49038b18ecbf42993eaa34 languageName: node linkType: hard @@ -12380,16 +11923,16 @@ __metadata: version: 2.2.1 resolution: "react-style-singleton@npm:2.2.1" dependencies: - get-nonce: ^1.0.0 - invariant: ^2.2.4 - tslib: ^2.0.0 + get-nonce: "npm:^1.0.0" + invariant: "npm:^2.2.4" + tslib: "npm:^2.0.0" peerDependencies: "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 peerDependenciesMeta: "@types/react": optional: true - checksum: 7ee8ef3aab74c7ae1d70ff34a27643d11ba1a8d62d072c767827d9ff9a520905223e567002e0bf6c772929d8ea1c781a3ba0cc4a563e92b1e3dc2eaa817ecbe8 + checksum: 80c58fd6aac3594e351e2e7b048d8a5b09508adb21031a38b3c40911fe58295572eddc640d4b20a7be364842c8ed1120fe30097e22ea055316b375b88d4ff02a languageName: node linkType: hard @@ -12397,14 +11940,14 @@ __metadata: version: 15.5.0 resolution: "react-syntax-highlighter@npm:15.5.0" dependencies: - "@babel/runtime": ^7.3.1 - highlight.js: ^10.4.1 - lowlight: ^1.17.0 - prismjs: ^1.27.0 - refractor: ^3.6.0 + "@babel/runtime": "npm:^7.3.1" + highlight.js: "npm:^10.4.1" + lowlight: "npm:^1.17.0" + prismjs: "npm:^1.27.0" + refractor: "npm:^3.6.0" peerDependencies: react: ">= 0.14.0" - checksum: c082b48f30f8ba8d0c55ed1d761910630860077c7ff5793c4c912adcb5760df06436ed0ad62be0de28113aac9ad2af55eccd995f8eee98df53382e4ced2072fb + checksum: 14291a92672a79cf167e6cf2dba2547b920c24573729a95ae24035bece43f7e00e3429477be7b87455e8ce018682c8992545c405a915421eb772c5cd07c00576 languageName: node linkType: hard @@ -12412,8 +11955,8 @@ __metadata: version: 18.2.0 resolution: "react@npm:18.2.0" dependencies: - loose-envify: ^1.1.0 - checksum: 88e38092da8839b830cda6feef2e8505dec8ace60579e46aa5490fc3dc9bba0bd50336507dc166f43e3afc1c42939c09fe33b25fae889d6f402721dcd78fca1b + loose-envify: "npm:^1.1.0" + checksum: b9214a9bd79e99d08de55f8bef2b7fc8c39630be97c4e29d7be173d14a9a10670b5325e94485f74cd8bff4966ef3c78ee53c79a7b0b9b70cba20aa8973acc694 languageName: node linkType: hard @@ -12421,9 +11964,9 @@ __metadata: version: 7.0.1 resolution: "read-pkg-up@npm:7.0.1" dependencies: - find-up: ^4.1.0 - read-pkg: ^5.2.0 - type-fest: ^0.8.1 + find-up: "npm:^4.1.0" + read-pkg: "npm:^5.2.0" + type-fest: "npm:^0.8.1" checksum: e4e93ce70e5905b490ca8f883eb9e48b5d3cebc6cd4527c25a0d8f3ae2903bd4121c5ab9c5a3e217ada0141098eeb661313c86fa008524b089b8ed0b7f165e44 languageName: node linkType: hard @@ -12432,37 +11975,37 @@ __metadata: version: 5.2.0 resolution: "read-pkg@npm:5.2.0" dependencies: - "@types/normalize-package-data": ^2.4.0 - normalize-package-data: ^2.5.0 - parse-json: ^5.0.0 - type-fest: ^0.6.0 + "@types/normalize-package-data": "npm:^2.4.0" + normalize-package-data: "npm:^2.5.0" + parse-json: "npm:^5.0.0" + type-fest: "npm:^0.6.0" checksum: eb696e60528b29aebe10e499ba93f44991908c57d70f2d26f369e46b8b9afc208ef11b4ba64f67630f31df8b6872129e0a8933c8c53b7b4daf0eace536901222 languageName: node linkType: hard -"readable-stream@npm:^2.0.0, readable-stream@npm:^2.2.2, readable-stream@npm:~2.3.6": +"readable-stream@npm:^2.0.0, readable-stream@npm:^2.2.2, readable-stream@npm:^2.3.5, readable-stream@npm:~2.3.6": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" dependencies: - core-util-is: ~1.0.0 - inherits: ~2.0.3 - isarray: ~1.0.0 - process-nextick-args: ~2.0.0 - safe-buffer: ~5.1.1 - string_decoder: ~1.1.1 - util-deprecate: ~1.0.1 - checksum: 65645467038704f0c8aaf026a72fbb588a9e2ef7a75cd57a01702ee9db1c4a1e4b03aaad36861a6a0926546a74d174149c8c207527963e0c2d3eee2f37678a42 + core-util-is: "npm:~1.0.0" + inherits: "npm:~2.0.3" + isarray: "npm:~1.0.0" + process-nextick-args: "npm:~2.0.0" + safe-buffer: "npm:~5.1.1" + string_decoder: "npm:~1.1.1" + util-deprecate: "npm:~1.0.1" + checksum: 8500dd3a90e391d6c5d889256d50ec6026c059fadee98ae9aa9b86757d60ac46fff24fafb7a39fa41d54cb39d8be56cc77be202ebd4cd8ffcf4cb226cbaa40d4 languageName: node linkType: hard -"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0": +"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" dependencies: - inherits: ^2.0.3 - string_decoder: ^1.1.1 - util-deprecate: ^1.0.1 - checksum: bdcbe6c22e846b6af075e32cf8f4751c2576238c5043169a1c221c92ee2878458a816a4ea33f4c67623c0b6827c8a400409bfb3cf0bf3381392d0b1dfb52ac8d + inherits: "npm:^2.0.3" + string_decoder: "npm:^1.1.1" + util-deprecate: "npm:^1.0.1" + checksum: d9e3e53193adcdb79d8f10f2a1f6989bd4389f5936c6f8b870e77570853561c362bee69feca2bbb7b32368ce96a85504aa4cedf7cf80f36e6a9de30d64244048 languageName: node linkType: hard @@ -12470,8 +12013,8 @@ __metadata: version: 3.6.0 resolution: "readdirp@npm:3.6.0" dependencies: - picomatch: ^2.2.1 - checksum: 1ced032e6e45670b6d7352d71d21ce7edf7b9b928494dcaba6f11fba63180d9da6cd7061ebc34175ffda6ff529f481818c962952004d273178acd70f7059b320 + picomatch: "npm:^2.2.1" + checksum: 196b30ef6ccf9b6e18c4e1724b7334f72a093d011a99f3b5920470f0b3406a51770867b3e1ae9711f227ef7a7065982f6ee2ce316746b2cb42c88efe44297fe7 languageName: node linkType: hard @@ -12479,12 +12022,12 @@ __metadata: version: 0.23.4 resolution: "recast@npm:0.23.4" dependencies: - assert: ^2.0.0 - ast-types: ^0.16.1 - esprima: ~4.0.0 - source-map: ~0.6.1 - tslib: ^2.0.1 - checksum: edb63bbe0457e68c0f4892f55413000e92aa7c5c53f9e109ab975d1c801cd299a62511ea72734435791f4aea6f0edf560f6a275761f66b2b6069ff6d72686029 + assert: "npm:^2.0.0" + ast-types: "npm:^0.16.1" + esprima: "npm:~4.0.0" + source-map: "npm:~0.6.1" + tslib: "npm:^2.0.1" + checksum: a82e388ded2154697ea54e6d65d060143c9cf4b521f770232a7483e253d45bdd9080b44dc5874d36fe720ba1a10cb20b95375896bd89f5cab631a751e93979f5 languageName: node linkType: hard @@ -12492,8 +12035,8 @@ __metadata: version: 3.0.0 resolution: "redent@npm:3.0.0" dependencies: - indent-string: ^4.0.0 - strip-indent: ^3.0.0 + indent-string: "npm:^4.0.0" + strip-indent: "npm:^3.0.0" checksum: fa1ef20404a2d399235e83cc80bd55a956642e37dd197b4b612ba7327bf87fa32745aeb4a1634b2bab25467164ab4ed9c15be2c307923dd08b0fe7c52431ae6b languageName: node linkType: hard @@ -12503,7 +12046,7 @@ __metadata: resolution: "redux-thunk@npm:2.4.2" peerDependencies: redux: ^4 - checksum: c7f757f6c383b8ec26152c113e20087818d18ed3edf438aaad43539e9a6b77b427ade755c9595c4a163b6ad3063adf3497e5fe6a36c68884eb1f1cfb6f049a5c + checksum: 9bcb1193835128ecebf1e1a1b1a37bc15e8dfbdf6b6ee1b5566dd4c8e4ca05a81175f0c6dda34ab47f87053cd13b74d9f881d59446691d7b192831852b5d7a72 languageName: node linkType: hard @@ -12511,8 +12054,8 @@ __metadata: version: 4.2.1 resolution: "redux@npm:4.2.1" dependencies: - "@babel/runtime": ^7.9.2 - checksum: f63b9060c3a1d930ae775252bb6e579b42415aee7a23c4114e21a0b4ba7ec12f0ec76936c00f546893f06e139819f0e2855e0d55ebfce34ca9c026241a6950dd + "@babel/runtime": "npm:^7.9.2" + checksum: 371e4833b671193303a7dea7803c8fdc8e0d566740c78f580e0a3b77b4161da25037626900a2205a5d616117fa6ad09a4232e5a110bd437186b5c6355a041750 languageName: node linkType: hard @@ -12520,14 +12063,14 @@ __metadata: version: 1.0.5 resolution: "reflect.getprototypeof@npm:1.0.5" dependencies: - call-bind: ^1.0.5 - define-properties: ^1.2.1 - es-abstract: ^1.22.3 - es-errors: ^1.0.0 - get-intrinsic: ^1.2.3 - globalthis: ^1.0.3 - which-builtin-type: ^1.1.3 - checksum: c7176be030b89b9e55882f4da3288de5ffd187c528d79870e27d2c8a713a82b3fa058ca2d0c9da25f6d61240e2685c42d7daa32cdf3d431d8207ee1b9ed30993 + call-bind: "npm:^1.0.5" + define-properties: "npm:^1.2.1" + es-abstract: "npm:^1.22.3" + es-errors: "npm:^1.0.0" + get-intrinsic: "npm:^1.2.3" + globalthis: "npm:^1.0.3" + which-builtin-type: "npm:^1.1.3" + checksum: 14560efa54b4b8549f5e0961ee4dfa9f034bd4b85c7805d487da30eb520ea252b566bc4098a7cb1bc2219e4d9cb095db43c05b27205bd6299bb141294cea2d14 languageName: node linkType: hard @@ -12535,10 +12078,10 @@ __metadata: version: 3.6.0 resolution: "refractor@npm:3.6.0" dependencies: - hastscript: ^6.0.0 - parse-entities: ^2.0.0 - prismjs: ~1.27.0 - checksum: 39b01c4168c77c5c8486f9bf8907bbb05f257f15026057ba5728535815a2d90eed620468a4bfbb2b8ceefbb3ce3931a1be8b17152dbdbc8b0eef92450ff750a2 + hastscript: "npm:^6.0.0" + parse-entities: "npm:^2.0.0" + prismjs: "npm:~1.27.0" + checksum: 671bbcf5ae1b4e207f98b9a3dc2cbae215be30effe9f3bdcfd10f565f45fecfe97334cf38c8e4f52d6cc012ff2ec7fb627d3d5678efc388751c8b1e1f7ca2a6c languageName: node linkType: hard @@ -12546,11 +12089,11 @@ __metadata: version: 4.8.1 resolution: "refractor@npm:4.8.1" dependencies: - "@types/hast": ^2.0.0 - "@types/prismjs": ^1.0.0 - hastscript: ^7.0.0 - parse-entities: ^4.0.0 - checksum: 51762ed1d62523e3fb4b1ccec3f846965d497b7828e43d1668b2839dcbbe6b0d4edfd9113ad6e3679e5a6b6fb2f6983883123d762a7e46e3b5a8cd480a0a1930 + "@types/hast": "npm:^2.0.0" + "@types/prismjs": "npm:^1.0.0" + hastscript: "npm:^7.0.0" + parse-entities: "npm:^4.0.0" + checksum: d41cdd3b7e2ee4dbe33cd06f5860fbd1219c4b60cb80ac606337ec9ff2f06b0fdd1b712a5f0e9dbf840193f37870546b5c145ca4772e961a5a117d5137ae8cc4 languageName: node linkType: hard @@ -12558,22 +12101,22 @@ __metadata: version: 10.1.1 resolution: "regenerate-unicode-properties@npm:10.1.1" dependencies: - regenerate: ^1.4.2 - checksum: b80958ef40f125275824c2c47d5081dfaefebd80bff26c76761e9236767c748a4a95a69c053fe29d2df881177f2ca85df4a71fe70a82360388b31159ef19adcf + regenerate: "npm:^1.4.2" + checksum: b855152efdcca0ecc37ceb0cb6647a544344555fc293af3b57191b918e1bc9c95ee404a9a64a1d692bf66d45850942c29d93f2740c0d1980d3a8ea2ca63b184e languageName: node linkType: hard "regenerate@npm:^1.4.2": version: 1.4.2 resolution: "regenerate@npm:1.4.2" - checksum: 3317a09b2f802da8db09aa276e469b57a6c0dd818347e05b8862959c6193408242f150db5de83c12c3fa99091ad95fb42a6db2c3329bfaa12a0ea4cbbeb30cb0 + checksum: dc6c95ae4b3ba6adbd7687cafac260eee4640318c7a95239d5ce847d9b9263979758389e862fe9c93d633b5792ea4ada5708df75885dc5aa05a309fa18140a87 languageName: node linkType: hard "regenerator-runtime@npm:^0.14.0": version: 0.14.1 resolution: "regenerator-runtime@npm:0.14.1" - checksum: 9f57c93277b5585d3c83b0cf76be47b473ae8c6d9142a46ce8b0291a04bb2cf902059f0f8445dcabb3fb7378e5fe4bb4ea1e008876343d42e46d3b484534ce38 + checksum: 5db3161abb311eef8c45bcf6565f4f378f785900ed3945acf740a9888c792f75b98ecb77f0775f3bf95502ff423529d23e94f41d80c8256e8fa05ed4b07cf471 languageName: node linkType: hard @@ -12581,18 +12124,8 @@ __metadata: version: 0.15.2 resolution: "regenerator-transform@npm:0.15.2" dependencies: - "@babel/runtime": ^7.8.4 - checksum: 20b6f9377d65954980fe044cfdd160de98df415b4bff38fbade67b3337efaf078308c4fed943067cd759827cc8cfeca9cb28ccda1f08333b85d6a2acbd022c27 - languageName: node - linkType: hard - -"regex-not@npm:^1.0.0, regex-not@npm:^1.0.2": - version: 1.0.2 - resolution: "regex-not@npm:1.0.2" - dependencies: - extend-shallow: ^3.0.2 - safe-regex: ^1.1.0 - checksum: 3081403de79559387a35ef9d033740e41818a559512668cef3d12da4e8a29ef34ee13c8ed1256b07e27ae392790172e8a15c8a06b72962fd4550476cde3d8f77 + "@babel/runtime": "npm:^7.8.4" + checksum: c4fdcb46d11bbe32605b4b9ed76b21b8d3f241a45153e9dc6f5542fed4c7744fed459f42701f650d5d5956786bf7de57547329d1c05a9df2ed9e367b9d903302 languageName: node linkType: hard @@ -12600,11 +12133,11 @@ __metadata: version: 1.5.2 resolution: "regexp.prototype.flags@npm:1.5.2" dependencies: - call-bind: ^1.0.6 - define-properties: ^1.2.1 - es-errors: ^1.3.0 - set-function-name: ^2.0.1 - checksum: d7f333667d5c564e2d7a97c56c3075d64c722c9bb51b2b4df6822b2e8096d623a5e63088fb4c83df919b6951ef8113841de8b47de7224872fa6838bc5d8a7d64 + call-bind: "npm:^1.0.6" + define-properties: "npm:^1.2.1" + es-errors: "npm:^1.3.0" + set-function-name: "npm:^2.0.1" + checksum: 9fffc01da9c4e12670ff95bc5204364615fcc12d86fc30642765af908675678ebb0780883c874b2dbd184505fb52fa603d80073ecf69f461ce7f56b15d10be9c languageName: node linkType: hard @@ -12612,13 +12145,13 @@ __metadata: version: 5.3.2 resolution: "regexpu-core@npm:5.3.2" dependencies: - "@babel/regjsgen": ^0.8.0 - regenerate: ^1.4.2 - regenerate-unicode-properties: ^10.1.0 - regjsparser: ^0.9.1 - unicode-match-property-ecmascript: ^2.0.0 - unicode-match-property-value-ecmascript: ^2.1.0 - checksum: 95bb97088419f5396e07769b7de96f995f58137ad75fac5811fb5fe53737766dfff35d66a0ee66babb1eb55386ef981feaef392f9df6d671f3c124812ba24da2 + "@babel/regjsgen": "npm:^0.8.0" + regenerate: "npm:^1.4.2" + regenerate-unicode-properties: "npm:^10.1.0" + regjsparser: "npm:^0.9.1" + unicode-match-property-ecmascript: "npm:^2.0.0" + unicode-match-property-value-ecmascript: "npm:^2.1.0" + checksum: ed0d7c66d84c633fbe8db4939d084c780190eca11f6920807dfb8ebac59e2676952cd8f2008d9c86ae8cf0463ea5fd12c5cff09ef2ce7d51ee6b420a5eb4d177 languageName: node linkType: hard @@ -12626,10 +12159,10 @@ __metadata: version: 0.9.1 resolution: "regjsparser@npm:0.9.1" dependencies: - jsesc: ~0.5.0 + jsesc: "npm:~0.5.0" bin: regjsparser: bin/parser - checksum: 5e1b76afe8f1d03c3beaf9e0d935dd467589c3625f6d65fb8ffa14f224d783a0fed4bf49c2c1b8211043ef92b6117313419edf055a098ed8342e340586741afc + checksum: be7757ef76e1db10bf6996001d1021048b5fb12f5cb470a99b8cf7f3ff943f0f0e2291c0dcdbb418b458ddc4ac10e48680a822b69ef487a0284c8b6b77beddc3 languageName: node linkType: hard @@ -12637,11 +12170,11 @@ __metadata: version: 8.0.0 resolution: "remark-external-links@npm:8.0.0" dependencies: - extend: ^3.0.0 - is-absolute-url: ^3.0.0 - mdast-util-definitions: ^4.0.0 - space-separated-tokens: ^1.0.0 - unist-util-visit: ^2.0.0 + extend: "npm:^3.0.0" + is-absolute-url: "npm:^3.0.0" + mdast-util-definitions: "npm:^4.0.0" + space-separated-tokens: "npm:^1.0.0" + unist-util-visit: "npm:^2.0.0" checksum: 48c4a41fe38916f79febb390b0c4deefe82b554dd36dc534262d851860d17fb6d15d78d515f29194e5fa48db5f01f4405a6f6dd077aaf32812a2efffb01700d7 languageName: node linkType: hard @@ -12650,59 +12183,59 @@ __metadata: version: 6.1.0 resolution: "remark-slug@npm:6.1.0" dependencies: - github-slugger: ^1.0.0 - mdast-util-to-string: ^1.0.0 - unist-util-visit: ^2.0.0 - checksum: 81fff0dcfaf6d6117ef1293bb1d26c3e25483d99c65c22434298eed93583a89ea5d7b94063d9a7f47c0647a708ce84f00ff62d274503f248feec03c344cabb20 + github-slugger: "npm:^1.0.0" + mdast-util-to-string: "npm:^1.0.0" + unist-util-visit: "npm:^2.0.0" + checksum: 8c90815a0f1f0568450e923391de0183205e18befb7a7e19e111c75ad08cabf7daebe62fccc82b6fbf9f54148dd311b87463632299dbf9fdfe412f6a0a9ab3ea languageName: node linkType: hard -"repeat-element@npm:^1.1.2": - version: 1.1.4 - resolution: "repeat-element@npm:1.1.4" - checksum: 1edd0301b7edad71808baad226f0890ba709443f03a698224c9ee4f2494c317892dc5211b2ba8cbea7194a9ddbcac01e283bd66de0467ab24ee1fc1a3711d8a9 +"remove-trailing-separator@npm:^1.0.1": + version: 1.1.0 + resolution: "remove-trailing-separator@npm:1.1.0" + checksum: d3c20b5a2d987db13e1cca9385d56ecfa1641bae143b620835ac02a6b70ab88f68f117a0021838db826c57b31373d609d52e4f31aca75fc490c862732d595419 languageName: node linkType: hard -"repeat-string@npm:^1.6.1": - version: 1.6.1 - resolution: "repeat-string@npm:1.6.1" - checksum: 1b809fc6db97decdc68f5b12c4d1a671c8e3f65ec4a40c238bc5200e44e85bcc52a54f78268ab9c29fcf5fe4f1343e805420056d1f30fa9a9ee4c2d93e3cc6c0 +"replace-ext@npm:^1.0.0": + version: 1.0.1 + resolution: "replace-ext@npm:1.0.1" + checksum: 4994ea1aaa3d32d152a8d98ff638988812c4fa35ba55485630008fe6f49e3384a8a710878e6fd7304b42b38d1b64c1cd070e78ece411f327735581a79dd88571 languageName: node linkType: hard "require-directory@npm:^2.1.1": version: 2.1.1 resolution: "require-directory@npm:2.1.1" - checksum: fb47e70bf0001fdeabdc0429d431863e9475e7e43ea5f94ad86503d918423c1543361cc5166d713eaa7029dd7a3d34775af04764bebff99ef413111a5af18c80 + checksum: a72468e2589270d91f06c7d36ec97a88db53ae5d6fe3787fadc943f0b0276b10347f89b363b2a82285f650bdcc135ad4a257c61bdd4d00d6df1fa24875b0ddaf languageName: node linkType: hard "require-from-string@npm:^2.0.2": version: 2.0.2 resolution: "require-from-string@npm:2.0.2" - checksum: a03ef6895445f33a4015300c426699bc66b2b044ba7b670aa238610381b56d3f07c686251740d575e22f4c87531ba662d06937508f0f3c0f1ddc04db3130560b + checksum: 839a3a890102a658f4cb3e7b2aa13a1f80a3a976b512020c3d1efc418491c48a886b6e481ea56afc6c4cb5eef678f23b2a4e70575e7534eccadf5e30ed2e56eb languageName: node linkType: hard "requireindex@npm:^1.2.0": version: 1.2.0 resolution: "requireindex@npm:1.2.0" - checksum: 50d8b10a1ff1fdf6aea7a1870bc7bd238b0fb1917d8d7ca17fd03afc38a65dcd7a8a4eddd031f89128b5f0065833d5c92c4fef67f2c04e8624057fe626c9cf94 + checksum: 266d1cb31f6cbc4b6cf2e898f5bbc45581f7919bcf61bba5c45d0adb69b722b9ff5a13727be3350cde4520d7cd37f39df45d58a29854baaa4552cd6b05ae4a1a languageName: node linkType: hard "requires-port@npm:^1.0.0": version: 1.0.0 resolution: "requires-port@npm:1.0.0" - checksum: eee0e303adffb69be55d1a214e415cf42b7441ae858c76dfc5353148644f6fd6e698926fc4643f510d5c126d12a705e7c8ed7e38061113bdf37547ab356797ff + checksum: 878880ee78ccdce372784f62f52a272048e2d0827c29ae31e7f99da18b62a2b9463ea03a75f277352f4697c100183debb0532371ad515a2d49d4bfe596dd4c20 languageName: node linkType: hard "reselect@npm:^4.1.8": version: 4.1.8 resolution: "reselect@npm:4.1.8" - checksum: a4ac87cedab198769a29be92bc221c32da76cfdad6911eda67b4d3e7136dca86208c3b210e31632eae31ebd2cded18596f0dd230d3ccc9e978df22f233b5583e + checksum: 199984d9872f71cd207f4aa6e6fd2bd48d95154f7aa9b3aee3398335f39f5491059e732f28c12e9031d5d434adab2c458dc8af5afb6564d0ad37e1644445e09c languageName: node linkType: hard @@ -12710,7 +12243,7 @@ __metadata: version: 3.0.0 resolution: "resolve-cwd@npm:3.0.0" dependencies: - resolve-from: ^5.0.0 + resolve-from: "npm:^5.0.0" checksum: 546e0816012d65778e580ad62b29e975a642989108d9a3c5beabfb2304192fa3c9f9146fbdfe213563c6ff51975ae41bac1d3c6e047dd9572c94863a057b4d81 languageName: node linkType: hard @@ -12718,28 +12251,21 @@ __metadata: "resolve-from@npm:^4.0.0": version: 4.0.0 resolution: "resolve-from@npm:4.0.0" - checksum: f4ba0b8494846a5066328ad33ef8ac173801a51739eb4d63408c847da9a2e1c1de1e6cbbf72699211f3d13f8fc1325648b169bd15eb7da35688e30a5fb0e4a7f + checksum: 91eb76ce83621eea7bbdd9b55121a5c1c4a39e54a9ce04a9ad4517f102f8b5131c2cf07622c738a6683991bf54f2ce178f5a42803ecbd527ddc5105f362cc9e3 languageName: node linkType: hard "resolve-from@npm:^5.0.0": version: 5.0.0 resolution: "resolve-from@npm:5.0.0" - checksum: 4ceeb9113e1b1372d0cd969f3468fa042daa1dd9527b1b6bb88acb6ab55d8b9cd65dbf18819f9f9ddf0db804990901dcdaade80a215e7b2c23daae38e64f5bdf - languageName: node - linkType: hard - -"resolve-url@npm:^0.2.1": - version: 0.2.1 - resolution: "resolve-url@npm:0.2.1" - checksum: 7b7035b9ed6e7bc7d289e90aef1eab5a43834539695dac6416ca6e91f1a94132ae4796bbd173cdacfdc2ade90b5f38a3fb6186bebc1b221cd157777a23b9ad14 + checksum: be18a5e4d76dd711778664829841cde690971d02b6cbae277735a09c1c28f407b99ef6ef3cd585a1e6546d4097b28df40ed32c4a287b9699dcf6d7f208495e23 languageName: node linkType: hard "resolve.exports@npm:^2.0.0": version: 2.0.2 resolution: "resolve.exports@npm:2.0.2" - checksum: 1c7778ca1b86a94f8ab4055d196c7d87d1874b96df4d7c3e67bbf793140f0717fd506dcafd62785b079cd6086b9264424ad634fb904409764c3509c3df1653f2 + checksum: f1cc0b6680f9a7e0345d783e0547f2a5110d8336b3c2a4227231dd007271ffd331fd722df934f017af90bae0373920ca0d4005da6f76cb3176c8ae426370f893 languageName: node linkType: hard @@ -12747,12 +12273,12 @@ __metadata: version: 1.22.8 resolution: "resolve@npm:1.22.8" dependencies: - is-core-module: ^2.13.0 - path-parse: ^1.0.7 - supports-preserve-symlinks-flag: ^1.0.0 + is-core-module: "npm:^2.13.0" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" bin: resolve: bin/resolve - checksum: f8a26958aa572c9b064562750b52131a37c29d072478ea32e129063e2da7f83e31f7f11e7087a18225a8561cfe8d2f0df9dbea7c9d331a897571c0a2527dbb4c + checksum: c473506ee01eb45cbcfefb68652ae5759e092e6b0fb64547feadf9736a6394f258fbc6f88e00c5ca36d5477fbb65388b272432a3600fa223062e54333c156753 languageName: node linkType: hard @@ -12760,38 +12286,38 @@ __metadata: version: 2.0.0-next.5 resolution: "resolve@npm:2.0.0-next.5" dependencies: - is-core-module: ^2.13.0 - path-parse: ^1.0.7 - supports-preserve-symlinks-flag: ^1.0.0 + is-core-module: "npm:^2.13.0" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" bin: resolve: bin/resolve - checksum: a73ac69a1c4bd34c56b213d91f5b17ce390688fdb4a1a96ed3025cc7e08e7bfb90b3a06fcce461780cb0b589c958afcb0080ab802c71c01a7ecc8c64feafc89f + checksum: 2d6fd28699f901744368e6f2032b4268b4c7b9185fd8beb64f68c93ac6b22e52ae13560ceefc96241a665b985edf9ffd393ae26d2946a7d3a07b7007b7d51e79 languageName: node linkType: hard -"resolve@patch:resolve@^1.10.0#~builtin, resolve@patch:resolve@^1.14.2#~builtin, resolve@patch:resolve@^1.20.0#~builtin, resolve@patch:resolve@^1.22.1#~builtin, resolve@patch:resolve@^1.22.4#~builtin": +"resolve@patch:resolve@npm%3A^1.10.0#optional!builtin, resolve@patch:resolve@npm%3A^1.14.2#optional!builtin, resolve@patch:resolve@npm%3A^1.20.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.1#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin": version: 1.22.8 - resolution: "resolve@patch:resolve@npm%3A1.22.8#~builtin::version=1.22.8&hash=c3c19d" + resolution: "resolve@patch:resolve@npm%3A1.22.8#optional!builtin::version=1.22.8&hash=c3c19d" dependencies: - is-core-module: ^2.13.0 - path-parse: ^1.0.7 - supports-preserve-symlinks-flag: ^1.0.0 + is-core-module: "npm:^2.13.0" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" bin: resolve: bin/resolve - checksum: 5479b7d431cacd5185f8db64bfcb7286ae5e31eb299f4c4f404ad8aa6098b77599563ac4257cb2c37a42f59dfc06a1bec2bcf283bb448f319e37f0feb9a09847 + checksum: f345cd37f56a2c0275e3fe062517c650bb673815d885e7507566df589375d165bbbf4bdb6aa95600a9bc55f4744b81f452b5a63f95b9f10a72787dba3c90890a languageName: node linkType: hard -"resolve@patch:resolve@^2.0.0-next.4#~builtin": +"resolve@patch:resolve@npm%3A^2.0.0-next.4#optional!builtin": version: 2.0.0-next.5 - resolution: "resolve@patch:resolve@npm%3A2.0.0-next.5#~builtin::version=2.0.0-next.5&hash=c3c19d" + resolution: "resolve@patch:resolve@npm%3A2.0.0-next.5#optional!builtin::version=2.0.0-next.5&hash=c3c19d" dependencies: - is-core-module: ^2.13.0 - path-parse: ^1.0.7 - supports-preserve-symlinks-flag: ^1.0.0 + is-core-module: "npm:^2.13.0" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" bin: resolve: bin/resolve - checksum: 064d09c1808d0c51b3d90b5d27e198e6d0c5dad0eb57065fd40803d6a20553e5398b07f76739d69cbabc12547058bec6b32106ea66622375fb0d7e8fca6a846c + checksum: 05fa778de9d0347c8b889eb7a18f1f06bf0f801b0eb4610b4871a4b2f22e220900cf0ad525e94f990bb8d8921c07754ab2122c0c225ab4cdcea98f36e64fa4c2 languageName: node linkType: hard @@ -12799,30 +12325,23 @@ __metadata: version: 3.1.0 resolution: "restore-cursor@npm:3.1.0" dependencies: - onetime: ^5.1.0 - signal-exit: ^3.0.2 + onetime: "npm:^5.1.0" + signal-exit: "npm:^3.0.2" checksum: f877dd8741796b909f2a82454ec111afb84eb45890eb49ac947d87991379406b3b83ff9673a46012fca0d7844bb989f45cc5b788254cf1a39b6b5a9659de0630 languageName: node linkType: hard -"ret@npm:~0.1.10": - version: 0.1.15 - resolution: "ret@npm:0.1.15" - checksum: d76a9159eb8c946586567bd934358dfc08a36367b3257f7a3d7255fdd7b56597235af23c6afa0d7f0254159e8051f93c918809962ebd6df24ca2a83dbe4d4151 - languageName: node - linkType: hard - "retry@npm:^0.12.0": version: 0.12.0 resolution: "retry@npm:0.12.0" - checksum: 623bd7d2e5119467ba66202d733ec3c2e2e26568074923bc0585b6b99db14f357e79bdedb63cab56cec47491c4a0da7e6021a7465ca6dc4f481d3898fdd3158c + checksum: 1f914879f97e7ee931ad05fe3afa629bd55270fc6cf1c1e589b6a99fab96d15daad0fa1a52a00c729ec0078045fe3e399bd4fd0c93bcc906957bdc17f89cb8e6 languageName: node linkType: hard "reusify@npm:^1.0.4": version: 1.0.4 resolution: "reusify@npm:1.0.4" - checksum: c3076ebcc22a6bc252cb0b9c77561795256c22b757f40c0d8110b1300723f15ec0fc8685e8d4ea6d7666f36c79ccc793b1939c748bf36f18f542744a4e379fcc + checksum: 14222c9e1d3f9ae01480c50d96057228a8524706db79cdeb5a2ce5bb7070dd9f409a6f84a02cbef8cdc80d39aef86f2dd03d155188a1300c599b05437dcd2ffb languageName: node linkType: hard @@ -12830,10 +12349,10 @@ __metadata: version: 2.7.1 resolution: "rimraf@npm:2.7.1" dependencies: - glob: ^7.1.3 + glob: "npm:^7.1.3" bin: rimraf: ./bin.js - checksum: cdc7f6eacb17927f2a075117a823e1c5951792c6498ebcce81ca8203454a811d4cf8900314154d3259bb8f0b42ab17f67396a8694a54cae3283326e57ad250cd + checksum: 4586c296c736483e297da7cffd19475e4a3e41d07b1ae124aad5d687c79e4ffa716bdac8732ed1db942caf65271cee9dd39f8b639611de161a2753e2112ffe1d languageName: node linkType: hard @@ -12841,10 +12360,10 @@ __metadata: version: 3.0.2 resolution: "rimraf@npm:3.0.2" dependencies: - glob: ^7.1.3 + glob: "npm:^7.1.3" bin: rimraf: bin.js - checksum: 87f4164e396f0171b0a3386cc1877a817f572148ee13a7e113b238e48e8a9f2f31d009a92ec38a591ff1567d9662c6b67fd8818a2dbbaed74bc26a87a2a4a9a0 + checksum: 063ffaccaaaca2cfd0ef3beafb12d6a03dd7ff1260d752d62a6077b5dfff6ae81bea571f655bb6b589d366930ec1bdd285d40d560c0dae9b12f125e54eb743d5 languageName: node linkType: hard @@ -12852,10 +12371,10 @@ __metadata: version: 2.6.3 resolution: "rimraf@npm:2.6.3" dependencies: - glob: ^7.1.3 + glob: "npm:^7.1.3" bin: rimraf: ./bin.js - checksum: 3ea587b981a19016297edb96d1ffe48af7e6af69660e3b371dbfc73722a73a0b0e9be5c88089fbeeb866c389c1098e07f64929c7414290504b855f54f901ab10 + checksum: 756419f2fa99aa119c46a9fc03e09d84ecf5421a80a72d1944c5088c9e4671e77128527a900a313ed9d3fdbdd37e2ae05486cd7e9116d5812d8c31f2399d7c86 languageName: node linkType: hard @@ -12863,13 +12382,13 @@ __metadata: version: 3.29.4 resolution: "rollup@npm:3.29.4" dependencies: - fsevents: ~2.3.2 + fsevents: "npm:~2.3.2" dependenciesMeta: fsevents: optional: true bin: rollup: dist/bin/rollup - checksum: 8bb20a39c8d91130825159c3823eccf4dc2295c9a0a5c4ed851a5bf2167dbf24d9a29f23461a54c955e5506395e6cc188eafc8ab0e20399d7489fb33793b184e + checksum: 9e39d54e23731a4c4067e9c02910cdf7479a0f9a7584796e2dc6efaa34bb1e5e015c062c87d1e64d96038baca76cefd47681ff22604fae5827147f54123dc6d0 languageName: node linkType: hard @@ -12877,13 +12396,13 @@ __metadata: version: 2.79.1 resolution: "rollup@npm:2.79.1" dependencies: - fsevents: ~2.3.2 + fsevents: "npm:~2.3.2" dependenciesMeta: fsevents: optional: true bin: rollup: dist/bin/rollup - checksum: 6a2bf167b3587d4df709b37d149ad0300692cc5deb510f89ac7bdc77c8738c9546ae3de9322b0968e1ed2b0e984571f5f55aae28fa7de4cfcb1bc5402a4e2be6 + checksum: df087b701304432f30922bbee5f534ab189aa6938bd383b5686c03147e0d00cd1789ea10a462361326ce6b6ebe448ce272ad3f3cc40b82eeb3157df12f33663c languageName: node linkType: hard @@ -12891,7 +12410,7 @@ __metadata: version: 1.2.0 resolution: "run-parallel@npm:1.2.0" dependencies: - queue-microtask: ^1.2.2 + queue-microtask: "npm:^1.2.2" checksum: cb4f97ad25a75ebc11a8ef4e33bb962f8af8516bb2001082ceabd8902e15b98f4b84b4f8a9b222e5d57fc3bd1379c483886ed4619367a7680dad65316993021d languageName: node linkType: hard @@ -12900,25 +12419,25 @@ __metadata: version: 1.1.0 resolution: "safe-array-concat@npm:1.1.0" dependencies: - call-bind: ^1.0.5 - get-intrinsic: ^1.2.2 - has-symbols: ^1.0.3 - isarray: ^2.0.5 - checksum: 5c71eaa999168ee7474929f1cd3aae80f486353a651a094d9968936692cf90aa065224929a6486dcda66334a27dce4250a83612f9e0fef6dced1a925d3ac7296 + call-bind: "npm:^1.0.5" + get-intrinsic: "npm:^1.2.2" + has-symbols: "npm:^1.0.3" + isarray: "npm:^2.0.5" + checksum: 41ac35ce46c44e2e8637b1805b0697d5269507779e3082b7afb92c01605fd73ab813bbc799510c56e300cfc941b1447fd98a338205db52db7fd1322ab32d7c9f languageName: node linkType: hard "safe-buffer@npm:5.1.2, safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": version: 5.1.2 resolution: "safe-buffer@npm:5.1.2" - checksum: f2f1f7943ca44a594893a852894055cf619c1fbcb611237fc39e461ae751187e7baf4dc391a72125e0ac4fb2d8c5c0b3c71529622e6a58f46b960211e704903c + checksum: 7eb5b48f2ed9a594a4795677d5a150faa7eb54483b2318b568dc0c4fc94092a6cce5be02c7288a0500a156282f5276d5688bce7259299568d1053b2150ef374a languageName: node linkType: hard "safe-buffer@npm:5.2.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" - checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491 + checksum: 32872cd0ff68a3ddade7a7617b8f4c2ae8764d8b7d884c651b74457967a9e0e886267d3ecc781220629c44a865167b61c375d2da6c720c840ecd73f45d5d9451 languageName: node linkType: hard @@ -12926,26 +12445,24 @@ __metadata: version: 1.0.3 resolution: "safe-regex-test@npm:1.0.3" dependencies: - call-bind: ^1.0.6 - es-errors: ^1.3.0 - is-regex: ^1.1.4 - checksum: 6c7d392ff1ae7a3ae85273450ed02d1d131f1d2c76e177d6b03eb88e6df8fa062639070e7d311802c1615f351f18dc58f9454501c58e28d5ffd9b8f502ba6489 + call-bind: "npm:^1.0.6" + es-errors: "npm:^1.3.0" + is-regex: "npm:^1.1.4" + checksum: b04de61114b10274d92e25b6de7ccb5de07f11ea15637ff636de4b5190c0f5cd8823fe586dde718504cf78055437d70fd8804976894df502fcf5a210c970afb3 languageName: node linkType: hard -"safe-regex@npm:^1.1.0": - version: 1.1.0 - resolution: "safe-regex@npm:1.1.0" - dependencies: - ret: ~0.1.10 - checksum: 9a8bba57c87a841f7997b3b951e8e403b1128c1a4fd1182f40cc1a20e2d490593d7c2a21030fadfea320c8e859219019e136f678c6689ed5960b391b822f01d5 +"safe-stable-stringify@npm:^2.3.1": + version: 2.4.3 + resolution: "safe-stable-stringify@npm:2.4.3" + checksum: a6c192bbefe47770a11072b51b500ed29be7b1c15095371c1ee1dc13e45ce48ee3c80330214c56764d006c485b88bd0b24940d868948170dddc16eed312582d8 languageName: node linkType: hard "safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" - checksum: cab8f25ae6f1434abee8d80023d7e72b598cf1327164ddab31003c51215526801e40b66c5e65d658a0af1e9d6478cadcb4c745f4bd6751f97d8644786c0978b0 + checksum: 7eaf7a0cf37cc27b42fb3ef6a9b1df6e93a1c6d98c6c6702b02fe262d5fcbd89db63320793b99b21cb5348097d0a53de81bd5f4e8b86e20cc9412e3f1cfb4e83 languageName: node linkType: hard @@ -12953,12 +12470,12 @@ __metadata: version: 1.71.1 resolution: "sass@npm:1.71.1" dependencies: - chokidar: ">=3.0.0 <4.0.0" - immutable: ^4.0.0 - source-map-js: ">=0.6.2 <2.0.0" + chokidar: "npm:>=3.0.0 <4.0.0" + immutable: "npm:^4.0.0" + source-map-js: "npm:>=0.6.2 <2.0.0" bin: sass: sass.js - checksum: 19c4939d3042eb9459d462bbd27b1f576fa18034e23c87ca0005b87effdee431c16503b5a785edcdcde1a76dfb804716d9ad42c85a78968ac3825d515e45cb53 + checksum: 51e3c667e262a80db9c80f31109dabd8d5b9a6f79e8e8aa627d83564607036ee0b13b1921d14fd317437d8cf7030d7c8cf1c3b7e11b1f4537a4a4029f6cb63a3 languageName: node linkType: hard @@ -12966,8 +12483,8 @@ __metadata: version: 6.0.0 resolution: "saxes@npm:6.0.0" dependencies: - xmlchars: ^2.2.0 - checksum: d3fa3e2aaf6c65ed52ee993aff1891fc47d5e47d515164b5449cbf5da2cbdc396137e55590472e64c5c436c14ae64a8a03c29b9e7389fc6f14035cf4e982ef3b + xmlchars: "npm:^2.2.0" + checksum: 97b50daf6ca3a153e89842efa18a862e446248296622b7473c169c84c823ee8a16e4a43bac2f73f11fc8cb9168c73fbb0d73340f26552bac17970e9052367aa9 languageName: node linkType: hard @@ -12975,8 +12492,8 @@ __metadata: version: 0.23.0 resolution: "scheduler@npm:0.23.0" dependencies: - loose-envify: ^1.1.0 - checksum: d79192eeaa12abef860c195ea45d37cbf2bbf5f66e3c4dcd16f54a7da53b17788a70d109ee3d3dde1a0fd50e6a8fc171f4300356c5aee4fc0171de526bf35f8a + loose-envify: "npm:^1.1.0" + checksum: 0c4557aa37bafca44ff21dc0ea7c92e2dbcb298bc62eae92b29a39b029134f02fb23917d6ebc8b1fa536b4184934314c20d8864d156a9f6357f3398aaf7bfda8 languageName: node linkType: hard @@ -12985,7 +12502,7 @@ __metadata: resolution: "semver@npm:5.7.2" bin: semver: bin/semver - checksum: fb4ab5e0dd1c22ce0c937ea390b4a822147a9c53dbd2a9a0132f12fe382902beef4fbf12cf51bb955248d8d15874ce8cd89532569756384f994309825f10b686 + checksum: fca14418a174d4b4ef1fecb32c5941e3412d52a4d3d85165924ce3a47fbc7073372c26faf7484ceb4bbc2bde25880c6b97e492473dc7e9708fdfb1c6a02d546e languageName: node linkType: hard @@ -12994,7 +12511,7 @@ __metadata: resolution: "semver@npm:6.3.1" bin: semver: bin/semver.js - checksum: ae47d06de28836adb9d3e25f22a92943477371292d9b665fb023fae278d345d508ca1958232af086d85e0155aee22e313e100971898bbb8d5d89b8b1d4054ca2 + checksum: 1ef3a85bd02a760c6ef76a45b8c1ce18226de40831e02a00bad78485390b98b6ccaa31046245fc63bba4a47a6a592b6c7eedc65cc47126e60489f9cc1ce3ed7e languageName: node linkType: hard @@ -13002,10 +12519,10 @@ __metadata: version: 7.6.0 resolution: "semver@npm:7.6.0" dependencies: - lru-cache: ^6.0.0 + lru-cache: "npm:^6.0.0" bin: semver: bin/semver.js - checksum: 7427f05b70786c696640edc29fdd4bc33b2acf3bbe1740b955029044f80575fc664e1a512e4113c3af21e767154a94b4aa214bf6cd6e42a1f6dba5914e0b208c + checksum: 1b41018df2d8aca5a1db4729985e8e20428c650daea60fcd16e926e9383217d00f574fab92d79612771884a98d2ee2a1973f49d630829a8d54d6570defe62535 languageName: node linkType: hard @@ -13013,20 +12530,20 @@ __metadata: version: 0.18.0 resolution: "send@npm:0.18.0" dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: ~1.0.2 - escape-html: ~1.0.3 - etag: ~1.8.1 - fresh: 0.5.2 - http-errors: 2.0.0 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: ~1.2.1 - statuses: 2.0.1 - checksum: 74fc07ebb58566b87b078ec63e5a3e41ecd987e4272ba67b7467e86c6ad51bc6b0b0154133b6d8b08a2ddda360464f71382f7ef864700f34844a76c8027817a8 + debug: "npm:2.6.9" + depd: "npm:2.0.0" + destroy: "npm:1.2.0" + encodeurl: "npm:~1.0.2" + escape-html: "npm:~1.0.3" + etag: "npm:~1.8.1" + fresh: "npm:0.5.2" + http-errors: "npm:2.0.0" + mime: "npm:1.6.0" + ms: "npm:2.1.3" + on-finished: "npm:2.4.1" + range-parser: "npm:~1.2.1" + statuses: "npm:2.0.1" + checksum: ec66c0ad109680ad8141d507677cfd8b4e40b9559de23191871803ed241718e99026faa46c398dcfb9250676076573bd6bfe5d0ec347f88f4b7b8533d1d391cb languageName: node linkType: hard @@ -13034,11 +12551,11 @@ __metadata: version: 1.15.0 resolution: "serve-static@npm:1.15.0" dependencies: - encodeurl: ~1.0.2 - escape-html: ~1.0.3 - parseurl: ~1.3.3 - send: 0.18.0 - checksum: af57fc13be40d90a12562e98c0b7855cf6e8bd4c107fe9a45c212bf023058d54a1871b1c89511c3958f70626fff47faeb795f5d83f8cf88514dbaeb2b724464d + encodeurl: "npm:~1.0.2" + escape-html: "npm:~1.0.3" + parseurl: "npm:~1.3.3" + send: "npm:0.18.0" + checksum: 699b2d4c29807a51d9b5e0f24955346911437aebb0178b3c4833ad30d3eca93385ff9927254f5c16da345903cad39d9cd4a532198c95a5129cc4ed43911b15a4 languageName: node linkType: hard @@ -13046,13 +12563,13 @@ __metadata: version: 1.2.1 resolution: "set-function-length@npm:1.2.1" dependencies: - define-data-property: ^1.1.2 - es-errors: ^1.3.0 - function-bind: ^1.1.2 - get-intrinsic: ^1.2.3 - gopd: ^1.0.1 - has-property-descriptors: ^1.0.1 - checksum: 23742476d695f2eae86348c069bd164d4f25fa7c26546a46a2b5f370f1f84b98ec64366d2cd17785d5b41bbf16b95855da4b7eb188e7056fe3b0248d61f6afda + define-data-property: "npm:^1.1.2" + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + get-intrinsic: "npm:^1.2.3" + gopd: "npm:^1.0.1" + has-property-descriptors: "npm:^1.0.1" + checksum: 9ab1d200149574ab27c1a7acae56d6235e02568fc68655fe8afe63e4e02ccad3c27665f55c32408bd1ff40705939dbb7539abfb9c3a07fda27ecad1ab9e449f5 languageName: node linkType: hard @@ -13060,30 +12577,18 @@ __metadata: version: 2.0.2 resolution: "set-function-name@npm:2.0.2" dependencies: - define-data-property: ^1.1.4 - es-errors: ^1.3.0 - functions-have-names: ^1.2.3 - has-property-descriptors: ^1.0.2 - checksum: d6229a71527fd0404399fc6227e0ff0652800362510822a291925c9d7b48a1ca1a468b11b281471c34cd5a2da0db4f5d7ff315a61d26655e77f6e971e6d0c80f - languageName: node - linkType: hard - -"set-value@npm:^2.0.0, set-value@npm:^2.0.1": - version: 2.0.1 - resolution: "set-value@npm:2.0.1" - dependencies: - extend-shallow: ^2.0.1 - is-extendable: ^0.1.1 - is-plain-object: ^2.0.3 - split-string: ^3.0.1 - checksum: 09a4bc72c94641aeae950eb60dc2755943b863780fcc32e441eda964b64df5e3f50603d5ebdd33394ede722528bd55ed43aae26e9df469b4d32e2292b427b601 + define-data-property: "npm:^1.1.4" + es-errors: "npm:^1.3.0" + functions-have-names: "npm:^1.2.3" + has-property-descriptors: "npm:^1.0.2" + checksum: c7614154a53ebf8c0428a6c40a3b0b47dac30587c1a19703d1b75f003803f73cdfa6a93474a9ba678fa565ef5fbddc2fae79bca03b7d22ab5fd5163dbe571a74 languageName: node linkType: hard "setprototypeof@npm:1.2.0": version: 1.2.0 resolution: "setprototypeof@npm:1.2.0" - checksum: be18cbbf70e7d8097c97f713a2e76edf84e87299b40d085c6bf8b65314e994cc15e2e317727342fa6996e38e1f52c59720b53fe621e2eb593a6847bf0356db89 + checksum: fde1630422502fbbc19e6844346778f99d449986b2f9cdcceb8326730d2f3d9964dbcb03c02aaadaefffecd0f2c063315ebea8b3ad895914bf1afc1747fc172e languageName: node linkType: hard @@ -13091,8 +12596,8 @@ __metadata: version: 3.0.1 resolution: "shallow-clone@npm:3.0.1" dependencies: - kind-of: ^6.0.2 - checksum: 39b3dd9630a774aba288a680e7d2901f5c0eae7b8387fc5c8ea559918b29b3da144b7bdb990d7ccd9e11be05508ac9e459ce51d01fd65e583282f6ffafcba2e7 + kind-of: "npm:^6.0.2" + checksum: e066bd540cfec5e1b0f78134853e0d892d1c8945fb9a926a579946052e7cb0c70ca4fc34f875a8083aa7910d751805d36ae64af250a6de6f3d28f9fa7be6c21b languageName: node linkType: hard @@ -13100,7 +12605,7 @@ __metadata: version: 2.0.0 resolution: "shebang-command@npm:2.0.0" dependencies: - shebang-regex: ^3.0.0 + shebang-regex: "npm:^3.0.0" checksum: 6b52fe87271c12968f6a054e60f6bde5f0f3d2db483a1e5c3e12d657c488a15474121a1d55cd958f6df026a54374ec38a4a963988c213b7570e1d51575cea7fa languageName: node linkType: hard @@ -13116,11 +12621,11 @@ __metadata: version: 1.0.5 resolution: "side-channel@npm:1.0.5" dependencies: - call-bind: ^1.0.6 - es-errors: ^1.3.0 - get-intrinsic: ^1.2.4 - object-inspect: ^1.13.1 - checksum: 640446b4e5a9554116ed6f5bec17c6740fa8da2c1a19e4d69c1202191185d4cc24f21ba0dd3ccca140eb6a8ee978d0b5bc5132f09b7962db7f9c4bc7872494ac + call-bind: "npm:^1.0.6" + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.4" + object-inspect: "npm:^1.13.1" + checksum: 27708b70b5d81bf18dc8cc23f38f1b6c9511691a64abc4aaf17956e67d132c855cf8b46f931e2fc5a6262b29371eb60da7755c1b9f4f862eccea8562b469f8f6 languageName: node linkType: hard @@ -13134,7 +12639,16 @@ __metadata: "signal-exit@npm:^4.0.1, signal-exit@npm:^4.1.0": version: 4.1.0 resolution: "signal-exit@npm:4.1.0" - checksum: 64c757b498cb8629ffa5f75485340594d2f8189e9b08700e69199069c8e3070fb3e255f7ab873c05dc0b3cec412aea7402e10a5990cb6a050bd33ba062a6c549 + checksum: c9fa63bbbd7431066174a48ba2dd9986dfd930c3a8b59de9c29d7b6854ec1c12a80d15310869ea5166d413b99f041bfa3dd80a7947bcd44ea8e6eb3ffeabfa1f + languageName: node + linkType: hard + +"simple-swizzle@npm:^0.2.2": + version: 0.2.2 + resolution: "simple-swizzle@npm:0.2.2" + dependencies: + is-arrayish: "npm:^0.3.1" + checksum: c6dffff17aaa383dae7e5c056fbf10cf9855a9f79949f20ee225c04f06ddde56323600e0f3d6797e82d08d006e93761122527438ee9531620031c08c9e0d73cc languageName: node linkType: hard @@ -13155,43 +12669,7 @@ __metadata: "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" - checksum: b5167a7142c1da704c0e3af85c402002b597081dd9575031a90b4f229ca5678e9a36e8a374f1814c8156a725d17008ae3bde63b92f9cfd132526379e580bec8b - languageName: node - linkType: hard - -"snapdragon-node@npm:^2.0.1": - version: 2.1.1 - resolution: "snapdragon-node@npm:2.1.1" - dependencies: - define-property: ^1.0.0 - isobject: ^3.0.0 - snapdragon-util: ^3.0.1 - checksum: 9bb57d759f9e2a27935dbab0e4a790137adebace832b393e350a8bf5db461ee9206bb642d4fe47568ee0b44080479c8b4a9ad0ebe3712422d77edf9992a672fd - languageName: node - linkType: hard - -"snapdragon-util@npm:^3.0.1": - version: 3.0.1 - resolution: "snapdragon-util@npm:3.0.1" - dependencies: - kind-of: ^3.2.0 - checksum: 684997dbe37ec995c03fd3f412fba2b711fc34cb4010452b7eb668be72e8811a86a12938b511e8b19baf853b325178c56d8b78d655305e5cfb0bb8b21677e7b7 - languageName: node - linkType: hard - -"snapdragon@npm:^0.8.1": - version: 0.8.2 - resolution: "snapdragon@npm:0.8.2" - dependencies: - base: ^0.11.1 - debug: ^2.2.0 - define-property: ^0.2.5 - extend-shallow: ^2.0.1 - map-cache: ^0.2.2 - source-map: ^0.5.6 - source-map-resolve: ^0.5.0 - use: ^3.1.0 - checksum: a197f242a8f48b11036563065b2487e9b7068f50a20dd81d9161eca6af422174fc158b8beeadbe59ce5ef172aa5718143312b3aebaae551c124b7824387c8312 + checksum: 927484aa0b1640fd9473cee3e0a0bcad6fce93fd7bbc18bac9ad0c33686f5d2e2c422fba24b5899c184524af01e11dd2bd051c2bf2b07e47aff8ca72cbfc60d2 languageName: node linkType: hard @@ -13199,10 +12677,10 @@ __metadata: version: 8.0.2 resolution: "socks-proxy-agent@npm:8.0.2" dependencies: - agent-base: ^7.0.2 - debug: ^4.3.4 - socks: ^2.7.1 - checksum: 4fb165df08f1f380881dcd887b3cdfdc1aba3797c76c1e9f51d29048be6e494c5b06d68e7aea2e23df4572428f27a3ec22b3d7c75c570c5346507433899a4b6d + agent-base: "npm:^7.0.2" + debug: "npm:^4.3.4" + socks: "npm:^2.7.1" + checksum: ea727734bd5b2567597aa0eda14149b3b9674bb44df5937bbb9815280c1586994de734d965e61f1dd45661183d7b41f115fb9e432d631287c9063864cfcc2ecc languageName: node linkType: hard @@ -13210,29 +12688,16 @@ __metadata: version: 2.8.1 resolution: "socks@npm:2.8.1" dependencies: - ip-address: ^9.0.5 - smart-buffer: ^4.2.0 - checksum: 29586d42e9c36c5016632b2bcb6595e3adfbcb694b3a652c51bc8741b079c5ec37bdd5675a1a89a1620078c8137208294991fabb50786f92d47759a725b2b62e + ip-address: "npm:^9.0.5" + smart-buffer: "npm:^4.2.0" + checksum: a3cc38e0716ab53a2db3fa00c703ca682ad54dbbc9ed4c7461624a999be6fa7cdc79fc904c411618e698d5eff55a55aa6d9329169a7db11636d0200814a2b5aa languageName: node linkType: hard "source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.2": version: 1.0.2 resolution: "source-map-js@npm:1.0.2" - checksum: c049a7fc4deb9a7e9b481ae3d424cc793cb4845daa690bc5a05d428bf41bf231ced49b4cf0c9e77f9d42fdb3d20d6187619fc586605f5eabe995a316da8d377c - languageName: node - linkType: hard - -"source-map-resolve@npm:^0.5.0": - version: 0.5.3 - resolution: "source-map-resolve@npm:0.5.3" - dependencies: - atob: ^2.1.2 - decode-uri-component: ^0.2.0 - resolve-url: ^0.2.1 - source-map-url: ^0.4.0 - urix: ^0.1.0 - checksum: c73fa44ac00783f025f6ad9e038ab1a2e007cd6a6b86f47fe717c3d0765b4a08d264f6966f3bd7cd9dbcd69e4832783d5472e43247775b2a550d6f2155d24bae + checksum: 38e2d2dd18d2e331522001fc51b54127ef4a5d473f53b1349c5cca2123562400e0986648b52e9407e348eaaed53bce49248b6e2641e6d793ca57cb2c360d6d51 languageName: node linkType: hard @@ -13240,9 +12705,9 @@ __metadata: version: 0.5.13 resolution: "source-map-support@npm:0.5.13" dependencies: - buffer-from: ^1.0.0 - source-map: ^0.6.0 - checksum: 933550047b6c1a2328599a21d8b7666507427c0f5ef5eaadd56b5da0fd9505e239053c66fe181bf1df469a3b7af9d775778eee283cbb7ae16b902ddc09e93a97 + buffer-from: "npm:^1.0.0" + source-map: "npm:^0.6.0" + checksum: d1514a922ac9c7e4786037eeff6c3322f461cd25da34bb9fefb15387b3490531774e6e31d95ab6d5b84a3e139af9c3a570ccaee6b47bd7ea262691ed3a8bc34e languageName: node linkType: hard @@ -13250,30 +12715,16 @@ __metadata: version: 0.5.21 resolution: "source-map-support@npm:0.5.21" dependencies: - buffer-from: ^1.0.0 - source-map: ^0.6.0 - checksum: 43e98d700d79af1d36f859bdb7318e601dfc918c7ba2e98456118ebc4c4872b327773e5a1df09b0524e9e5063bb18f0934538eace60cca2710d1fa687645d137 - languageName: node - linkType: hard - -"source-map-url@npm:^0.4.0": - version: 0.4.1 - resolution: "source-map-url@npm:0.4.1" - checksum: 64c5c2c77aff815a6e61a4120c309ae4cac01298d9bcbb3deb1b46a4dd4c46d4a1eaeda79ec9f684766ae80e8dc86367b89326ce9dd2b89947bd9291fc1ac08c - languageName: node - linkType: hard - -"source-map@npm:^0.5.6": - version: 0.5.7 - resolution: "source-map@npm:0.5.7" - checksum: 5dc2043b93d2f194142c7f38f74a24670cd7a0063acdaf4bf01d2964b402257ae843c2a8fa822ad5b71013b5fcafa55af7421383da919752f22ff488bc553f4d + buffer-from: "npm:^1.0.0" + source-map: "npm:^0.6.0" + checksum: 8317e12d84019b31e34b86d483dd41d6f832f389f7417faf8fc5c75a66a12d9686e47f589a0554a868b8482f037e23df9d040d29387eb16fa14cb85f091ba207 languageName: node linkType: hard "source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.1": version: 0.6.1 resolution: "source-map@npm:0.6.1" - checksum: 59ce8640cf3f3124f64ac289012c2b8bd377c238e316fb323ea22fbfe83da07d81e000071d7242cad7a23cd91c7de98e4df8830ec3f133cb6133a5f6e9f67bc2 + checksum: 59ef7462f1c29d502b3057e822cdbdae0b0e565302c4dd1a95e11e793d8d9d62006cdc10e0fd99163ca33ff2071360cf50ee13f90440806e7ed57d81cba2f7ff languageName: node linkType: hard @@ -13295,9 +12746,9 @@ __metadata: version: 3.2.0 resolution: "spdx-correct@npm:3.2.0" dependencies: - spdx-expression-parse: ^3.0.0 - spdx-license-ids: ^3.0.0 - checksum: e9ae98d22f69c88e7aff5b8778dc01c361ef635580e82d29e5c60a6533cc8f4d820803e67d7432581af0cc4fb49973125076ee3b90df191d153e223c004193b2 + spdx-expression-parse: "npm:^3.0.0" + spdx-license-ids: "npm:^3.0.0" + checksum: cc2e4dbef822f6d12142116557d63f5facf3300e92a6bd24e907e4865e17b7e1abd0ee6b67f305cae6790fc2194175a24dc394bfcc01eea84e2bdad728e9ae9a languageName: node linkType: hard @@ -13312,8 +12763,8 @@ __metadata: version: 3.0.1 resolution: "spdx-expression-parse@npm:3.0.1" dependencies: - spdx-exceptions: ^2.1.0 - spdx-license-ids: ^3.0.0 + spdx-exceptions: "npm:^2.1.0" + spdx-license-ids: "npm:^3.0.0" checksum: a1c6e104a2cbada7a593eaa9f430bd5e148ef5290d4c0409899855ce8b1c39652bcc88a725259491a82601159d6dc790bedefc9016c7472f7de8de7361f8ccde languageName: node linkType: hard @@ -13321,30 +12772,21 @@ __metadata: "spdx-license-ids@npm:^3.0.0": version: 3.0.17 resolution: "spdx-license-ids@npm:3.0.17" - checksum: 0aba5d16292ff604dd20982200e23b4d425f6ba364765039bdbde2f6c956b9909fce1ad040a897916a5f87388e85e001f90cb64bf706b6e319f3908cfc445a59 - languageName: node - linkType: hard - -"split-string@npm:^3.0.1, split-string@npm:^3.0.2": - version: 3.1.0 - resolution: "split-string@npm:3.1.0" - dependencies: - extend-shallow: ^3.0.0 - checksum: ae5af5c91bdc3633628821bde92fdf9492fa0e8a63cf6a0376ed6afde93c701422a1610916f59be61972717070119e848d10dfbbd5024b7729d6a71972d2a84c + checksum: 8f6c6ae02ebb25b4ca658b8990d9e8a8f8d8a95e1d8b9fd84d87eed80a7dc8f8073d6a8d50b8a0295c0e8399e1f8814f5c00e2985e6bf3731540a16f7241cbf1 languageName: node linkType: hard "sprintf-js@npm:^1.1.3": version: 1.1.3 resolution: "sprintf-js@npm:1.1.3" - checksum: a3fdac7b49643875b70864a9d9b469d87a40dfeaf5d34d9d0c5b1cda5fd7d065531fcb43c76357d62254c57184a7b151954156563a4d6a747015cfb41021cad0 + checksum: e7587128c423f7e43cc625fe2f87e6affdf5ca51c1cc468e910d8aaca46bb44a7fbcfa552f787b1d3987f7043aeb4527d1b99559e6621e01b42b3f45e5a24cbb languageName: node linkType: hard "sprintf-js@npm:~1.0.2": version: 1.0.3 resolution: "sprintf-js@npm:1.0.3" - checksum: 19d79aec211f09b99ec3099b5b2ae2f6e9cdefe50bc91ac4c69144b6d3928a640bb6ae5b3def70c2e85a2c3d9f5ec2719921e3a59d3ca3ef4b2fd1a4656a0df3 + checksum: c34828732ab8509c2741e5fd1af6b767c3daf2c642f267788f933a65b1614943c282e74c4284f4fa749c264b18ee016a0d37a3e5b73aee446da46277d3a85daa languageName: node linkType: hard @@ -13352,8 +12794,8 @@ __metadata: version: 10.0.5 resolution: "ssri@npm:10.0.5" dependencies: - minipass: ^7.0.3 - checksum: 0a31b65f21872dea1ed3f7c200d7bc1c1b91c15e419deca14f282508ba917cbb342c08a6814c7f68ca4ca4116dd1a85da2bbf39227480e50125a1ceffeecb750 + minipass: "npm:^7.0.3" + checksum: 453f9a1c241c13f5dfceca2ab7b4687bcff354c3ccbc932f35452687b9ef0ccf8983fd13b8a3baa5844c1a4882d6e3ddff48b0e7fd21d743809ef33b80616d79 languageName: node linkType: hard @@ -13364,22 +12806,19 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.3": - version: 2.0.6 - resolution: "stack-utils@npm:2.0.6" - dependencies: - escape-string-regexp: ^2.0.0 - checksum: 052bf4d25bbf5f78e06c1d5e67de2e088b06871fa04107ca8d3f0e9d9263326e2942c8bedee3545795fc77d787d443a538345eef74db2f8e35db3558c6f91ff7 +"stack-trace@npm:0.0.x": + version: 0.0.10 + resolution: "stack-trace@npm:0.0.10" + checksum: 7bd633f0e9ac46e81a0b0fe6538482c1d77031959cf94478228731709db4672fbbed59176f5b9a9fd89fec656b5dae03d084ef2d1b0c4c2f5683e05f2dbb1405 languageName: node linkType: hard -"static-extend@npm:^0.1.1": - version: 0.1.2 - resolution: "static-extend@npm:0.1.2" +"stack-utils@npm:^2.0.3": + version: 2.0.6 + resolution: "stack-utils@npm:2.0.6" dependencies: - define-property: ^0.2.5 - object-copy: ^0.1.0 - checksum: 8657485b831f79e388a437260baf22784540417a9b29e11572c87735df24c22b84eda42107403a64b30861b2faf13df9f7fc5525d51f9d1d2303aba5cbf4e12c + escape-string-regexp: "npm:^2.0.0" + checksum: cdc988acbc99075b4b036ac6014e5f1e9afa7e564482b687da6384eee6a1909d7eaffde85b0a17ffbe186c5247faf6c2b7544e802109f63b72c7be69b13151bb languageName: node linkType: hard @@ -13394,15 +12833,15 @@ __metadata: version: 1.0.0 resolution: "stop-iteration-iterator@npm:1.0.0" dependencies: - internal-slot: ^1.0.4 - checksum: d04173690b2efa40e24ab70e5e51a3ff31d56d699550cfad084104ab3381390daccb36652b25755e420245f3b0737de66c1879eaa2a8d4fc0a78f9bf892fcb42 + internal-slot: "npm:^1.0.4" + checksum: 2a23a36f4f6bfa63f46ae2d53a3f80fe8276110b95a55345d8ed3d92125413494033bc8697eb774e8f7aeb5725f70e3d69753caa2ecacdac6258c16fa8aa8b0f languageName: node linkType: hard "store2@npm:^2.14.2": version: 2.14.3 resolution: "store2@npm:2.14.3" - checksum: 971a47aa479ff5491f89ee3fcbaf4ddafe0cfb55ac2f4cf4b4fc7b21d349fa3a761f79368d1573b9f65af08b3cf0f6973eed56a213b8bb4cb7e820ac048d1613 + checksum: f95f6fbacff14cc3bb9e5e16ced2f29e2d706e30b248c16cf19abed8b2bb31d8f3907c8ccf1a5284d806fdcaf06e96710e4f4f52195e51522a452536beaf7af9 languageName: node linkType: hard @@ -13410,7 +12849,7 @@ __metadata: version: 7.6.17 resolution: "storybook@npm:7.6.17" dependencies: - "@storybook/cli": 7.6.17 + "@storybook/cli": "npm:7.6.17" bin: sb: ./index.js storybook: ./index.js @@ -13425,19 +12864,12 @@ __metadata: languageName: node linkType: hard -"strict-uri-encode@npm:^1.0.0": - version: 1.1.0 - resolution: "strict-uri-encode@npm:1.1.0" - checksum: 9466d371f7b36768d43f7803f26137657559e4c8b0161fb9e320efb8edba3ae22f8e99d4b0d91da023b05a13f62ec5412c3f4f764b5788fac11d1fea93720bb3 - languageName: node - linkType: hard - "string-length@npm:^4.0.1": version: 4.0.2 resolution: "string-length@npm:4.0.2" dependencies: - char-regex: ^1.0.2 - strip-ansi: ^6.0.0 + char-regex: "npm:^1.0.2" + strip-ansi: "npm:^6.0.0" checksum: ce85533ef5113fcb7e522bcf9e62cb33871aa99b3729cec5595f4447f660b0cefd542ca6df4150c97a677d58b0cb727a3fe09ac1de94071d05526c73579bf505 languageName: node linkType: hard @@ -13446,9 +12878,9 @@ __metadata: version: 4.2.3 resolution: "string-width@npm:4.2.3" dependencies: - emoji-regex: ^8.0.0 - is-fullwidth-code-point: ^3.0.0 - strip-ansi: ^6.0.1 + emoji-regex: "npm:^8.0.0" + is-fullwidth-code-point: "npm:^3.0.0" + strip-ansi: "npm:^6.0.1" checksum: e52c10dc3fbfcd6c3a15f159f54a90024241d0f149cf8aed2982a2d801d2e64df0bf1dc351cf8e95c3319323f9f220c16e740b06faecd53e2462df1d2b5443fb languageName: node linkType: hard @@ -13457,9 +12889,9 @@ __metadata: version: 5.1.2 resolution: "string-width@npm:5.1.2" dependencies: - eastasianwidth: ^0.2.0 - emoji-regex: ^9.2.2 - strip-ansi: ^7.0.1 + eastasianwidth: "npm:^0.2.0" + emoji-regex: "npm:^9.2.2" + strip-ansi: "npm:^7.0.1" checksum: 7369deaa29f21dda9a438686154b62c2c5f661f8dda60449088f9f980196f7908fc39fdd1803e3e01541970287cf5deae336798337e9319a7055af89dafa7193 languageName: node linkType: hard @@ -13468,16 +12900,16 @@ __metadata: version: 4.0.10 resolution: "string.prototype.matchall@npm:4.0.10" dependencies: - call-bind: ^1.0.2 - define-properties: ^1.2.0 - es-abstract: ^1.22.1 - get-intrinsic: ^1.2.1 - has-symbols: ^1.0.3 - internal-slot: ^1.0.5 - regexp.prototype.flags: ^1.5.0 - set-function-name: ^2.0.0 - side-channel: ^1.0.4 - checksum: 3c78bdeff39360c8e435d7c4c6ea19f454aa7a63eda95fa6fadc3a5b984446a2f9f2c02d5c94171ce22268a573524263fbd0c8edbe3ce2e9890d7cc036cdc3ed + call-bind: "npm:^1.0.2" + define-properties: "npm:^1.2.0" + es-abstract: "npm:^1.22.1" + get-intrinsic: "npm:^1.2.1" + has-symbols: "npm:^1.0.3" + internal-slot: "npm:^1.0.5" + regexp.prototype.flags: "npm:^1.5.0" + set-function-name: "npm:^2.0.0" + side-channel: "npm:^1.0.4" + checksum: 0f7a1a7f91790cd45f804039a16bc6389c8f4f25903e648caa3eea080b019a5c7b0cac2ca83976646140c2332b159042140bf389f23675609d869dd52450cddc languageName: node linkType: hard @@ -13485,10 +12917,10 @@ __metadata: version: 1.2.8 resolution: "string.prototype.trim@npm:1.2.8" dependencies: - call-bind: ^1.0.2 - define-properties: ^1.2.0 - es-abstract: ^1.22.1 - checksum: 49eb1a862a53aba73c3fb6c2a53f5463173cb1f4512374b623bcd6b43ad49dd559a06fb5789bdec771a40fc4d2a564411c0a75d35fb27e76bbe738c211ecff07 + call-bind: "npm:^1.0.2" + define-properties: "npm:^1.2.0" + es-abstract: "npm:^1.22.1" + checksum: 9301f6cb2b6c44f069adde1b50f4048915985170a20a1d64cf7cb2dc53c5cd6b9525b92431f1257f894f94892d6c4ae19b5aa7f577c3589e7e51772dffc9d5a4 languageName: node linkType: hard @@ -13496,10 +12928,10 @@ __metadata: version: 1.0.7 resolution: "string.prototype.trimend@npm:1.0.7" dependencies: - call-bind: ^1.0.2 - define-properties: ^1.2.0 - es-abstract: ^1.22.1 - checksum: 2375516272fd1ba75992f4c4aa88a7b5f3c7a9ca308d963bcd5645adf689eba6f8a04ebab80c33e30ec0aefc6554181a3a8416015c38da0aa118e60ec896310c + call-bind: "npm:^1.0.2" + define-properties: "npm:^1.2.0" + es-abstract: "npm:^1.22.1" + checksum: 3f0d3397ab9bd95cd98ae2fe0943bd3e7b63d333c2ab88f1875cf2e7c958c75dc3355f6fe19ee7c8fca28de6f39f2475e955e103821feb41299a2764a7463ffa languageName: node linkType: hard @@ -13507,10 +12939,10 @@ __metadata: version: 1.0.7 resolution: "string.prototype.trimstart@npm:1.0.7" dependencies: - call-bind: ^1.0.2 - define-properties: ^1.2.0 - es-abstract: ^1.22.1 - checksum: 13d0c2cb0d5ff9e926fa0bec559158b062eed2b68cd5be777ffba782c96b2b492944e47057274e064549b94dd27cf81f48b27a31fee8af5b574cff253e7eb613 + call-bind: "npm:^1.0.2" + define-properties: "npm:^1.2.0" + es-abstract: "npm:^1.22.1" + checksum: 6e594d3a61b127d243b8be1312e9f78683abe452cfe0bcafa3e0dc62ad6f030ccfb64d87ed3086fb7cb540fda62442c164d237cc5cc4d53c6e3eb659c29a0aeb languageName: node linkType: hard @@ -13518,8 +12950,8 @@ __metadata: version: 1.3.0 resolution: "string_decoder@npm:1.3.0" dependencies: - safe-buffer: ~5.2.0 - checksum: 8417646695a66e73aefc4420eb3b84cc9ffd89572861fe004e6aeb13c7bc00e2f616247505d2dbbef24247c372f70268f594af7126f43548565c68c117bdeb56 + safe-buffer: "npm:~5.2.0" + checksum: 54d23f4a6acae0e93f999a585e673be9e561b65cd4cca37714af1e893ab8cd8dfa52a9e4f58f48f87b4a44918d3a9254326cb80ed194bf2e4c226e2b21767e56 languageName: node linkType: hard @@ -13527,8 +12959,8 @@ __metadata: version: 1.1.1 resolution: "string_decoder@npm:1.1.1" dependencies: - safe-buffer: ~5.1.0 - checksum: 9ab7e56f9d60a28f2be697419917c50cac19f3e8e6c28ef26ed5f4852289fe0de5d6997d29becf59028556f2c62983790c1d9ba1e2a3cc401768ca12d5183a5b + safe-buffer: "npm:~5.1.0" + checksum: 7c41c17ed4dea105231f6df208002ebddd732e8e9e2d619d133cecd8e0087ddfd9587d2feb3c8caf3213cbd841ada6d057f5142cae68a4e62d3540778d9819b4 languageName: node linkType: hard @@ -13536,17 +12968,8 @@ __metadata: version: 6.0.1 resolution: "strip-ansi@npm:6.0.1" dependencies: - ansi-regex: ^5.0.1 - checksum: f3cd25890aef3ba6e1a74e20896c21a46f482e93df4a06567cebf2b57edabb15133f1f94e57434e0a958d61186087b1008e89c94875d019910a213181a14fc8c - languageName: node - linkType: hard - -"strip-ansi@npm:^3.0.0": - version: 3.0.1 - resolution: "strip-ansi@npm:3.0.1" - dependencies: - ansi-regex: ^2.0.0 - checksum: 9b974de611ce5075c70629c00fa98c46144043db92ae17748fb780f706f7a789e9989fd10597b7c2053ae8d1513fd707816a91f1879b2f71e6ac0b6a863db465 + ansi-regex: "npm:^5.0.1" + checksum: ae3b5436d34fadeb6096367626ce987057713c566e1e7768818797e00ac5d62023d0f198c4e681eae9e20701721980b26a64a8f5b91238869592a9c6800719a2 languageName: node linkType: hard @@ -13554,8 +12977,8 @@ __metadata: version: 7.1.0 resolution: "strip-ansi@npm:7.1.0" dependencies: - ansi-regex: ^6.0.1 - checksum: 859c73fcf27869c22a4e4d8c6acfe690064659e84bef9458aa6d13719d09ca88dcfd40cbf31fd0be63518ea1a643fe070b4827d353e09533a5b0b9fd4553d64d + ansi-regex: "npm:^6.0.1" + checksum: 475f53e9c44375d6e72807284024ac5d668ee1d06010740dec0b9744f2ddf47de8d7151f80e5f6190fc8f384e802fdf9504b76a7e9020c9faee7103623338be2 languageName: node linkType: hard @@ -13591,7 +13014,7 @@ __metadata: version: 3.0.0 resolution: "strip-indent@npm:3.0.0" dependencies: - min-indent: ^1.0.0 + min-indent: "npm:^1.0.0" checksum: 18f045d57d9d0d90cd16f72b2313d6364fd2cb4bf85b9f593523ad431c8720011a4d5f08b6591c9d580f446e78855c5334a30fb91aa1560f5d9f95ed1b4a0530 languageName: node linkType: hard @@ -13600,7 +13023,7 @@ __metadata: version: 4.0.0 resolution: "strip-indent@npm:4.0.0" dependencies: - min-indent: ^1.0.1 + min-indent: "npm:^1.0.1" checksum: 06cbcd93da721c46bc13caeb1c00af93a9b18146a1c95927672d2decab6a25ad83662772417cea9317a2507fb143253ecc23c4415b64f5828cef9b638a744598 languageName: node linkType: hard @@ -13616,8 +13039,8 @@ __metadata: version: 1.1.10 resolution: "style-to-js@npm:1.1.10" dependencies: - style-to-object: 1.0.5 - checksum: 2db181d838b0076161f1ac0959e64841efd91ad9f03d80aabd11769e88f7d979a6b4d73c59810dfb47a2e87543fb02693447a03c385dda86d8750e8cf1802d62 + style-to-object: "npm:1.0.5" + checksum: d89274efc4b59ba742f93967380e316370c8a5f0ba1d3481712d3b8d5278f3f9a496e69b95464043058b56bd8c673427da03a98b407b2147e042052f3cca001d languageName: node linkType: hard @@ -13625,24 +13048,8 @@ __metadata: version: 1.0.5 resolution: "style-to-object@npm:1.0.5" dependencies: - inline-style-parser: 0.2.2 - checksum: 6201063204b6a94645f81b189452b2ca3e63d61867ec48523f4d52609c81e96176739fa12020d97fbbf023efb57a6f7ec3a15fb3a7fb7eb3ffea0b52b9dd6b8c - languageName: node - linkType: hard - -"supports-color@npm:^2.0.0": - version: 2.0.0 - resolution: "supports-color@npm:2.0.0" - checksum: 602538c5812b9006404370b5a4b885d3e2a1f6567d314f8b4a41974ffe7d08e525bf92ae0f9c7030e3b4c78e4e34ace55d6a67a74f1571bc205959f5972f88f0 - languageName: node - linkType: hard - -"supports-color@npm:^3.2.3": - version: 3.2.3 - resolution: "supports-color@npm:3.2.3" - dependencies: - has-flag: ^1.0.0 - checksum: 56afc05fa87d00100d90148c4d0a6e20a0af0d56dca5c54d4d40b2553ee737dab0ca4e8b53c4471afc035227b5b44dfa4824747a7f01ad733173536f7da6fbbb + inline-style-parser: "npm:0.2.2" + checksum: 8bedb6aa2e4e82b675cc414fa3436017fbfbf689f9ce3efc76bfc9d75fbe105bea08afc2f9cca1beee73f016e4847712789847efd888ae2cce915af74085e76b languageName: node linkType: hard @@ -13650,8 +13057,8 @@ __metadata: version: 5.5.0 resolution: "supports-color@npm:5.5.0" dependencies: - has-flag: ^3.0.0 - checksum: 95f6f4ba5afdf92f495b5a912d4abee8dcba766ae719b975c56c084f5004845f6f5a5f7769f52d53f40e21952a6d87411bafe34af4a01e65f9926002e38e1dac + has-flag: "npm:^3.0.0" + checksum: 5f505c6fa3c6e05873b43af096ddeb22159831597649881aeb8572d6fe3b81e798cc10840d0c9735e0026b250368851b7f77b65e84f4e4daa820a4f69947f55b languageName: node linkType: hard @@ -13659,8 +13066,8 @@ __metadata: version: 7.2.0 resolution: "supports-color@npm:7.2.0" dependencies: - has-flag: ^4.0.0 - checksum: 3dda818de06ebbe5b9653e07842d9479f3555ebc77e9a0280caf5a14fb877ffee9ed57007c3b78f5a6324b8dbeec648d9e97a24e2ed9fdb81ddc69ea07100f4a + has-flag: "npm:^4.0.0" + checksum: c8bb7afd564e3b26b50ca6ee47572c217526a1389fe018d00345856d4a9b08ffbd61fadaf283a87368d94c3dcdb8f5ffe2650a5a65863e21ad2730ca0f05210a languageName: node linkType: hard @@ -13668,43 +13075,49 @@ __metadata: version: 8.1.1 resolution: "supports-color@npm:8.1.1" dependencies: - has-flag: ^4.0.0 - checksum: c052193a7e43c6cdc741eb7f378df605636e01ad434badf7324f17fb60c69a880d8d8fcdcb562cf94c2350e57b937d7425ab5b8326c67c2adc48f7c87c1db406 + has-flag: "npm:^4.0.0" + checksum: 157b534df88e39c5518c5e78c35580c1eca848d7dbaf31bbe06cdfc048e22c7ff1a9d046ae17b25691128f631a51d9ec373c1b740c12ae4f0de6e292037e4282 languageName: node linkType: hard "supports-preserve-symlinks-flag@npm:^1.0.0": version: 1.0.0 resolution: "supports-preserve-symlinks-flag@npm:1.0.0" - checksum: 53b1e247e68e05db7b3808b99b892bd36fb096e6fba213a06da7fab22045e97597db425c724f2bbd6c99a3c295e1e73f3e4de78592289f38431049e1277ca0ae + checksum: a9dc19ae2220c952bd2231d08ddeecb1b0328b61e72071ff4000c8384e145cc07c1c0bdb3b5a1cb06e186a7b2790f1dee793418b332f6ddf320de25d9125be7e languageName: node linkType: hard -"svg-baker@npm:~1.7.0": - version: 1.7.0 - resolution: "svg-baker@npm:1.7.0" - dependencies: - bluebird: ^3.5.0 - clone: ^2.1.1 - he: ^1.1.1 - image-size: ^0.5.1 - loader-utils: ^1.1.0 - merge-options: 1.0.1 - micromatch: 3.1.0 - postcss: ^5.2.17 - postcss-prefix-selector: ^1.6.0 - posthtml-rename-id: ^1.0 - posthtml-svg-mode: ^1.0.3 - query-string: ^4.3.2 - traverse: ^0.6.6 - checksum: 06724dd6cd098016a11a778cfa2771defac6bdcdee1e4c61669aa32ebfa52815d66cea989ea1f1d31b440634ceda54637e2d7d57687925b0d256adf9ba176b50 +"svg-parser@npm:^2.0.4": + version: 2.0.4 + resolution: "svg-parser@npm:2.0.4" + checksum: ec196da6ea21481868ab26911970e35488361c39ead1c6cdd977ba16c885c21a91ddcbfd113bfb01f79a822e2a751ef85b2f7f95e2cb9245558ebce12c34af1f languageName: node linkType: hard -"svg-parser@npm:^2.0.4": +"svg-sprite@npm:^2.0.2": version: 2.0.4 - resolution: "svg-parser@npm:2.0.4" - checksum: b3de6653048212f2ae7afe4a423e04a76ec6d2d06e1bf7eacc618a7c5f7df7faa5105561c57b94579ec831fbbdbf5f190ba56a9205ff39ed13eabdf8ab086ddf + resolution: "svg-sprite@npm:2.0.4" + dependencies: + "@resvg/resvg-js": "npm:^2.6.0" + "@xmldom/xmldom": "npm:^0.8.10" + async: "npm:^3.2.5" + css-selector-parser: "npm:^1.4.1" + csso: "npm:^4.2.0" + cssom: "npm:^0.5.0" + glob: "npm:^7.2.3" + js-yaml: "npm:^4.1.0" + lodash.escape: "npm:^4.0.1" + lodash.merge: "npm:^4.6.2" + mustache: "npm:^4.2.0" + prettysize: "npm:^2.0.0" + svgo: "npm:^2.8.0" + vinyl: "npm:^2.2.1" + winston: "npm:^3.11.0" + xpath: "npm:^0.0.34" + yargs: "npm:^17.7.2" + bin: + svg-sprite: bin/svg-sprite.js + checksum: ddcffb23af23d6ddc37a0f5e7e57838c578f37efd8ddb377112a8abba1c5ebc17e24cffc1279720509895cef79db455e2dfd1439ea0f42fc45df38329000faa3 languageName: node linkType: hard @@ -13712,30 +13125,30 @@ __metadata: version: 2.8.0 resolution: "svgo@npm:2.8.0" dependencies: - "@trysound/sax": 0.2.0 - commander: ^7.2.0 - css-select: ^4.1.3 - css-tree: ^1.1.3 - csso: ^4.2.0 - picocolors: ^1.0.0 - stable: ^0.1.8 + "@trysound/sax": "npm:0.2.0" + commander: "npm:^7.2.0" + css-select: "npm:^4.1.3" + css-tree: "npm:^1.1.3" + csso: "npm:^4.2.0" + picocolors: "npm:^1.0.0" + stable: "npm:^0.1.8" bin: svgo: bin/svgo - checksum: b92f71a8541468ffd0b81b8cdb36b1e242eea320bf3c1a9b2c8809945853e9d8c80c19744267eb91cabf06ae9d5fff3592d677df85a31be4ed59ff78534fa420 + checksum: 2b74544da1a9521852fe2784252d6083b336e32528d0e424ee54d1613f17312edc7020c29fa399086560e96cba42ede4a2205328a08edeefa26de84cd769a64a languageName: node linkType: hard "symbol-tree@npm:^3.2.4": version: 3.2.4 resolution: "symbol-tree@npm:3.2.4" - checksum: 6e8fc7e1486b8b54bea91199d9535bb72f10842e40c79e882fc94fb7b14b89866adf2fd79efa5ebb5b658bc07fb459ccce5ac0e99ef3d72f474e74aaf284029d + checksum: c09a00aadf279d47d0c5c46ca3b6b2fbaeb45f0a184976d599637d412d3a70bbdc043ff33effe1206dea0e36e0ad226cb957112e7ce9a4bf2daedf7fa4f85c53 languageName: node linkType: hard "synchronous-promise@npm:^2.0.15": version: 2.0.17 resolution: "synchronous-promise@npm:2.0.17" - checksum: 7b1342c93741f3f92ebde1edf5d6ce8dde2278de948d84e9bd85e232c16c0d77c90c4940f9975be3effcb20f047cfb0f16fa311c3b4e092c22f3bf2889fb0fb4 + checksum: dd74b1c05caab8ea34e26c8b52a0966efd70b0229ad39447ce066501dd6931d4d97a3f88b0f306880a699660cd334180a24d9738b385aed0bd0104a5be207ec1 languageName: node linkType: hard @@ -13743,16 +13156,16 @@ __metadata: version: 0.8.8 resolution: "synckit@npm:0.8.8" dependencies: - "@pkgr/core": ^0.1.0 - tslib: ^2.6.2 - checksum: 9ed5d33abb785f5f24e2531efd53b2782ca77abf7912f734d170134552b99001915531be5a50297aa45c5701b5c9041e8762e6cd7a38e41e2461c1e7fccdedf8 + "@pkgr/core": "npm:^0.1.0" + tslib: "npm:^2.6.2" + checksum: 2864a5c3e689ad5b991bebbd8a583c5682c4fa08a4f39986b510b6b5d160c08fc3672444069f8f96ed6a9d12772879c674c1f61e728573eadfa90af40a765b74 languageName: node linkType: hard "tabbable@npm:^6.0.1": version: 6.2.0 resolution: "tabbable@npm:6.2.0" - checksum: f8440277d223949272c74bb627a3371be21735ca9ad34c2570f7e1752bd646ccfc23a9d8b1ee65d6561243f4134f5fbbf1ad6b39ac3c4b586554accaff4a1300 + checksum: 980fa73476026e99dcacfc0d6e000d41d42c8e670faf4682496d30c625495e412c4369694f2a15cf1e5252d22de3c396f2b62edbe8d60b5dadc40d09e3f2dde3 languageName: node linkType: hard @@ -13760,11 +13173,11 @@ __metadata: version: 2.1.1 resolution: "tar-fs@npm:2.1.1" dependencies: - chownr: ^1.1.1 - mkdirp-classic: ^0.5.2 - pump: ^3.0.0 - tar-stream: ^2.1.4 - checksum: f5b9a70059f5b2969e65f037b4e4da2daf0fa762d3d232ffd96e819e3f94665dbbbe62f76f084f1acb4dbdcce16c6e4dac08d12ffc6d24b8d76720f4d9cf032d + chownr: "npm:^1.1.1" + mkdirp-classic: "npm:^0.5.2" + pump: "npm:^3.0.0" + tar-stream: "npm:^2.1.4" + checksum: 526deae025453e825f87650808969662fbb12eb0461d033e9b447de60ec951c6c4607d0afe7ce057defe9d4e45cf80399dd74bc15f9d9e0773d5e990a78ce4ac languageName: node linkType: hard @@ -13772,12 +13185,12 @@ __metadata: version: 2.2.0 resolution: "tar-stream@npm:2.2.0" dependencies: - bl: ^4.0.3 - end-of-stream: ^1.4.1 - fs-constants: ^1.0.0 - inherits: ^2.0.3 - readable-stream: ^3.1.1 - checksum: 699831a8b97666ef50021c767f84924cfee21c142c2eb0e79c63254e140e6408d6d55a065a2992548e72b06de39237ef2b802b99e3ece93ca3904a37622a66f3 + bl: "npm:^4.0.3" + end-of-stream: "npm:^1.4.1" + fs-constants: "npm:^1.0.0" + inherits: "npm:^2.0.3" + readable-stream: "npm:^3.1.1" + checksum: 1a52a51d240c118cbcd30f7368ea5e5baef1eac3e6b793fb1a41e6cd7319296c79c0264ccc5859f5294aa80f8f00b9239d519e627b9aade80038de6f966fec6a languageName: node linkType: hard @@ -13785,13 +13198,13 @@ __metadata: version: 6.2.0 resolution: "tar@npm:6.2.0" dependencies: - chownr: ^2.0.0 - fs-minipass: ^2.0.0 - minipass: ^5.0.0 - minizlib: ^2.1.1 - mkdirp: ^1.0.3 - yallist: ^4.0.0 - checksum: db4d9fe74a2082c3a5016630092c54c8375ff3b280186938cfd104f2e089c4fd9bad58688ef6be9cf186a889671bf355c7cda38f09bbf60604b281715ca57f5c + chownr: "npm:^2.0.0" + fs-minipass: "npm:^2.0.0" + minipass: "npm:^5.0.0" + minizlib: "npm:^2.1.1" + mkdirp: "npm:^1.0.3" + yallist: "npm:^4.0.0" + checksum: 2042bbb14830b5cd0d584007db0eb0a7e933e66d1397e72a4293768d2332449bc3e312c266a0887ec20156dea388d8965e53b4fc5097f42d78593549016da089 languageName: node linkType: hard @@ -13799,8 +13212,8 @@ __metadata: version: 7.2.0 resolution: "telejson@npm:7.2.0" dependencies: - memoizerific: ^1.11.3 - checksum: 55a3380c9ff3c5ad84581bb6bda28fc33c6b7c4a0c466894637da687639b8db0d21b0ff4c1bc1a7a92ae6b70662549d09e7b9e8b1ec334b2ef93078762ecdfb9 + memoizerific: "npm:^1.11.3" + checksum: 6e89b3d3c45b5a2aced9132f6a968fcdf758c00be4c3acb115d7d81e95c9e04083a7a4a9b43057fcf48b101156c1607a38f5491615956acb28d4d1f78a4bda20 languageName: node linkType: hard @@ -13815,8 +13228,8 @@ __metadata: version: 0.8.4 resolution: "temp@npm:0.8.4" dependencies: - rimraf: ~2.6.2 - checksum: f35bed78565355dfdf95f730b7b489728bd6b7e35071bcc6497af7c827fb6c111fbe9063afc7b8cbc19522a072c278679f9a0ee81e684aa2c8617cc0f2e9c191 + rimraf: "npm:~2.6.2" + checksum: 0a7f76b49637415bc391c3f6e69377cc4c38afac95132b4158fa711e77b70b082fe56fd886f9d11ffab9d148df181a105a93c8b618fb72266eeaa5e5ddbfe37f languageName: node linkType: hard @@ -13824,12 +13237,12 @@ __metadata: version: 1.0.1 resolution: "tempy@npm:1.0.1" dependencies: - del: ^6.0.0 - is-stream: ^2.0.0 - temp-dir: ^2.0.0 - type-fest: ^0.16.0 - unique-string: ^2.0.0 - checksum: e77ca4440af18e42dc64d8903b7ed0be673455b76680ff94a7d7c6ee7c16f7604bdcdee3c39436342b1082c23eda010dbe48f6094e836e0bd53c8b1aa63e5b95 + del: "npm:^6.0.0" + is-stream: "npm:^2.0.0" + temp-dir: "npm:^2.0.0" + type-fest: "npm:^0.16.0" + unique-string: "npm:^2.0.0" + checksum: e3a3857cd102db84c484b8e878203b496f0e927025b7c60dd118c0c9a0962f4589321c6b3093185d529576af5c58be65d755e72c2a6ad009ff340ab8cbbe4d33 languageName: node linkType: hard @@ -13837,17 +13250,24 @@ __metadata: version: 6.0.0 resolution: "test-exclude@npm:6.0.0" dependencies: - "@istanbuljs/schema": ^0.1.2 - glob: ^7.1.4 - minimatch: ^3.0.4 - checksum: 3b34a3d77165a2cb82b34014b3aba93b1c4637a5011807557dc2f3da826c59975a5ccad765721c4648b39817e3472789f9b0fa98fc854c5c1c7a1e632aacdc28 + "@istanbuljs/schema": "npm:^0.1.2" + glob: "npm:^7.1.4" + minimatch: "npm:^3.0.4" + checksum: 8fccb2cb6c8fcb6bb4115394feb833f8b6cf4b9503ec2485c2c90febf435cac62abe882a0c5c51a37b9bbe70640cdd05acf5f45e486ac4583389f4b0855f69e5 + languageName: node + linkType: hard + +"text-hex@npm:1.0.x": + version: 1.0.0 + resolution: "text-hex@npm:1.0.0" + checksum: 1138f68adc97bf4381a302a24e2352f04992b7b1316c5003767e9b0d3367ffd0dc73d65001ea02b07cd0ecc2a9d186de0cf02f3c2d880b8a522d4ccb9342244a languageName: node linkType: hard "text-table@npm:^0.2.0": version: 0.2.0 resolution: "text-table@npm:0.2.0" - checksum: b6937a38c80c7f84d9c11dd75e49d5c44f71d95e810a3250bd1f1797fc7117c57698204adf676b71497acc205d769d65c16ae8fa10afad832ae1322630aef10a + checksum: 4383b5baaeffa9bb4cda2ac33a4aa2e6d1f8aaf811848bf73513a9b88fd76372dc461f6fd6d2e9cb5100f48b473be32c6f95bd983509b7d92bb4d92c10747452 languageName: node linkType: hard @@ -13855,9 +13275,9 @@ __metadata: version: 2.0.5 resolution: "through2@npm:2.0.5" dependencies: - readable-stream: ~2.3.6 - xtend: ~4.0.1 - checksum: beb0f338aa2931e5660ec7bf3ad949e6d2e068c31f4737b9525e5201b824ac40cac6a337224856b56bd1ddd866334bbfb92a9f57cd6f66bc3f18d3d86fc0fe50 + readable-stream: "npm:~2.3.6" + xtend: "npm:~4.0.1" + checksum: cd71f7dcdc7a8204fea003a14a433ef99384b7d4e31f5497e1f9f622b3cf3be3691f908455f98723bdc80922a53af7fa10c3b7abbe51c6fd3d536dbc7850e2c4 languageName: node linkType: hard @@ -13889,57 +13309,26 @@ __metadata: languageName: node linkType: hard -"to-object-path@npm:^0.3.0": - version: 0.3.0 - resolution: "to-object-path@npm:0.3.0" - dependencies: - kind-of: ^3.0.2 - checksum: 9425effee5b43e61d720940fa2b889623f77473d459c2ce3d4a580a4405df4403eec7be6b857455908070566352f9e2417304641ed158dda6f6a365fe3e66d70 - languageName: node - linkType: hard - -"to-regex-range@npm:^2.1.0": - version: 2.1.1 - resolution: "to-regex-range@npm:2.1.1" - dependencies: - is-number: ^3.0.0 - repeat-string: ^1.6.1 - checksum: 46093cc14be2da905cc931e442d280b2e544e2bfdb9a24b3cf821be8d342f804785e5736c108d5be026021a05d7b38144980a61917eee3c88de0a5e710e10320 - languageName: node - linkType: hard - "to-regex-range@npm:^5.0.1": version: 5.0.1 resolution: "to-regex-range@npm:5.0.1" dependencies: - is-number: ^7.0.0 - checksum: f76fa01b3d5be85db6a2a143e24df9f60dd047d151062d0ba3df62953f2f697b16fe5dad9b0ac6191c7efc7b1d9dcaa4b768174b7b29da89d4428e64bc0a20ed - languageName: node - linkType: hard - -"to-regex@npm:^3.0.1": - version: 3.0.2 - resolution: "to-regex@npm:3.0.2" - dependencies: - define-property: ^2.0.2 - extend-shallow: ^3.0.2 - regex-not: ^1.0.2 - safe-regex: ^1.1.0 - checksum: 4ed4a619059b64e204aad84e4e5f3ea82d97410988bcece7cf6cbfdbf193d11bff48cf53842d88b8bb00b1bfc0d048f61f20f0709e6f393fd8fe0122662d9db4 + is-number: "npm:^7.0.0" + checksum: 10dda13571e1f5ad37546827e9b6d4252d2e0bc176c24a101252153ef435d83696e2557fe128c4678e4e78f5f01e83711c703eef9814eb12dab028580d45980a languageName: node linkType: hard "tocbot@npm:^4.20.1": version: 4.25.0 resolution: "tocbot@npm:4.25.0" - checksum: ac382063526ae8cde93390e42761da7aac136a452f5109ddcbcaf0d1890de478a13d2cf01ef52ec2a50934e4fa10f1d4237109cccfa651ba8c5021638528b982 + checksum: fcbe6299ec26322f51e62d54d1281b31370efab89b7a7e58c90fa431a51548e1a09b8aafd7314ed2500694bee8451713f59ecddafa7242e6bf626134b0e3cce6 languageName: node linkType: hard "toggle-selection@npm:^1.0.6": version: 1.0.6 resolution: "toggle-selection@npm:1.0.6" - checksum: a90dc80ed1e7b18db8f4e16e86a5574f87632dc729cfc07d9ea3ced50021ad42bb4e08f22c0913e0b98e3837b0b717e0a51613c65f30418e21eb99da6556a74c + checksum: 9a0ed0ecbaac72b4944888dacd79fe0a55eeea76120a4c7e46b3bb3d85b24f086e90560bb22f5a965654a25ab43d79ec47dfdb3f1850ba740b14c5a50abc7040 languageName: node linkType: hard @@ -13954,11 +13343,11 @@ __metadata: version: 4.1.3 resolution: "tough-cookie@npm:4.1.3" dependencies: - psl: ^1.1.33 - punycode: ^2.1.1 - universalify: ^0.2.0 - url-parse: ^1.5.3 - checksum: c9226afff36492a52118432611af083d1d8493a53ff41ec4ea48e5b583aec744b989e4280bcf476c910ec1525a89a4a0f1cae81c08b18fb2ec3a9b3a72b91dcc + psl: "npm:^1.1.33" + punycode: "npm:^2.1.1" + universalify: "npm:^0.2.0" + url-parse: "npm:^1.5.3" + checksum: cf148c359b638a7069fc3ba9a5257bdc9616a6948a98736b92c3570b3f8401cf9237a42bf716878b656f372a1fb65b74dd13a46ccff8eceba14ffd053d33f72a languageName: node linkType: hard @@ -13966,22 +13355,22 @@ __metadata: version: 3.0.0 resolution: "tr46@npm:3.0.0" dependencies: - punycode: ^2.1.1 - checksum: 44c3cc6767fb800490e6e9fd64fd49041aa4e49e1f6a012b34a75de739cc9ed3a6405296072c1df8b6389ae139c5e7c6496f659cfe13a04a4bff3a1422981270 + punycode: "npm:^2.1.1" + checksum: b09a15886cbfaee419a3469081223489051ce9dca3374dd9500d2378adedbee84a3c73f83bfdd6bb13d53657753fc0d4e20a46bfcd3f1b9057ef528426ad7ce4 languageName: node linkType: hard "tr46@npm:~0.0.3": version: 0.0.3 resolution: "tr46@npm:0.0.3" - checksum: 726321c5eaf41b5002e17ffbd1fb7245999a073e8979085dacd47c4b4e8068ff5777142fc6726d6ca1fd2ff16921b48788b87225cbc57c72636f6efa8efbffe3 + checksum: 8f1f5aa6cb232f9e1bdc86f485f916b7aa38caee8a778b378ffec0b70d9307873f253f5cbadbe2955ece2ac5c83d0dc14a77513166ccd0a0c7fe197e21396695 languageName: node linkType: hard -"traverse@npm:^0.6.6": - version: 0.6.8 - resolution: "traverse@npm:0.6.8" - checksum: ef22abfc73fe2052403093b6747febbfeb52dcf827db1ca0542a78932c918706b9b12c373ef27e1c3e07e3e92eb1c646b4fe97b936fe775d59cbce7da417e13b +"triple-beam@npm:^1.3.0": + version: 1.4.1 + resolution: "triple-beam@npm:1.4.1" + checksum: 2e881a3e8e076b6f2b85b9ec9dd4a900d3f5016e6d21183ed98e78f9abcc0149e7d54d79a3f432b23afde46b0885bdcdcbff789f39bc75de796316961ec07f61 languageName: node linkType: hard @@ -13990,7 +13379,7 @@ __metadata: resolution: "ts-api-utils@npm:1.2.1" peerDependencies: typescript: ">=4.2.0" - checksum: 17a2a4454d65a6765b9351304cfd516fcda3098f49d72bba90cb7f22b6a09a573b4a1993fd7de7d6b8046c408960c5f21a25e64ccb969d484b32ea3b3e19d6e4 + checksum: 6d7f60fd01e3885bb334607f22b9cb1002e72da81dad2e672fef1b0d1a2f640b0f0ff5310369401488fac90c7a7f5d39c89fd18789af59c672c9b5aef4cade3e languageName: node linkType: hard @@ -14011,7 +13400,7 @@ __metadata: optional: true bin: tsconfck: bin/tsconfck.js - checksum: 6fd2f7de012a724f6b4bf48ae76cc7dae2b59dd5cad2dc50bac58d224d4ed7d5c43c6b26e55d3e00636f426f8b5373c996523d73b7830d05f8479a9b83282192 + checksum: 61df3b03b334a25eabb0a52e67a0c8d85770c631f2739db7703af8fdd102a2ebd598f1c851cc5fc6d6a59f2497a26c845be71c934ea16d838a3ff95a885034fb languageName: node linkType: hard @@ -14019,25 +13408,25 @@ __metadata: version: 3.15.0 resolution: "tsconfig-paths@npm:3.15.0" dependencies: - "@types/json5": ^0.0.29 - json5: ^1.0.2 - minimist: ^1.2.6 - strip-bom: ^3.0.0 - checksum: 59f35407a390d9482b320451f52a411a256a130ff0e7543d18c6f20afab29ac19fbe55c360a93d6476213cc335a4d76ce90f67df54c4e9037f7d240920832201 + "@types/json5": "npm:^0.0.29" + json5: "npm:^1.0.2" + minimist: "npm:^1.2.6" + strip-bom: "npm:^3.0.0" + checksum: 2041beaedc6c271fc3bedd12e0da0cc553e65d030d4ff26044b771fac5752d0460944c0b5e680f670c2868c95c664a256cec960ae528888db6ded83524e33a14 languageName: node linkType: hard "tslib@npm:^1.13.0, tslib@npm:^1.8.1": version: 1.14.1 resolution: "tslib@npm:1.14.1" - checksum: dbe628ef87f66691d5d2959b3e41b9ca0045c3ee3c7c7b906cc1e328b39f199bb1ad9e671c39025bd56122ac57dfbf7385a94843b1cc07c60a4db74795829acd + checksum: 7dbf34e6f55c6492637adb81b555af5e3b4f9cc6b998fb440dac82d3b42bdc91560a35a5fb75e20e24a076c651438234da6743d139e4feabf0783f3cdfe1dddb languageName: node linkType: hard "tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2": version: 2.6.2 resolution: "tslib@npm:2.6.2" - checksum: 329ea56123005922f39642318e3d1f0f8265d1e7fcb92c633e0809521da75eeaca28d2cf96d7248229deb40e5c19adf408259f4b9640afd20d13aecc1430f3ad + checksum: bd26c22d36736513980091a1e356378e8b662ded04204453d353a7f34a4c21ed0afc59b5f90719d4ba756e581a162ecbf93118dc9c6be5acf70aa309188166ca languageName: node linkType: hard @@ -14045,10 +13434,10 @@ __metadata: version: 3.21.0 resolution: "tsutils@npm:3.21.0" dependencies: - tslib: ^1.8.1 + tslib: "npm:^1.8.1" peerDependencies: typescript: ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" - checksum: 1843f4c1b2e0f975e08c4c21caa4af4f7f65a12ac1b81b3b8489366826259323feb3fc7a243123453d2d1a02314205a7634e048d4a8009921da19f99755cdc48 + checksum: ea036bec1dd024e309939ffd49fda7a351c0e87a1b8eb049570dd119d447250e2c56e0e6c00554e8205760e7417793fdebff752a46e573fbe07d4f375502a5b2 languageName: node linkType: hard @@ -14056,57 +13445,57 @@ __metadata: version: 0.4.0 resolution: "type-check@npm:0.4.0" dependencies: - prelude-ls: ^1.2.1 - checksum: ec688ebfc9c45d0c30412e41ca9c0cdbd704580eb3a9ccf07b9b576094d7b86a012baebc95681999dd38f4f444afd28504cb3a89f2ef16b31d4ab61a0739025a + prelude-ls: "npm:^1.2.1" + checksum: 14687776479d048e3c1dbfe58a2409e00367810d6960c0f619b33793271ff2a27f81b52461f14a162f1f89a9b1d8da1b237fc7c99b0e1fdcec28ec63a86b1fec languageName: node linkType: hard "type-detect@npm:4.0.8": version: 4.0.8 resolution: "type-detect@npm:4.0.8" - checksum: 62b5628bff67c0eb0b66afa371bd73e230399a8d2ad30d852716efcc4656a7516904570cd8631a49a3ce57c10225adf5d0cbdcb47f6b0255fe6557c453925a15 + checksum: 5179e3b8ebc51fce1b13efb75fdea4595484433f9683bbc2dca6d99789dba4e602ab7922d2656f2ce8383987467f7770131d4a7f06a26287db0615d2f4c4ce7d languageName: node linkType: hard "type-fest@npm:^0.16.0": version: 0.16.0 resolution: "type-fest@npm:0.16.0" - checksum: 1a4102c06dc109db00418c753062e206cab65befd469d000ece4452ee649bf2a9cf57686d96fb42326bc9d918d9a194d4452897b486dcc41989e5c99e4e87094 + checksum: fd8c47ccb90e9fe7bae8bfc0e116e200e096120200c1ab1737bf0bc9334b344dd4925f876ed698174ffd58cd179bb56a55467be96aedc22d5d72748eac428bc8 languageName: node linkType: hard "type-fest@npm:^0.20.2": version: 0.20.2 resolution: "type-fest@npm:0.20.2" - checksum: 4fb3272df21ad1c552486f8a2f8e115c09a521ad7a8db3d56d53718d0c907b62c6e9141ba5f584af3f6830d0872c521357e512381f24f7c44acae583ad517d73 + checksum: 8907e16284b2d6cfa4f4817e93520121941baba36b39219ea36acfe64c86b9dbc10c9941af450bd60832c8f43464974d51c0957f9858bc66b952b66b6914cbb9 languageName: node linkType: hard "type-fest@npm:^0.21.3": version: 0.21.3 resolution: "type-fest@npm:0.21.3" - checksum: e6b32a3b3877f04339bae01c193b273c62ba7bfc9e325b8703c4ee1b32dc8fe4ef5dfa54bf78265e069f7667d058e360ae0f37be5af9f153b22382cd55a9afe0 + checksum: f4254070d9c3d83a6e573bcb95173008d73474ceadbbf620dd32d273940ca18734dff39c2b2480282df9afe5d1675ebed5499a00d791758748ea81f61a38961f languageName: node linkType: hard "type-fest@npm:^0.6.0": version: 0.6.0 resolution: "type-fest@npm:0.6.0" - checksum: b2188e6e4b21557f6e92960ec496d28a51d68658018cba8b597bd3ef757721d1db309f120ae987abeeda874511d14b776157ff809f23c6d1ce8f83b9b2b7d60f + checksum: 9ecbf4ba279402b14c1a0614b6761bbe95626fab11377291fecd7e32b196109551e0350dcec6af74d97ced1b000ba8060a23eca33157091e642b409c2054ba82 languageName: node linkType: hard "type-fest@npm:^0.8.1": version: 0.8.1 resolution: "type-fest@npm:0.8.1" - checksum: d61c4b2eba24009033ae4500d7d818a94fd6d1b481a8111612ee141400d5f1db46f199c014766b9fa9b31a6a7374d96fc748c6d688a78a3ce5a33123839becb7 + checksum: fd4a91bfb706aeeb0d326ebd2e9a8ea5263979e5dec8d16c3e469a5bd3a946e014a062ef76c02e3086d3d1c7209a56a20a4caafd0e9f9a5c2ab975084ea3d388 languageName: node linkType: hard "type-fest@npm:^2.19.0, type-fest@npm:~2.19": version: 2.19.0 resolution: "type-fest@npm:2.19.0" - checksum: a4ef07ece297c9fba78fc1bd6d85dff4472fe043ede98bd4710d2615d15776902b595abf62bd78339ed6278f021235fb28a96361f8be86ed754f778973a0d278 + checksum: 7bf9e8fdf34f92c8bb364c0af14ca875fac7e0183f2985498b77be129dc1b3b1ad0a6b3281580f19e48c6105c037fb966ad9934520c69c6434d17fd0af4eed78 languageName: node linkType: hard @@ -14114,9 +13503,9 @@ __metadata: version: 1.6.18 resolution: "type-is@npm:1.6.18" dependencies: - media-typer: 0.3.0 - mime-types: ~2.1.24 - checksum: 2c8e47675d55f8b4e404bcf529abdf5036c537a04c2b20177bcf78c9e3c1da69da3942b1346e6edb09e823228c0ee656ef0e033765ec39a70d496ef601a0c657 + media-typer: "npm:0.3.0" + mime-types: "npm:~2.1.24" + checksum: 0bd9eeae5efd27d98fd63519f999908c009e148039d8e7179a074f105362d4fcc214c38b24f6cda79c87e563cbd12083a4691381ed28559220d4a10c2047bed4 languageName: node linkType: hard @@ -14124,9 +13513,9 @@ __metadata: version: 1.0.2 resolution: "typed-array-buffer@npm:1.0.2" dependencies: - call-bind: ^1.0.7 - es-errors: ^1.3.0 - is-typed-array: ^1.1.13 + call-bind: "npm:^1.0.7" + es-errors: "npm:^1.3.0" + is-typed-array: "npm:^1.1.13" checksum: 02ffc185d29c6df07968272b15d5319a1610817916ec8d4cd670ded5d1efe72901541ff2202fcc622730d8a549c76e198a2f74e312eabbfb712ed907d45cbb0b languageName: node linkType: hard @@ -14135,12 +13524,12 @@ __metadata: version: 1.0.1 resolution: "typed-array-byte-length@npm:1.0.1" dependencies: - call-bind: ^1.0.7 - for-each: ^0.3.3 - gopd: ^1.0.1 - has-proto: ^1.0.3 - is-typed-array: ^1.1.13 - checksum: f65e5ecd1cf76b1a2d0d6f631f3ea3cdb5e08da106c6703ffe687d583e49954d570cc80434816d3746e18be889ffe53c58bf3e538081ea4077c26a41055b216d + call-bind: "npm:^1.0.7" + for-each: "npm:^0.3.3" + gopd: "npm:^1.0.1" + has-proto: "npm:^1.0.3" + is-typed-array: "npm:^1.1.13" + checksum: e4a38329736fe6a73b52a09222d4a9e8de14caaa4ff6ad8e55217f6705b017d9815b7284c85065b3b8a7704e226ccff1372a72b78c2a5b6b71b7bf662308c903 languageName: node linkType: hard @@ -14148,13 +13537,13 @@ __metadata: version: 1.0.2 resolution: "typed-array-byte-offset@npm:1.0.2" dependencies: - available-typed-arrays: ^1.0.7 - call-bind: ^1.0.7 - for-each: ^0.3.3 - gopd: ^1.0.1 - has-proto: ^1.0.3 - is-typed-array: ^1.1.13 - checksum: c8645c8794a621a0adcc142e0e2c57b1823bbfa4d590ad2c76b266aa3823895cf7afb9a893bf6685e18454ab1b0241e1a8d885a2d1340948efa4b56add4b5f67 + available-typed-arrays: "npm:^1.0.7" + call-bind: "npm:^1.0.7" + for-each: "npm:^0.3.3" + gopd: "npm:^1.0.1" + has-proto: "npm:^1.0.3" + is-typed-array: "npm:^1.1.13" + checksum: ac26d720ebb2aacbc45e231347c359e6649f52e0cfe0e76e62005912f8030d68e4cb7b725b1754e8fdd48e433cb68df5a8620a3e420ad1457d666e8b29bf9150 languageName: node linkType: hard @@ -14162,20 +13551,20 @@ __metadata: version: 1.0.5 resolution: "typed-array-length@npm:1.0.5" dependencies: - call-bind: ^1.0.7 - for-each: ^0.3.3 - gopd: ^1.0.1 - has-proto: ^1.0.3 - is-typed-array: ^1.1.13 - possible-typed-array-names: ^1.0.0 - checksum: 82f5b666155cff1b345a1f3ab018d3f7667990f525435e4c8448cc094ab0f8ea283bb7cbde4d7bc82ea0b9b1072523bf31e86620d72615951d7fa9ccb4f42dfa + call-bind: "npm:^1.0.7" + for-each: "npm:^0.3.3" + gopd: "npm:^1.0.1" + has-proto: "npm:^1.0.3" + is-typed-array: "npm:^1.1.13" + possible-typed-array-names: "npm:^1.0.0" + checksum: f9a0da99c41880b44e2c5e5d0d01515c2a6e0f54b10c594151804f013272d837df3b67ea84d7304ecfbab2c10d99c3372168bf3a4bd295abf13ac5a72f93054a languageName: node linkType: hard "typedarray@npm:^0.0.6": version: 0.0.6 resolution: "typedarray@npm:0.0.6" - checksum: 33b39f3d0e8463985eeaeeacc3cb2e28bc3dfaf2a5ed219628c0b629d5d7b810b0eb2165f9f607c34871d5daa92ba1dc69f49051cf7d578b4cbd26c340b9d1b1 + checksum: 2cc1bcf7d8c1237f6a16c04efc06637b2c5f2d74e58e84665445cf87668b85a21ab18dd751fa49eee6ae024b70326635d7b79ad37b1c370ed2fec6aeeeb52714 languageName: node linkType: hard @@ -14185,24 +13574,24 @@ __metadata: bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 7912821dac4d962d315c36800fe387cdc0a6298dba7ec171b350b4a6e988b51d7b8f051317786db1094bd7431d526b648aba7da8236607febb26cf5b871d2d3c + checksum: d65e50eb849bd21ff8677e5b9447f9c6e74777e346afd67754934264dcbf4bd59e7d2473f6062d9a015d66bd573311166357e3eb07fea0b52859cf9bb2b58555 languageName: node linkType: hard -"typescript@patch:typescript@5.2.2#~builtin": +"typescript@patch:typescript@npm%3A5.2.2#optional!builtin": version: 5.2.2 - resolution: "typescript@patch:typescript@npm%3A5.2.2#~builtin::version=5.2.2&hash=f3b441" + resolution: "typescript@patch:typescript@npm%3A5.2.2#optional!builtin::version=5.2.2&hash=f3b441" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 0f4da2f15e6f1245e49db15801dbee52f2bbfb267e1c39225afdab5afee1a72839cd86000e65ee9d7e4dfaff12239d28beaf5ee431357fcced15fb08583d72ca + checksum: f79cc2ba802c94c2b78dbb00d767a10adb67368ae764709737dc277273ec148aa4558033a03ce901406b35fddf4eac46dabc94a1e1d12d2587e2b9cfe5707b4a languageName: node linkType: hard "ufo@npm:^1.3.2": version: 1.4.0 resolution: "ufo@npm:1.4.0" - checksum: 7c7ca3d823ae56a0439bc7038116a26a8c4e95aa9252aef43091f08f104af5557c2d220d990d07891c2771ca7c0589c479e330737ce6d7bbee485bb031046f19 + checksum: b7aea8503878dc5ad797d8fc6fe39fec64d9cc7e89fb147ef86ec676e37bb462d99d67c6aad20b15f7d3e6d275d66666b29214422e268f1d98f6eaf707a207a6 languageName: node linkType: hard @@ -14211,7 +13600,7 @@ __metadata: resolution: "uglify-js@npm:3.17.4" bin: uglifyjs: bin/uglifyjs - checksum: 7b3897df38b6fc7d7d9f4dcd658599d81aa2b1fb0d074829dd4e5290f7318dbca1f4af2f45acb833b95b1fe0ed4698662ab61b87e94328eb4c0a0d3435baf924 + checksum: 4c0b800e0ff192079d2c3ce8414fd3b656a570028c7c79af5c29c53d5c532b68bbcae4ad47307f89c2ee124d11826fff7a136b59d5c5bb18422bcdf5568afe1e languageName: node linkType: hard @@ -14219,18 +13608,18 @@ __metadata: version: 1.0.2 resolution: "unbox-primitive@npm:1.0.2" dependencies: - call-bind: ^1.0.2 - has-bigints: ^1.0.2 - has-symbols: ^1.0.3 - which-boxed-primitive: ^1.0.2 - checksum: b7a1cf5862b5e4b5deb091672ffa579aa274f648410009c81cca63fed3b62b610c4f3b773f912ce545bb4e31edc3138975b5bc777fc6e4817dca51affb6380e9 + call-bind: "npm:^1.0.2" + has-bigints: "npm:^1.0.2" + has-symbols: "npm:^1.0.3" + which-boxed-primitive: "npm:^1.0.2" + checksum: 06e1ee41c1095e37281cb71a975cb3350f7cb470a0665d2576f02cc9564f623bd90cfc0183693b8a7fdf2d242963dcc3010b509fa3ac683f540c765c0f3e7e43 languageName: node linkType: hard "undici-types@npm:~5.26.4": version: 5.26.5 resolution: "undici-types@npm:5.26.5" - checksum: 3192ef6f3fd5df652f2dc1cd782b49d6ff14dc98e5dced492aa8a8c65425227da5da6aafe22523c67f035a272c599bb89cfe803c1db6311e44bed3042fc25487 + checksum: 0097779d94bc0fd26f0418b3a05472410408877279141ded2bd449167be1aed7ea5b76f756562cb3586a07f251b90799bab22d9019ceba49c037c76445f7cddd languageName: node linkType: hard @@ -14245,8 +13634,8 @@ __metadata: version: 2.0.0 resolution: "unicode-match-property-ecmascript@npm:2.0.0" dependencies: - unicode-canonical-property-names-ecmascript: ^2.0.0 - unicode-property-aliases-ecmascript: ^2.0.0 + unicode-canonical-property-names-ecmascript: "npm:^2.0.0" + unicode-property-aliases-ecmascript: "npm:^2.0.0" checksum: 1f34a7434a23df4885b5890ac36c5b2161a809887000be560f56ad4b11126d433c0c1c39baf1016bdabed4ec54829a6190ee37aa24919aa116dc1a5a8a62965a languageName: node linkType: hard @@ -14254,7 +13643,7 @@ __metadata: "unicode-match-property-value-ecmascript@npm:^2.1.0": version: 2.1.0 resolution: "unicode-match-property-value-ecmascript@npm:2.1.0" - checksum: 8d6f5f586b9ce1ed0e84a37df6b42fdba1317a05b5df0c249962bd5da89528771e2d149837cad11aa26bcb84c35355cb9f58a10c3d41fa3b899181ece6c85220 + checksum: 06661bc8aba2a60c7733a7044f3e13085808939ad17924ffd4f5222a650f88009eb7c09481dc9c15cfc593d4ad99bd1cde8d54042733b335672591a81c52601c languageName: node linkType: hard @@ -14265,23 +13654,11 @@ __metadata: languageName: node linkType: hard -"union-value@npm:^1.0.0": - version: 1.0.1 - resolution: "union-value@npm:1.0.1" - dependencies: - arr-union: ^3.1.0 - get-value: ^2.0.6 - is-extendable: ^0.1.1 - set-value: ^2.0.1 - checksum: a3464097d3f27f6aa90cf103ed9387541bccfc006517559381a10e0dffa62f465a9d9a09c9b9c3d26d0f4cbe61d4d010e2fbd710fd4bf1267a768ba8a774b0ba - languageName: node - linkType: hard - "unique-filename@npm:^3.0.0": version: 3.0.0 resolution: "unique-filename@npm:3.0.0" dependencies: - unique-slug: ^4.0.0 + unique-slug: "npm:^4.0.0" checksum: 8e2f59b356cb2e54aab14ff98a51ac6c45781d15ceaab6d4f1c2228b780193dc70fae4463ce9e1df4479cb9d3304d7c2043a3fb905bdeca71cc7e8ce27e063df languageName: node linkType: hard @@ -14290,8 +13667,8 @@ __metadata: version: 4.0.0 resolution: "unique-slug@npm:4.0.0" dependencies: - imurmurhash: ^0.1.4 - checksum: 0884b58365af59f89739e6f71e3feacb5b1b41f2df2d842d0757933620e6de08eff347d27e9d499b43c40476cbaf7988638d3acb2ffbcb9d35fd035591adfd15 + imurmurhash: "npm:^0.1.4" + checksum: 40912a8963fc02fb8b600cf50197df4a275c602c60de4cac4f75879d3c48558cfac48de08a25cc10df8112161f7180b3bbb4d662aadb711568602f9eddee54f0 languageName: node linkType: hard @@ -14299,15 +13676,15 @@ __metadata: version: 2.0.0 resolution: "unique-string@npm:2.0.0" dependencies: - crypto-random-string: ^2.0.0 - checksum: ef68f639136bcfe040cf7e3cd7a8dff076a665288122855148a6f7134092e6ed33bf83a7f3a9185e46c98dddc445a0da6ac25612afa1a7c38b8b654d6c02498e + crypto-random-string: "npm:^2.0.0" + checksum: 107cae65b0b618296c2c663b8e52e4d1df129e9af04ab38d53b4f2189e96da93f599c85f4589b7ffaf1a11c9327cbb8a34f04c71b8d4950d3e385c2da2a93828 languageName: node linkType: hard "unist-util-is@npm:^4.0.0": version: 4.1.0 resolution: "unist-util-is@npm:4.1.0" - checksum: 726484cd2adc9be75a939aeedd48720f88294899c2e4a3143da413ae593f2b28037570730d5cf5fd910ff41f3bc1501e3d636b6814c478d71126581ef695f7ea + checksum: c046cc87c0a4f797b2afce76d917218e6a9af946a56cb5a88cb7f82be34f16c11050a10ddc4c66a3297dbb2782ca7d72a358cd77900b439ea9c683ba003ffe90 languageName: node linkType: hard @@ -14315,9 +13692,9 @@ __metadata: version: 3.1.1 resolution: "unist-util-visit-parents@npm:3.1.1" dependencies: - "@types/unist": ^2.0.0 - unist-util-is: ^4.0.0 - checksum: 1170e397dff88fab01e76d5154981666eb0291019d2462cff7a2961a3e76d3533b42eaa16b5b7e2d41ad42a5ea7d112301458283d255993e660511387bf67bc3 + "@types/unist": "npm:^2.0.0" + unist-util-is: "npm:^4.0.0" + checksum: 1b18343d88a0ad9cafaf8164ff8a1d3e3903328b3936b1565d61731f0b5778b9b9f400c455d3ad5284eeebcfdd7558ce24eb15c303a9cc0bd9218d01b2116923 languageName: node linkType: hard @@ -14325,9 +13702,9 @@ __metadata: version: 2.0.3 resolution: "unist-util-visit@npm:2.0.3" dependencies: - "@types/unist": ^2.0.0 - unist-util-is: ^4.0.0 - unist-util-visit-parents: ^3.0.0 + "@types/unist": "npm:^2.0.0" + unist-util-is: "npm:^4.0.0" + unist-util-visit-parents: "npm:^3.0.0" checksum: 1fe19d500e212128f96d8c3cfa3312846e586b797748a1fd195fe6479f06bc90a6f6904deb08eefc00dd58e83a1c8a32fb8677252d2273ad7a5e624525b69b8f languageName: node linkType: hard @@ -14357,21 +13734,11 @@ __metadata: version: 1.7.1 resolution: "unplugin@npm:1.7.1" dependencies: - acorn: ^8.11.3 - chokidar: ^3.5.3 - webpack-sources: ^3.2.3 - webpack-virtual-modules: ^0.6.1 - checksum: 932349e15bc3eb04e86822bb01453e59715640b60e776e373adc7fa75e48cbe60939cf7de253def1d6c97a7cbb514f753a35f1008eff02b5065c40940d51b343 - languageName: node - linkType: hard - -"unset-value@npm:^1.0.0": - version: 1.0.0 - resolution: "unset-value@npm:1.0.0" - dependencies: - has-value: ^0.3.1 - isobject: ^3.0.0 - checksum: 5990ecf660672be2781fc9fb322543c4aa592b68ed9a3312fa4df0e9ba709d42e823af090fc8f95775b4cd2c9a5169f7388f0cec39238b6d0d55a69fc2ab6b29 + acorn: "npm:^8.11.3" + chokidar: "npm:^3.5.3" + webpack-sources: "npm:^3.2.3" + webpack-virtual-modules: "npm:^0.6.1" + checksum: cadee8d57d574b4b017c82e696c2ed03b9e90a13f8a3baad14261b6888b989f852ef91e401b6488c03886a4231250e61168f15ef89714d5760d729712c2d4064 languageName: node linkType: hard @@ -14386,13 +13753,13 @@ __metadata: version: 1.0.13 resolution: "update-browserslist-db@npm:1.0.13" dependencies: - escalade: ^3.1.1 - picocolors: ^1.0.0 + escalade: "npm:^3.1.1" + picocolors: "npm:^1.0.0" peerDependencies: browserslist: ">= 4.21.0" bin: update-browserslist-db: cli.js - checksum: 1e47d80182ab6e4ad35396ad8b61008ae2a1330221175d0abd37689658bdb61af9b705bfc41057fd16682474d79944fb2d86767c5ed5ae34b6276b9bed353322 + checksum: 9074b4ef34d2ed931f27d390aafdd391ee7c45ad83c508e8fed6aaae1eb68f81999a768ed8525c6f88d4001a4fbf1b8c0268f099d0e8e72088ec5945ac796acf languageName: node linkType: hard @@ -14400,15 +13767,8 @@ __metadata: version: 4.4.1 resolution: "uri-js@npm:4.4.1" dependencies: - punycode: ^2.1.0 - checksum: 7167432de6817fe8e9e0c9684f1d2de2bb688c94388f7569f7dbdb1587c9f4ca2a77962f134ec90be0cc4d004c939ff0d05acc9f34a0db39a3c797dada262633 - languageName: node - linkType: hard - -"urix@npm:^0.1.0": - version: 0.1.0 - resolution: "urix@npm:0.1.0" - checksum: 4c076ecfbf3411e888547fe844e52378ab5ada2d2f27625139011eada79925e77f7fbf0e4016d45e6a9e9adb6b7e64981bd49b22700c7c401c5fc15f423303b3 + punycode: "npm:^2.1.0" + checksum: b271ca7e3d46b7160222e3afa3e531505161c9a4e097febae9664e4b59912f4cbe94861361a4175edac3a03fee99d91e44b6a58c17a634bc5a664b19fc76fbcb languageName: node linkType: hard @@ -14416,9 +13776,9 @@ __metadata: version: 1.5.10 resolution: "url-parse@npm:1.5.10" dependencies: - querystringify: ^2.1.1 - requires-port: ^1.0.0 - checksum: fbdba6b1d83336aca2216bbdc38ba658d9cfb8fc7f665eb8b17852de638ff7d1a162c198a8e4ed66001ddbf6c9888d41e4798912c62b4fd777a31657989f7bdf + querystringify: "npm:^2.1.1" + requires-port: "npm:^1.0.0" + checksum: c9e96bc8c5b34e9f05ddfeffc12f6aadecbb0d971b3cc26015b58d5b44676a99f50d5aeb1e5c9e61fa4d49961ae3ab1ae997369ed44da51b2f5ac010d188e6ad languageName: node linkType: hard @@ -14426,14 +13786,14 @@ __metadata: version: 1.3.1 resolution: "use-callback-ref@npm:1.3.1" dependencies: - tslib: ^2.0.0 + tslib: "npm:^2.0.0" peerDependencies: "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 peerDependenciesMeta: "@types/react": optional: true - checksum: 6a6a3a8bfe88f466eab982b8a92e5da560a7127b3b38815e89bc4d195d4b33aa9a53dba50d93e8138e7502bcc7e39efe9f2735a07a673212630990c73483e8e9 + checksum: 7cc68dbd8bb9890e21366f153938988967f0a17168a215bf31e24519f826a2de7de596e981f016603a363362f736f2cffad05091c3857fcafbc9c3b20a3eef1e languageName: node linkType: hard @@ -14441,11 +13801,11 @@ __metadata: version: 9.1.0 resolution: "use-resize-observer@npm:9.1.0" dependencies: - "@juggle/resize-observer": ^3.3.1 + "@juggle/resize-observer": "npm:^3.3.1" peerDependencies: react: 16.8.0 - 18 react-dom: 16.8.0 - 18 - checksum: 92be0ac34a3b3cf884cd55847c90792b5b44833dc258e96d650152815ad246afe45825aa223332203004d836535a927ab74f18dc0313229e2c7c69510eddf382 + checksum: 821d3f783090e36c694ef0ae3e366b364a691a8254d04337700ea79757e01e2d79f307ee517487c9246db7e8bc9625b474dd6ac7dad18d777004dee817826080 languageName: node linkType: hard @@ -14453,15 +13813,15 @@ __metadata: version: 1.1.2 resolution: "use-sidecar@npm:1.1.2" dependencies: - detect-node-es: ^1.1.0 - tslib: ^2.0.0 + detect-node-es: "npm:^1.1.0" + tslib: "npm:^2.0.0" peerDependencies: "@types/react": ^16.9.0 || ^17.0.0 || ^18.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 peerDependenciesMeta: "@types/react": optional: true - checksum: 925d1922f9853e516eaad526b6fed1be38008073067274f0ecc3f56b17bb8ab63480140dd7c271f94150027c996cea4efe83d3e3525e8f3eda22055f6a39220b + checksum: ec99e31aefeb880f6dc4d02cb19a01d123364954f857811470ece32872f70d6c3eadbe4d073770706a9b7db6136f2a9fbf1bb803e07fbb21e936a47479281690 languageName: node linkType: hard @@ -14470,14 +13830,7 @@ __metadata: resolution: "use-sync-external-store@npm:1.2.0" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: 5c639e0f8da3521d605f59ce5be9e094ca772bd44a4ce7322b055a6f58eeed8dda3c94cabd90c7a41fb6fa852210092008afe48f7038792fd47501f33299116a - languageName: node - linkType: hard - -"use@npm:^3.1.0": - version: 3.1.1 - resolution: "use@npm:3.1.1" - checksum: 08a130289f5238fcbf8f59a18951286a6e660d17acccc9d58d9b69dfa0ee19aa038e8f95721b00b432c36d1629a9e32a464bf2e7e0ae6a244c42ddb30bdd8b33 + checksum: a676216affc203876bd47981103f201f28c2731361bb186367e12d287a7566763213a8816910c6eb88265eccd4c230426eb783d64c373c4a180905be8820ed8e languageName: node linkType: hard @@ -14492,19 +13845,19 @@ __metadata: version: 0.12.5 resolution: "util@npm:0.12.5" dependencies: - inherits: ^2.0.3 - is-arguments: ^1.0.4 - is-generator-function: ^1.0.7 - is-typed-array: ^1.1.3 - which-typed-array: ^1.1.2 - checksum: 705e51f0de5b446f4edec10739752ac25856541e0254ea1e7e45e5b9f9b0cb105bc4bd415736a6210edc68245a7f903bf085ffb08dd7deb8a0e847f60538a38a + inherits: "npm:^2.0.3" + is-arguments: "npm:^1.0.4" + is-generator-function: "npm:^1.0.7" + is-typed-array: "npm:^1.1.3" + which-typed-array: "npm:^1.1.2" + checksum: 61a10de7753353dd4d744c917f74cdd7d21b8b46379c1e48e1c4fd8e83f8190e6bd9978fc4e5102ab6a10ebda6019d1b36572fa4a325e175ec8b789a121f6147 languageName: node linkType: hard "utils-merge@npm:1.0.1": version: 1.0.1 resolution: "utils-merge@npm:1.0.1" - checksum: c81095493225ecfc28add49c106ca4f09cdf56bc66731aa8dabc2edbbccb1e1bfe2de6a115e5c6a380d3ea166d1636410b62ef216bb07b3feb1cfde1d95d5080 + checksum: 5d6949693d58cb2e636a84f3ee1c6e7b2f9c16cb1d42d0ecb386d8c025c69e327205aa1c69e2868cc06a01e5e20681fbba55a4e0ed0cce913d60334024eae798 languageName: node linkType: hard @@ -14513,7 +13866,7 @@ __metadata: resolution: "uuid@npm:9.0.1" bin: uuid: dist/bin/uuid - checksum: 39931f6da74e307f51c0fb463dc2462807531dc80760a9bff1e35af4316131b4fc3203d16da60ae33f07fdca5b56f3f1dd662da0c99fea9aaeab2004780cc5f4 + checksum: 9d0b6adb72b736e36f2b1b53da0d559125ba3e39d913b6072f6f033e0c87835b414f0836b45bcfaf2bdf698f92297fea1c3cc19b0b258bc182c9c43cc0fab9f2 languageName: node linkType: hard @@ -14521,10 +13874,10 @@ __metadata: version: 9.2.0 resolution: "v8-to-istanbul@npm:9.2.0" dependencies: - "@jridgewell/trace-mapping": ^0.3.12 - "@types/istanbul-lib-coverage": ^2.0.1 - convert-source-map: ^2.0.0 - checksum: 31ef98c6a31b1dab6be024cf914f235408cd4c0dc56a5c744a5eea1a9e019ba279e1b6f90d695b78c3186feed391ed492380ccf095009e2eb91f3d058f0b4491 + "@jridgewell/trace-mapping": "npm:^0.3.12" + "@types/istanbul-lib-coverage": "npm:^2.0.1" + convert-source-map: "npm:^2.0.0" + checksum: 18dd8cebfb6790f27f4e41e7cff77c7ab1c8904085f354dd7875e2eb65f4261c4cf40939132502875779d92304bfea46b8336346ecb40b6f33c3a3979e6f5729 languageName: node linkType: hard @@ -14532,16 +13885,30 @@ __metadata: version: 3.0.4 resolution: "validate-npm-package-license@npm:3.0.4" dependencies: - spdx-correct: ^3.0.0 - spdx-expression-parse: ^3.0.0 - checksum: 35703ac889d419cf2aceef63daeadbe4e77227c39ab6287eeb6c1b36a746b364f50ba22e88591f5d017bc54685d8137bc2d328d0a896e4d3fd22093c0f32a9ad + spdx-correct: "npm:^3.0.0" + spdx-expression-parse: "npm:^3.0.0" + checksum: 86242519b2538bb8aeb12330edebb61b4eb37fd35ef65220ab0b03a26c0592c1c8a7300d32da3cde5abd08d18d95e8dabfad684b5116336f6de9e6f207eec224 languageName: node linkType: hard "vary@npm:~1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2" - checksum: ae0123222c6df65b437669d63dfa8c36cee20a504101b2fcd97b8bf76f91259c17f9f2b4d70a1e3c6bbcee7f51b28392833adb6b2770b23b01abec84e369660b + checksum: 31389debef15a480849b8331b220782230b9815a8e0dbb7b9a8369559aed2e9a7800cd904d4371ea74f4c3527db456dc8e7ac5befce5f0d289014dbdf47b2242 + languageName: node + linkType: hard + +"vinyl@npm:^2.2.1": + version: 2.2.1 + resolution: "vinyl@npm:2.2.1" + dependencies: + clone: "npm:^2.1.1" + clone-buffer: "npm:^1.0.0" + clone-stats: "npm:^1.0.0" + cloneable-readable: "npm:^1.0.0" + remove-trailing-separator: "npm:^1.0.1" + replace-ext: "npm:^1.0.0" + checksum: 6f7c034381afbfd2fd3d09d75a7275f232a00e623f84e9f7fd3569015110f7d03b7535e6c9e6dd0166e1cee6d490182a25aa17a95db1c6aab6d066561466fb49 languageName: node linkType: hard @@ -14549,9 +13916,9 @@ __metadata: version: 1.8.1 resolution: "vite-plugin-eslint@npm:1.8.1" dependencies: - "@rollup/pluginutils": ^4.2.1 - "@types/eslint": ^8.4.5 - rollup: ^2.77.2 + "@rollup/pluginutils": "npm:^4.2.1" + "@types/eslint": "npm:^8.4.5" + rollup: "npm:^2.77.2" peerDependencies: eslint: ">=7" vite: ">=2" @@ -14564,20 +13931,16 @@ __metadata: resolution: "vite-plugin-react-remove-attributes@npm:1.0.3" peerDependencies: vite: ^2.4.4 - checksum: 9ae11525a6b4d111a6d655b7495c319cb99dc2aadaf2f5e1a6c311b3db32a93118f0d4b314f2c039a27e21a15a644bbfafbb7fb87963afcc06912b95c505505b + checksum: 4ffda1ac666128caaef33de2634696df9f8f18f92af9909abddfec5c1b0929bb4061003af72c24fac6c0881200cefca54184b60fd52b48ce2c3738d40761ecd9 languageName: node linkType: hard -"vite-plugin-svg-sprite@npm:0.3.2": - version: 0.3.2 - resolution: "vite-plugin-svg-sprite@npm:0.3.2" +"vite-plugin-svg-spriter@npm:1.0.0": + version: 1.0.0 + resolution: "vite-plugin-svg-spriter@npm:1.0.0" dependencies: - micromatch: ^4.0.2 - svg-baker: ~1.7.0 - svgo: ^2.8.0 - peerDependencies: - vite: ^2 || ^3 || ^4 - checksum: 2f1ae63fae87b286548912f3da3e3b9ea4c77ee5aafd45144e29d594632e0684e9ebadb070cb088f90c275493a2a2e2df8958f288c3ac1c8e5429ff6ca79a317 + svg-sprite: "npm:^2.0.2" + checksum: 5b2c821e8917237da4503a930dae8a5d51b4c1f312e1825f51a68db73cc1538d4ca8f2129ee4a0521974ff3d183584d9f40b0c53c2304eec55a55f135ed97805 languageName: node linkType: hard @@ -14585,9 +13948,9 @@ __metadata: version: 3.2.0 resolution: "vite-plugin-svgr@npm:3.2.0" dependencies: - "@rollup/pluginutils": ^5.0.2 - "@svgr/core": ^7.0.0 - "@svgr/plugin-jsx": ^7.0.0 + "@rollup/pluginutils": "npm:^5.0.2" + "@svgr/core": "npm:^7.0.0" + "@svgr/plugin-jsx": "npm:^7.0.0" peerDependencies: vite: ^2.6.0 || 3 || 4 checksum: 19887e1db910ecdd6c12645e430d9e1d9ad40fe6945d3f7de68fc235fba0277586deffa47db1a6be2fa511207b01893a3c5bad9d1bd558ca28971feca13ecd9a @@ -14598,15 +13961,15 @@ __metadata: version: 4.2.0 resolution: "vite-tsconfig-paths@npm:4.2.0" dependencies: - debug: ^4.1.1 - globrex: ^0.1.2 - tsconfck: ^2.1.0 + debug: "npm:^4.1.1" + globrex: "npm:^0.1.2" + tsconfck: "npm:^2.1.0" peerDependencies: vite: "*" peerDependenciesMeta: vite: optional: true - checksum: 73a8467de72d7ac502328454fd00c19571cd4bad2dd5982643b24718bb95e449a3f4153cfc2d58a358bfc8f37e592fb442fc10884b59ae82138c1329160cd952 + checksum: 8550650c5f5b203e13f4975f6bfd26389000175a031be8f408f6b5d619d5fd8430e067a0661599a4d0872c431496b81239e5d5ad69d9c4b4f9ca7efd4b874bb1 languageName: node linkType: hard @@ -14614,10 +13977,10 @@ __metadata: version: 4.4.1 resolution: "vite@npm:4.4.1" dependencies: - esbuild: ^0.18.10 - fsevents: ~2.3.2 - postcss: ^8.4.24 - rollup: ^3.25.2 + esbuild: "npm:^0.18.10" + fsevents: "npm:~2.3.2" + postcss: "npm:^8.4.24" + rollup: "npm:^3.25.2" peerDependencies: "@types/node": ">= 14" less: "*" @@ -14646,7 +14009,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: c91d5228cd0b2410e95ea4b17279640a414f1a2d5290a01baec77351af2dda7d5901c240ed6a62de2b465567e328168d386da2aaa262d3a138fde827b289592d + checksum: d4827cdd41c8bb69c5071200a88015fa3c4d29082c46fd9f20db8df01f7b4948042e4b2e67b6a4391c0bdbfc6c4925832cc6b2411661518988c9a25d4b887598 languageName: node linkType: hard @@ -14654,8 +14017,8 @@ __metadata: version: 4.0.0 resolution: "w3c-xmlserializer@npm:4.0.0" dependencies: - xml-name-validator: ^4.0.0 - checksum: eba070e78deb408ae8defa4d36b429f084b2b47a4741c4a9be3f27a0a3d1845e277e3072b04391a138f7e43776842627d1334e448ff13ff90ad9fb1214ee7091 + xml-name-validator: "npm:^4.0.0" + checksum: 9a00c412b5496f4f040842c9520bc0aaec6e0c015d06412a91a723cd7d84ea605ab903965f546b4ecdb3eae267f5145ba08565222b1d6cb443ee488cda9a0aee languageName: node linkType: hard @@ -14663,7 +14026,7 @@ __metadata: version: 1.0.8 resolution: "walker@npm:1.0.8" dependencies: - makeerror: 1.0.12 + makeerror: "npm:1.0.12" checksum: ad7a257ea1e662e57ef2e018f97b3c02a7240ad5093c392186ce0bcf1f1a60bbadd520d073b9beb921ed99f64f065efb63dfc8eec689a80e569f93c1c5d5e16c languageName: node linkType: hard @@ -14672,9 +14035,9 @@ __metadata: version: 2.4.0 resolution: "watchpack@npm:2.4.0" dependencies: - glob-to-regexp: ^0.4.1 - graceful-fs: ^4.1.2 - checksum: 23d4bc58634dbe13b86093e01c6a68d8096028b664ab7139d58f0c37d962d549a940e98f2f201cecdabd6f9c340338dc73ef8bf094a2249ef582f35183d1a131 + glob-to-regexp: "npm:^0.4.1" + graceful-fs: "npm:^4.1.2" + checksum: 4280b45bc4b5d45d5579113f2a4af93b67ae1b9607cc3d86ae41cdd53ead10db5d9dc3237f24256d05ef88b28c69a02712f78e434cb7ecc8edaca134a56e8cab languageName: node linkType: hard @@ -14682,36 +14045,36 @@ __metadata: version: 1.0.1 resolution: "wcwidth@npm:1.0.1" dependencies: - defaults: ^1.0.3 - checksum: 814e9d1ddcc9798f7377ffa448a5a3892232b9275ebb30a41b529607691c0491de47cba426e917a4d08ded3ee7e9ba2f3fe32e62ee3cd9c7d3bafb7754bd553c + defaults: "npm:^1.0.3" + checksum: 182ebac8ca0b96845fae6ef44afd4619df6987fe5cf552fdee8396d3daa1fb9b8ec5c6c69855acb7b3c1231571393bd1f0a4cdc4028d421575348f64bb0a8817 languageName: node linkType: hard "webidl-conversions@npm:^3.0.0": version: 3.0.1 resolution: "webidl-conversions@npm:3.0.1" - checksum: c92a0a6ab95314bde9c32e1d0a6dfac83b578f8fa5f21e675bc2706ed6981bc26b7eb7e6a1fab158e5ce4adf9caa4a0aee49a52505d4d13c7be545f15021b17c + checksum: b65b9f8d6854572a84a5c69615152b63371395f0c5dcd6729c45789052296df54314db2bc3e977df41705eacb8bc79c247cee139a63fa695192f95816ed528ad languageName: node linkType: hard "webidl-conversions@npm:^7.0.0": version: 7.0.0 resolution: "webidl-conversions@npm:7.0.0" - checksum: f05588567a2a76428515333eff87200fae6c83c3948a7482ebb109562971e77ef6dc49749afa58abb993391227c5697b3ecca52018793e0cb4620a48f10bd21b + checksum: 4c4f65472c010eddbe648c11b977d048dd96956a625f7f8b9d64e1b30c3c1f23ea1acfd654648426ce5c743c2108a5a757c0592f02902cf7367adb7d14e67721 languageName: node linkType: hard "webpack-sources@npm:^3.2.3": version: 3.2.3 resolution: "webpack-sources@npm:3.2.3" - checksum: 989e401b9fe3536529e2a99dac8c1bdc50e3a0a2c8669cbafad31271eadd994bc9405f88a3039cd2e29db5e6d9d0926ceb7a1a4e7409ece021fe79c37d9c4607 + checksum: a661f41795d678b7526ae8a88cd1b3d8ce71a7d19b6503da8149b2e667fc7a12f9b899041c1665d39e38245ed3a59ab68de648ea31040c3829aa695a5a45211d languageName: node linkType: hard "webpack-virtual-modules@npm:^0.6.1": version: 0.6.1 resolution: "webpack-virtual-modules@npm:0.6.1" - checksum: 0cd993d7b00af0ed89eee96ed6dcb2307fa8dc38e37f34e78690088314976aa79a31cf146553c5e414cdc87222878c5e4979abeb0b00bf6dc9c6f018604a1310 + checksum: 12a43ecdb910185c9d7e4ec19cc3b13bff228dae362e8a487c0bd292b393555e017ad16f771d5ce5b692d91d65b71a7bcd64763958d39066a5351ea325395539 languageName: node linkType: hard @@ -14719,15 +14082,15 @@ __metadata: version: 2.0.0 resolution: "whatwg-encoding@npm:2.0.0" dependencies: - iconv-lite: 0.6.3 - checksum: 7087810c410aa9b689cbd6af8773341a53cdc1f3aae2a882c163bd5522ec8ca4cdfc269aef417a5792f411807d5d77d50df4c24e3abb00bb60192858a40cc675 + iconv-lite: "npm:0.6.3" + checksum: 162d712d88fd134a4fe587e53302da812eb4215a1baa4c394dfd86eff31d0a079ff932c05233857997de07481093358d6e7587997358f49b8a580a777be22089 languageName: node linkType: hard "whatwg-mimetype@npm:^3.0.0": version: 3.0.0 resolution: "whatwg-mimetype@npm:3.0.0" - checksum: ce08bbb36b6aaf64f3a84da89707e3e6a31e5ab1c1a2379fd68df79ba712a4ab090904f0b50e6693b0dafc8e6343a6157e40bf18fdffd26e513cf95ee2a59824 + checksum: 96f9f628c663c2ae05412c185ca81b3df54bcb921ab52fe9ebc0081c1720f25d770665401eb2338ab7f48c71568133845638e18a81ed52ab5d4dcef7d22b40ef languageName: node linkType: hard @@ -14735,9 +14098,9 @@ __metadata: version: 11.0.0 resolution: "whatwg-url@npm:11.0.0" dependencies: - tr46: ^3.0.0 - webidl-conversions: ^7.0.0 - checksum: ed4826aaa57e66bb3488a4b25c9cd476c46ba96052747388b5801f137dd740b73fde91ad207d96baf9f17fbcc80fc1a477ad65181b5eb5fa718d27c69501d7af + tr46: "npm:^3.0.0" + webidl-conversions: "npm:^7.0.0" + checksum: dfcd51c6f4bfb54685528fb10927f3fd3d7c809b5671beef4a8cdd7b1408a7abf3343a35bc71dab83a1424f1c1e92cc2700d7930d95d231df0fac361de0c7648 languageName: node linkType: hard @@ -14745,9 +14108,9 @@ __metadata: version: 5.0.0 resolution: "whatwg-url@npm:5.0.0" dependencies: - tr46: ~0.0.3 - webidl-conversions: ^3.0.0 - checksum: b8daed4ad3356cc4899048a15b2c143a9aed0dfae1f611ebd55073310c7b910f522ad75d727346ad64203d7e6c79ef25eafd465f4d12775ca44b90fa82ed9e2c + tr46: "npm:~0.0.3" + webidl-conversions: "npm:^3.0.0" + checksum: f95adbc1e80820828b45cc671d97da7cd5e4ef9deb426c31bcd5ab00dc7103042291613b3ef3caec0a2335ed09e0d5ed026c940755dbb6d404e2b27f940fdf07 languageName: node linkType: hard @@ -14755,12 +14118,12 @@ __metadata: version: 1.0.2 resolution: "which-boxed-primitive@npm:1.0.2" dependencies: - is-bigint: ^1.0.1 - is-boolean-object: ^1.1.0 - is-number-object: ^1.0.4 - is-string: ^1.0.5 - is-symbol: ^1.0.3 - checksum: 53ce774c7379071729533922adcca47220228405e1895f26673bbd71bdf7fb09bee38c1d6399395927c6289476b5ae0629863427fd151491b71c4b6cb04f3a5e + is-bigint: "npm:^1.0.1" + is-boolean-object: "npm:^1.1.0" + is-number-object: "npm:^1.0.4" + is-string: "npm:^1.0.5" + is-symbol: "npm:^1.0.3" + checksum: 9c7ca7855255f25ac47f4ce8b59c4cc33629e713fd7a165c9d77a2bb47bf3d9655a5664660c70337a3221cf96742f3589fae15a3a33639908d33e29aa2941efb languageName: node linkType: hard @@ -14768,19 +14131,19 @@ __metadata: version: 1.1.3 resolution: "which-builtin-type@npm:1.1.3" dependencies: - function.prototype.name: ^1.1.5 - has-tostringtag: ^1.0.0 - is-async-function: ^2.0.0 - is-date-object: ^1.0.5 - is-finalizationregistry: ^1.0.2 - is-generator-function: ^1.0.10 - is-regex: ^1.1.4 - is-weakref: ^1.0.2 - isarray: ^2.0.5 - which-boxed-primitive: ^1.0.2 - which-collection: ^1.0.1 - which-typed-array: ^1.1.9 - checksum: 43730f7d8660ff9e33d1d3f9f9451c4784265ee7bf222babc35e61674a11a08e1c2925019d6c03154fcaaca4541df43abe35d2720843b9b4cbcebdcc31408f36 + function.prototype.name: "npm:^1.1.5" + has-tostringtag: "npm:^1.0.0" + is-async-function: "npm:^2.0.0" + is-date-object: "npm:^1.0.5" + is-finalizationregistry: "npm:^1.0.2" + is-generator-function: "npm:^1.0.10" + is-regex: "npm:^1.1.4" + is-weakref: "npm:^1.0.2" + isarray: "npm:^2.0.5" + which-boxed-primitive: "npm:^1.0.2" + which-collection: "npm:^1.0.1" + which-typed-array: "npm:^1.1.9" + checksum: d7823c4a6aa4fc8183eb572edd9f9ee2751e5f3ba2ccd5b298cc163f720df0f02ee1a5291d18ca8a41d48144ef40007ff6a64e6f5e7c506527086c7513a5f673 languageName: node linkType: hard @@ -14788,11 +14151,11 @@ __metadata: version: 1.0.1 resolution: "which-collection@npm:1.0.1" dependencies: - is-map: ^2.0.1 - is-set: ^2.0.1 - is-weakmap: ^2.0.1 - is-weakset: ^2.0.1 - checksum: c815bbd163107ef9cb84f135e6f34453eaf4cca994e7ba85ddb0d27cea724c623fae2a473ceccfd5549c53cc65a5d82692de418166df3f858e1e5dc60818581c + is-map: "npm:^2.0.1" + is-set: "npm:^2.0.1" + is-weakmap: "npm:^2.0.1" + is-weakset: "npm:^2.0.1" + checksum: 85c95fcf92df7972ce66bed879e53d9dc752a30ef08e1ca4696df56bcf1c302e3b9965a39b04a20fa280a997fad6c170eb0b4d62435569b7f6c0bc7be910572b languageName: node linkType: hard @@ -14800,12 +14163,12 @@ __metadata: version: 1.1.14 resolution: "which-typed-array@npm:1.1.14" dependencies: - available-typed-arrays: ^1.0.6 - call-bind: ^1.0.5 - for-each: ^0.3.3 - gopd: ^1.0.1 - has-tostringtag: ^1.0.1 - checksum: efe30c143c58630dde8ab96f9330e20165bacd77ca843c602b510120a415415573bcdef3ccbc30a0e5aaf20f257360cfe24712aea0008f149ce5bb99834c0c0b + available-typed-arrays: "npm:^1.0.6" + call-bind: "npm:^1.0.5" + for-each: "npm:^0.3.3" + gopd: "npm:^1.0.1" + has-tostringtag: "npm:^1.0.1" + checksum: 56253d2c9d6b41b8a4af96d8c2751bac5508906bd500cdcd0dc5301fb082de0391a4311ab21258bc8d2609ed593f422c1a66f0020fcb3a1e97f719bc928b9018 languageName: node linkType: hard @@ -14813,10 +14176,10 @@ __metadata: version: 2.0.2 resolution: "which@npm:2.0.2" dependencies: - isexe: ^2.0.0 + isexe: "npm:^2.0.0" bin: node-which: ./bin/node-which - checksum: 1a5c563d3c1b52d5f893c8b61afe11abc3bab4afac492e8da5bde69d550de701cf9806235f20a47b5c8fa8a1d6a9135841de2596535e998027a54589000e66d1 + checksum: 4782f8a1d6b8fc12c65e968fea49f59752bf6302dc43036c3bf87da718a80710f61a062516e9764c70008b487929a73546125570acea95c5b5dcc8ac3052c70f languageName: node linkType: hard @@ -14824,17 +14187,47 @@ __metadata: version: 4.0.0 resolution: "which@npm:4.0.0" dependencies: - isexe: ^3.1.1 + isexe: "npm:^3.1.1" bin: node-which: bin/which.js checksum: f17e84c042592c21e23c8195108cff18c64050b9efb8459589116999ea9da6dd1509e6a1bac3aeebefd137be00fabbb61b5c2bc0aa0f8526f32b58ee2f545651 languageName: node linkType: hard +"winston-transport@npm:^4.7.0": + version: 4.7.0 + resolution: "winston-transport@npm:4.7.0" + dependencies: + logform: "npm:^2.3.2" + readable-stream: "npm:^3.6.0" + triple-beam: "npm:^1.3.0" + checksum: c8eae7b110e68396edcf26aec86608bd8ac98f3cc05961064e2e577b023d9c4aa485546cacba84efaf48b7d6b1e282dc211fd959ee16cbd31d34476d96daea43 + languageName: node + linkType: hard + +"winston@npm:^3.11.0": + version: 3.13.0 + resolution: "winston@npm:3.13.0" + dependencies: + "@colors/colors": "npm:^1.6.0" + "@dabh/diagnostics": "npm:^2.0.2" + async: "npm:^3.2.3" + is-stream: "npm:^2.0.0" + logform: "npm:^2.4.0" + one-time: "npm:^1.0.0" + readable-stream: "npm:^3.4.0" + safe-stable-stringify: "npm:^2.3.1" + stack-trace: "npm:0.0.x" + triple-beam: "npm:^1.3.0" + winston-transport: "npm:^4.7.0" + checksum: 436675598359af27e4eabde2ce578cf77da893ffd57d0479f037fef939e8eb721031f0102b14399eee93b3412b545946c431d1fff23db3beeac2ffa395537f7b + languageName: node + linkType: hard + "wordwrap@npm:^1.0.0": version: 1.0.0 resolution: "wordwrap@npm:1.0.0" - checksum: 2a44b2788165d0a3de71fd517d4880a8e20ea3a82c080ce46e294f0b68b69a2e49cff5f99c600e275c698a90d12c5ea32aff06c311f0db2eb3f1201f3e7b2a04 + checksum: 497d40beb2bdb08e6d38754faa17ce20b0bf1306327f80cb777927edb23f461ee1f6bc659b3c3c93f26b08e1cf4b46acc5bae8fda1f0be3b5ab9a1a0211034cd languageName: node linkType: hard @@ -14842,10 +14235,10 @@ __metadata: version: 7.0.0 resolution: "wrap-ansi@npm:7.0.0" dependencies: - ansi-styles: ^4.0.0 - string-width: ^4.1.0 - strip-ansi: ^6.0.0 - checksum: a790b846fd4505de962ba728a21aaeda189b8ee1c7568ca5e817d85930e06ef8d1689d49dbf0e881e8ef84436af3a88bc49115c2e2788d841ff1b8b5b51a608b + ansi-styles: "npm:^4.0.0" + string-width: "npm:^4.1.0" + strip-ansi: "npm:^6.0.0" + checksum: cebdaeca3a6880da410f75209e68cd05428580de5ad24535f22696d7d9cab134d1f8498599f344c3cf0fb37c1715807a183778d8c648d6cc0cb5ff2bb4236540 languageName: node linkType: hard @@ -14853,10 +14246,10 @@ __metadata: version: 8.1.0 resolution: "wrap-ansi@npm:8.1.0" dependencies: - ansi-styles: ^6.1.0 - string-width: ^5.0.1 - strip-ansi: ^7.0.1 - checksum: 371733296dc2d616900ce15a0049dca0ef67597d6394c57347ba334393599e800bab03c41d4d45221b6bc967b8c453ec3ae4749eff3894202d16800fdfe0e238 + ansi-styles: "npm:^6.1.0" + string-width: "npm:^5.0.1" + strip-ansi: "npm:^7.0.1" + checksum: 7b1e4b35e9bb2312d2ee9ee7dc95b8cb5f8b4b5a89f7dde5543fe66c1e3715663094defa50d75454ac900bd210f702d575f15f3f17fa9ec0291806d2578d1ddf languageName: node linkType: hard @@ -14871,10 +14264,10 @@ __metadata: version: 2.4.3 resolution: "write-file-atomic@npm:2.4.3" dependencies: - graceful-fs: ^4.1.11 - imurmurhash: ^0.1.4 - signal-exit: ^3.0.2 - checksum: 2db81f92ae974fd87ab4a5e7932feacaca626679a7c98fcc73ad8fcea5a1950eab32fa831f79e9391ac99b562ca091ad49be37a79045bd65f595efbb8f4596ae + graceful-fs: "npm:^4.1.11" + imurmurhash: "npm:^0.1.4" + signal-exit: "npm:^3.0.2" + checksum: 15ce863dce07075d0decedd7c9094f4461e46139d28a758c53162f24c0791c16cd2e7a76baa5b47b1a851fbb51e16f2fab739afb156929b22628f3225437135c languageName: node linkType: hard @@ -14882,9 +14275,9 @@ __metadata: version: 4.0.2 resolution: "write-file-atomic@npm:4.0.2" dependencies: - imurmurhash: ^0.1.4 - signal-exit: ^3.0.7 - checksum: 5da60bd4eeeb935eec97ead3df6e28e5917a6bd317478e4a85a5285e8480b8ed96032bbcc6ecd07b236142a24f3ca871c924ec4a6575e623ec1b11bf8c1c253c + imurmurhash: "npm:^0.1.4" + signal-exit: "npm:^3.0.7" + checksum: 3be1f5508a46c190619d5386b1ac8f3af3dbe951ed0f7b0b4a0961eed6fc626bd84b50cf4be768dabc0a05b672f5d0c5ee7f42daa557b14415d18c3a13c7d246 languageName: node linkType: hard @@ -14892,8 +14285,8 @@ __metadata: version: 6.2.2 resolution: "ws@npm:6.2.2" dependencies: - async-limiter: ~1.0.0 - checksum: aec3154ec51477c094ac2cb5946a156e17561a581fa27005cbf22c53ac57f8d4e5f791dd4bbba6a488602cb28778c8ab7df06251d590507c3c550fd8ebeee949 + async-limiter: "npm:~1.0.0" + checksum: bb791ac02ad7e59fd4208cc6dd3a5bf7a67dff4611a128ed33365996f9fc24fa0d699043559f1798b4bc8045639fd21a1fd3ceca81de560124444abd8e321afc languageName: node linkType: hard @@ -14908,21 +14301,28 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: feb3eecd2bae82fa8a8beef800290ce437d8b8063bdc69712725f21aef77c49cb2ff45c6e5e7fce622248f9c7abaee506bae0a9064067ffd6935460c7357321b + checksum: 7c511c59e979bd37b63c3aea4a8e4d4163204f00bd5633c053b05ed67835481995f61a523b0ad2b603566f9a89b34cb4965cb9fab9649fbfebd8f740cea57f17 languageName: node linkType: hard "xml-name-validator@npm:^4.0.0": version: 4.0.0 resolution: "xml-name-validator@npm:4.0.0" - checksum: af100b79c29804f05fa35aa3683e29a321db9b9685d5e5febda3fa1e40f13f85abc40f45a6b2bf7bee33f68a1dc5e8eaef4cec100a304a9db565e6061d4cb5ad + checksum: f9582a3f281f790344a471c207516e29e293c6041b2c20d84dd6e58832cd7c19796c47e108fd4fd4b164a5e72ad94f2268f8ace8231cde4a2c6428d6aa220f92 languageName: node linkType: hard "xmlchars@npm:^2.2.0": version: 2.2.0 resolution: "xmlchars@npm:2.2.0" - checksum: 8c70ac94070ccca03f47a81fcce3b271bd1f37a591bf5424e787ae313fcb9c212f5f6786e1fa82076a2c632c0141552babcd85698c437506dfa6ae2d58723062 + checksum: 4ad5924974efd004a47cce6acf5c0269aee0e62f9a805a426db3337af7bcbd331099df174b024ace4fb18971b8a56de386d2e73a1c4b020e3abd63a4a9b917f1 + languageName: node + linkType: hard + +"xpath@npm:^0.0.34": + version: 0.0.34 + resolution: "xpath@npm:0.0.34" + checksum: 77ce03c4494dab97b70fa443761c35a6bd484538a449714b981387a532a6eb22e245b29164f5d8a4a82f4f3cfd71d27ba71d09ed2b6fe933654585c6e46c0a25 languageName: node linkType: hard @@ -14936,43 +14336,43 @@ __metadata: "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" - checksum: 54f0fb95621ee60898a38c572c515659e51cc9d9f787fb109cef6fde4befbe1c4602dc999d30110feee37456ad0f1660fa2edcfde6a9a740f86a290999550d30 + checksum: 5f1b5f95e3775de4514edbb142398a2c37849ccfaf04a015be5d75521e9629d3be29bd4432d23c57f37e5b61ade592fb0197022e9993f81a06a5afbdcda9346d languageName: node linkType: hard "yallist@npm:^3.0.2": version: 3.1.1 resolution: "yallist@npm:3.1.1" - checksum: 48f7bb00dc19fc635a13a39fe547f527b10c9290e7b3e836b9a8f1ca04d4d342e85714416b3c2ab74949c9c66f9cebb0473e6bc353b79035356103b47641285d + checksum: 9af0a4329c3c6b779ac4736c69fae4190ac03029fa27c1aef4e6bcc92119b73dea6fe5db5fe881fb0ce2a0e9539a42cdf60c7c21eda04d1a0b8c082e38509efb languageName: node linkType: hard "yallist@npm:^4.0.0": version: 4.0.0 resolution: "yallist@npm:4.0.0" - checksum: 343617202af32df2a15a3be36a5a8c0c8545208f3d3dfbc6bb7c3e3b7e8c6f8e7485432e4f3b88da3031a6e20afa7c711eded32ddfb122896ac5d914e75848d5 + checksum: 4cb02b42b8a93b5cf50caf5d8e9beb409400a8a4d85e83bb0685c1457e9ac0b7a00819e9f5991ac25ffabb56a78e2f017c1acc010b3a1babfe6de690ba531abd languageName: node linkType: hard "yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" - checksum: ed2d96a616a9e3e1cc7d204c62ecc61f7aaab633dcbfab2c6df50f7f87b393993fe6640d017759fe112d0cb1e0119f2b4150a87305cc873fd90831c6a58ccf1c + checksum: 9dc2c217ea3bf8d858041252d43e074f7166b53f3d010a8c711275e09cd3d62a002969a39858b92bbda2a6a63a585c7127014534a560b9c69ed2d923d113406e languageName: node linkType: hard -"yargs@npm:^17.3.1": +"yargs@npm:^17.3.1, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: - cliui: ^8.0.1 - escalade: ^3.1.1 - get-caller-file: ^2.0.5 - require-directory: ^2.1.1 - string-width: ^4.2.3 - y18n: ^5.0.5 - yargs-parser: ^21.1.1 - checksum: 73b572e863aa4a8cbef323dd911d79d193b772defd5a51aab0aca2d446655216f5002c42c5306033968193bdbf892a7a4c110b0d77954a7fdf563e653967b56a + cliui: "npm:^8.0.1" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + require-directory: "npm:^2.1.1" + string-width: "npm:^4.2.3" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^21.1.1" + checksum: abb3e37678d6e38ea85485ed86ebe0d1e3464c640d7d9069805ea0da12f69d5a32df8e5625e370f9c96dd1c2dc088ab2d0a4dd32af18222ef3c4224a19471576 languageName: node linkType: hard @@ -14980,9 +14380,9 @@ __metadata: version: 2.10.0 resolution: "yauzl@npm:2.10.0" dependencies: - buffer-crc32: ~0.2.3 - fd-slicer: ~1.1.0 - checksum: 7f21fe0bbad6e2cb130044a5d1d0d5a0e5bf3d8d4f8c4e6ee12163ce798fee3de7388d22a7a0907f563ac5f9d40f8699a223d3d5c1718da90b0156da6904022b + buffer-crc32: "npm:~0.2.3" + fd-slicer: "npm:~1.1.0" + checksum: 1e4c311050dc0cf2ee3dbe8854fe0a6cde50e420b3e561a8d97042526b4cf7a0718d6c8d89e9e526a152f4a9cec55bcea9c3617264115f48bd6704cf12a04445 languageName: node linkType: hard From 73a09a535b270a2f769fcd2e59f5b30d5b04ffd2 Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Wed, 29 May 2024 07:07:38 +0000 Subject: [PATCH 137/208] ADCM-5567: remove `reverse` from test_component.py --- python/api_v2/tests/test_component.py | 95 +++------------------------ 1 file changed, 9 insertions(+), 86 deletions(-) diff --git a/python/api_v2/tests/test_component.py b/python/api_v2/tests/test_component.py index 12b89e4d18..6b12ebb1c0 100644 --- a/python/api_v2/tests/test_component.py +++ b/python/api_v2/tests/test_component.py @@ -14,7 +14,6 @@ from cm.models import Action, ConcernType, MaintenanceMode, ServiceComponent from cm.tests.mocks.task_runner import RunTaskMock from cm.tests.utils import gen_concern_item -from django.urls import reverse from rest_framework.status import HTTP_200_OK, HTTP_405_METHOD_NOT_ALLOWED, HTTP_409_CONFLICT from api_v2.tests.base import BaseAPITestCase @@ -34,27 +33,13 @@ def setUp(self) -> None: self.action_1 = Action.objects.get(name="action_1_comp_1", prototype=self.component_1.prototype) def test_list(self): - response = self.client.get( - path=reverse( - "v2:component-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": self.service_1.pk}, - ), - ) + response = self.client.v2[self.service_1, "components"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 2) def test_retrieve_success(self): - response = self.client.get( - path=reverse( - "v2:component-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "pk": self.component_1.pk, - }, - ), - ) + response = self.client.v2[self.component_1].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["id"], self.component_1.pk) @@ -67,77 +52,31 @@ def test_adcm_4526_retrieve_concerns_regardless_owner_success(self): add_concern_to_object(object_=self.component_1, concern=concern_2) add_concern_to_object(object_=self.component_1, concern=concern_3) - response = self.client.get( - path=reverse( - "v2:component-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "pk": self.component_1.pk, - }, - ), - ) + response = self.client.v2[self.component_1].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()["concerns"]), 3) def test_delete_success(self): - response = self.client.delete( - path=reverse( - "v2:component-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "pk": self.component_1.pk, - }, - ), - ) + response = self.client.v2[self.component_1].delete() self.assertEqual(response.status_code, HTTP_405_METHOD_NOT_ALLOWED) def test_action_list_success(self): - response = self.client.get( - path=reverse( - "v2:component-action-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - }, - ), - ) + response = self.client.v2[self.component_1, "actions"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()), 2) def test_action_retrieve_success(self): - response = self.client.get( - path=reverse( - "v2:component-action-detail", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "pk": self.action_1.pk, - }, - ), - ) + response = self.client.v2[self.component_1, "actions", self.action_1].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertTrue(response.json()) def test_action_run_success(self): with RunTaskMock() as run_task: - response = self.client.post( - path=reverse( - "v2:component-action-run", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - "pk": self.action_1.pk, - }, - ), + response = self.client.v2[self.component_1, "actions", self.action_1, "run"].post( data={"hostComponent_map": [], "config": {}, "adcmMeta": {}, "isVerbose": False}, ) @@ -165,30 +104,14 @@ def setUp(self) -> None: ) def test_change_mm_success(self): - response = self.client.post( - path=reverse( - "v2:component-maintenance-mode", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1_cl_1.pk, - "pk": self.component_1_cl_1.pk, - }, - ), + response = self.client.v2[self.component_1_cl_1, "maintenance-mode"].post( data={"maintenance_mode": MaintenanceMode.ON}, ) self.assertEqual(response.status_code, HTTP_200_OK) def test_chamge_mm_not_available_fail(self): - response = self.client.post( - path=reverse( - "v2:component-maintenance-mode", - kwargs={ - "cluster_pk": self.cluster_2.pk, - "service_pk": self.service_cl_2.pk, - "pk": self.component_cl_1.pk, - }, - ), + response = self.client.v2[self.component_cl_1, "maintenance-mode"].post( data={"maintenance_mode": MaintenanceMode.ON}, ) From baa77160e3b18932a928bc482047d5968d8df7d4 Mon Sep 17 00:00:00 2001 From: Aleksandr Alferov Date: Wed, 29 May 2024 14:39:22 +0300 Subject: [PATCH 138/208] Remove duplicate tests --- python/api_v2/tests/test_group_config.py | 65 ------------------------ 1 file changed, 65 deletions(-) diff --git a/python/api_v2/tests/test_group_config.py b/python/api_v2/tests/test_group_config.py index 943665599c..62f6ca906d 100644 --- a/python/api_v2/tests/test_group_config.py +++ b/python/api_v2/tests/test_group_config.py @@ -134,71 +134,6 @@ def test_create_group_with_same_name_for_different_entities_of_same_type_success self.assertEqual(GroupConfig.objects.filter(name=name).count(), 2) -class TestGroupConfigNaming(BaseServiceGroupConfigTestCase): - def test_create_group_with_same_name_for_different_entities_of_same_type_success(self) -> None: - service_2 = self.add_services_to_cluster(service_names=["service_1_clone"], cluster=self.cluster_1).get() - component_of_service_2 = ServiceComponent.objects.get(service=service_2, prototype__name=self.component_1.name) - - with self.subTest("Cluster"): - self.assertEqual(GroupConfig.objects.filter(name=self.cluster_1_group_config.name).count(), 1) - - response = self.client.post( - path=reverse(viewname="v2:cluster-group-config-list", kwargs={"cluster_pk": self.cluster_2.pk}), - data={"name": self.cluster_1_group_config.name, "description": "group-config-new"}, - ) - - self.assertEqual(response.status_code, HTTP_201_CREATED) - self.assertEqual(GroupConfig.objects.filter(name=self.cluster_1_group_config.name).count(), 2) - - with self.subTest("Service"): - self.assertEqual(GroupConfig.objects.filter(name=self.service_1_group_config.name).count(), 1) - - response = self.client.post( - path=reverse( - viewname="v2:service-group-config-list", - kwargs={"cluster_pk": self.cluster_1.pk, "service_pk": service_2.pk}, - ), - data={"name": self.service_1_group_config.name, "description": "group-config-new"}, - ) - - self.assertEqual(response.status_code, HTTP_201_CREATED) - self.assertEqual(GroupConfig.objects.filter(name=self.service_1_group_config.name).count(), 2) - - with self.subTest("Component"): - name = "component_group" - self.assertEqual(GroupConfig.objects.filter(name=name).count(), 0) - - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": service_2.pk, - "component_pk": component_of_service_2.pk, - }, - ), - data={"name": name, "description": "group-config-new"}, - ) - - self.assertEqual(response.status_code, HTTP_201_CREATED) - self.assertEqual(GroupConfig.objects.filter(name=name).count(), 1) - - response = self.client.post( - path=reverse( - viewname="v2:component-group-config-list", - kwargs={ - "cluster_pk": self.cluster_1.pk, - "service_pk": self.service_1.pk, - "component_pk": self.component_1.pk, - }, - ), - data={"name": name, "description": "group-config-new"}, - ) - - self.assertEqual(response.status_code, HTTP_201_CREATED) - self.assertEqual(GroupConfig.objects.filter(name=name).count(), 2) - - class TestClusterGroupConfig(BaseClusterGroupConfigTestCase): def test_list_success(self): response = self.client.v2[self.cluster_1, CONFIG_GROUPS].get() From ea1b337098dd77cc8f20202aa63cdc8c428f9616 Mon Sep 17 00:00:00 2001 From: Aleksandr Alferov Date: Wed, 29 May 2024 14:55:58 +0300 Subject: [PATCH 139/208] Set dev ADCM_VERSION --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index d17a0408f2..285fcc237a 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ APP_IMAGE ?= hub.adsw.io/adcm/adcm APP_TAG ?= $(subst /,_,$(BRANCH_NAME)) SELENOID_HOST ?= 10.92.2.65 SELENOID_PORT ?= 4444 -ADCM_VERSION = "2.1.2" +ADCM_VERSION = "2.2.0-dev" PY_FILES = python dev/linters conf/adcm/python_scripts .PHONY: help From a1a9686bfd542dec60e526539636dc6bca2d6b5a Mon Sep 17 00:00:00 2001 From: Kirill Fedorenko Date: Wed, 29 May 2024 12:31:07 +0000 Subject: [PATCH 140/208] ADCM-5627 [UI] Remove unused CollapseTree from /uikit https://tracker.yandex.ru/ADCM-5627 --- .../CollapseTree/CollapseTree.module.scss | 59 -------- .../CollapseTree/CollapseTree.stories.tsx | 66 --------- .../uikit/CollapseTree/CollapseTree.tsx | 58 -------- .../NodeContent/NodeContent.module.scss | 137 ------------------ .../CollapseTree/NodeContent/NodeContent.tsx | 35 ----- 5 files changed, 355 deletions(-) delete mode 100644 adcm-web/app/src/components/uikit/CollapseTree/CollapseTree.module.scss delete mode 100644 adcm-web/app/src/components/uikit/CollapseTree/CollapseTree.stories.tsx delete mode 100644 adcm-web/app/src/components/uikit/CollapseTree/CollapseTree.tsx delete mode 100644 adcm-web/app/src/components/uikit/CollapseTree/NodeContent/NodeContent.module.scss delete mode 100644 adcm-web/app/src/components/uikit/CollapseTree/NodeContent/NodeContent.tsx diff --git a/adcm-web/app/src/components/uikit/CollapseTree/CollapseTree.module.scss b/adcm-web/app/src/components/uikit/CollapseTree/CollapseTree.module.scss deleted file mode 100644 index 9e44217082..0000000000 --- a/adcm-web/app/src/components/uikit/CollapseTree/CollapseTree.module.scss +++ /dev/null @@ -1,59 +0,0 @@ -.collapseTreeNode { - --tree-node-child-padding-top: 20px; - --tree-node-child-padding-left: 16px; - --tree-node-margin-bottom: 8px; - --tree-node-border-width: 1px; - - &:not(:last-child) { - padding-bottom: var(--tree-node-margin-bottom); - } - - &__trigger { - position: relative; - width: fit-content; - - &_enabled { - cursor: pointer; - } - } - - &__children { - margin-left: 40px; - } - - &__children &:first-child { - padding-top: var(--tree-node-child-padding-top); - } - - &__children & { - border-left : var(--tree-node-border-width) solid var(--tree-node-parent-border-color); - - padding-left: var(--tree-node-child-padding-left); - box-sizing: border-box; - } - - &__children &:last-child { - border-color: transparent; - } - - &__children &__trigger::before { - content: ''; - display: block; - position: absolute; - bottom: 50%; - right: 100%; - height: 50%; - width: var(--tree-node-child-padding-left); - border-bottom: var(--tree-node-border-width) solid var(--tree-node-child-border-color, var(--tree-node-parent-border-color)); - box-sizing: content-box; - } - - &__children &:last-child > &__trigger::before { - border-left: var(--tree-node-border-width) solid var(--tree-node-parent-border-color); - margin-top: -1px; - } - - &__children &:last-child:first-child > &__trigger::before { - height: calc(50% + var(--tree-node-child-padding-top)); - } -} diff --git a/adcm-web/app/src/components/uikit/CollapseTree/CollapseTree.stories.tsx b/adcm-web/app/src/components/uikit/CollapseTree/CollapseTree.stories.tsx deleted file mode 100644 index 8b5b260ecb..0000000000 --- a/adcm-web/app/src/components/uikit/CollapseTree/CollapseTree.stories.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react'; -import CollapseTree from './CollapseTree'; -import NodeContent, { Node } from './NodeContent/NodeContent'; -import cn from 'classnames'; -import s from './NodeContent/NodeContent.module.scss'; - -type Story = StoryObj; -export default { - title: 'uikit/CollapseTree', - component: CollapseTree, -} as Meta; - -const renderItem = (node: Node, isExpanded: boolean) => ( - { - console.info(node.title); - }} - /> -); - -const getNodeClassName = (node: Node) => - cn(s.collapseTreeNode, { - [s.collapseTreeNode_failed]: !node.isValid, - }); - -const CollapseComponentWithHooks = () => { - const model: Node = { - title: 'root', - isValid: false, - children: [ - { - title: 'ch1', - isValid: true, - children: [ - { - title: 'ch1-1', - isValid: true, - }, - ], - }, - { - title: 'ch2', - isValid: false, - }, - ], - }; - - return ( - <> - - - ); -}; - -export const CollapseComponent: Story = { - render: () => , -}; diff --git a/adcm-web/app/src/components/uikit/CollapseTree/CollapseTree.tsx b/adcm-web/app/src/components/uikit/CollapseTree/CollapseTree.tsx deleted file mode 100644 index be8084917f..0000000000 --- a/adcm-web/app/src/components/uikit/CollapseTree/CollapseTree.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React, { ReactNode, useState } from 'react'; -import s from './CollapseTree.module.scss'; -import Collapse from '@uikit/Collapse/Collapse'; -import { getValueByPath } from '@utils/objectUtils'; -import cn from 'classnames'; - -interface CollapseTreeProps { - renderNode: (model: T, isExpanded: boolean) => ReactNode; - childFieldName: keyof T; - uniqueFieldName: string; - getNodeClassName?: (model: T) => string; - model: T; -} -const CollapseTree = ({ - renderNode, - childFieldName, - uniqueFieldName, - model, - getNodeClassName, -}: CollapseTreeProps) => { - const [isExpanded, setIsExpanded] = useState(false); - const childrenModels = model[childFieldName] as T[]; - const hasChildren = childrenModels?.length > 0; - - const toggleCollapseNode = () => { - if (hasChildren) { - setIsExpanded((prev) => !prev); - } - }; - - return ( -
-
- {renderNode(model, isExpanded)} -
- {hasChildren && ( -
- - {childrenModels.map((childModel) => ( - - ))} - -
- )} -
- ); -}; -export default CollapseTree; diff --git a/adcm-web/app/src/components/uikit/CollapseTree/NodeContent/NodeContent.module.scss b/adcm-web/app/src/components/uikit/CollapseTree/NodeContent/NodeContent.module.scss deleted file mode 100644 index a1a4b78745..0000000000 --- a/adcm-web/app/src/components/uikit/CollapseTree/NodeContent/NodeContent.module.scss +++ /dev/null @@ -1,137 +0,0 @@ -:global { - body.theme-dark { - // closed - --node-content-border-default: var(--color-xdark); // - --node-content-background-default: transparent; // - --node-content-color-default: var(--color-xwhite); // - - // hover - --node-content-background-default-hover: var(--newDark30); - --node-content-color-default-hover: var(--color-white); - - // selected - --node-content-border-default-selected: var(--darkx1); - --node-content-background-default-selected: var(--color-black-3); - --node-content-color-default-selected: var(--color-green); - - // is-open - --node-content-border-default-open: var(--color-gray); - - // error - --node-content-border-failed: var(--color-dark-red); - --node-content-background-failed: transparent; - --node-content-color-failed: var(--color-xred); - - // selected - --node-content-background-failed-selected: var(--red10); - - --node-content-arrow-color: var(--color-xgray-light); - } - body.theme-light { - // closed - --node-content-border-default: var(--grayxLighter); - --node-content-background-default: transparent; - --node-content-color-default: var(--grayxLight); - - --node-content-background-default-hover: var(--color-gray-5); - --node-content-color-default-hover: var(--darkx1); - - --node-content-border-default-selected: var(--color-green); - --node-content-background-default-selected: var(--color-white-2); - --node-content-color-default-selected: var(--color-green); - - // is-open - --node-content-border-default-open: var(--darkx1); - - // error - --node-content-border-failed: var(--color-red); - --node-content-background-failed: transparent; - --node-content-color-failed: var(--color-red); - --node-content-background-failed-selected: var(--red10); - - --node-content-arrow-color: var(--grayxLight); - } -} - -.nodeContent { - font-weight: 400; - font-size: 14px; - line-height: 20px; - border: 2px solid var(--node-content-border, var(--node-content-border-default)); - border-radius: 8px; - padding: 8px 12px 8px 8px; - display: flex; - align-items: center; - cursor: pointer; - - transition: background-color 250ms, color 250ms, border-color 250ms; - - background: var(--node-content-background, var(--node-content-background-default)); - color: var(--node-content-color, var(--node-content-color-default)); - - &:hover { - --node-content-background: var(--node-content-background-default-hover); - --node-content-color: var(--node-content-color-default-hover); - } - - &:global(.is-selected) { - --node-content-border: var(--node-content-border-default-selected); - --node-content-background: var(--node-content-background-default-selected); - --node-content-color: var(--node-content-color-default-selected); - } - - &:global(.is-open) { - --node-content-border: var(--node-content-border-default-open); - } - - &__title { - margin-left: 8px; - } - - &:not(:global(.is-open)) &__arrow { - transform: rotate(-90deg); - } - &__arrow { - margin-left: 20px; - color: var(--node-content-arrow-color); - } - - svg { - flex-shrink: 0; - } -} - -.collapseTreeNode { - --tree-node-parent-border-color: var(--node-content-border-default); - --tree-node-child-border-color: var(--node-content-border-default); - - &_failed { - --tree-node-child-border-color: var(--node-content-border-failed); - - & > div > div > .collapseTreeNode { - --tree-node-parent-border-color: var(--node-content-border-failed); - } - } -} - -.collapseTreeNode_failed > div > .nodeContent { - --node-content-border: var(--node-content-border-failed); - --node-content-background: var(--node-content-background-failed); - --node-content-color: var(--node-content-color-failed); - - &:hover { - --node-content-color: var(--node-content-color-failed); - --node-content-background: var(--node-content-background-default-hover); - } - - &:global(.is-selected) { - --node-content-border: var(--node-content-border-failed); - --node-content-color: var(--node-content-color-failed); - --node-content-background: var(--node-content-background-failed-selected); - } - - &:global(.is-open) { - --node-content-border: var(--node-content-border-failed); - } - -} diff --git a/adcm-web/app/src/components/uikit/CollapseTree/NodeContent/NodeContent.tsx b/adcm-web/app/src/components/uikit/CollapseTree/NodeContent/NodeContent.tsx deleted file mode 100644 index b0c8b994e6..0000000000 --- a/adcm-web/app/src/components/uikit/CollapseTree/NodeContent/NodeContent.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import cn from 'classnames'; -import { Icon } from '@uikit'; -import s from './NodeContent.module.scss'; - -export type Node = { title: string; children?: Node[]; isValid: boolean }; - -interface CollapseTreeNodeProps { - node: Node; - isExpanded: boolean; - isSelected: boolean; - onClick: (node: Node) => void; -} - -const NodeContent: React.FC = ({ node, isExpanded, isSelected, onClick }) => { - const className = cn(s.nodeContent, { - 'is-open': isExpanded, - 'is-selected': isSelected, - 'is-failed': !node.isValid, - }); - - const handleClick = () => { - onClick(node); - }; - - return ( -
- {!node.isValid && } - {node.title} - {node.children?.length && } -
- ); -}; - -export default NodeContent; From d5b1752f7558d5892ecc026472f40d2ef52c1b0c Mon Sep 17 00:00:00 2001 From: Skrynnik Daniil Date: Wed, 29 May 2024 16:03:11 +0300 Subject: [PATCH 141/208] ADCM-5581: remove `reverse` from test_role.py --- python/adcm/tests/client.py | 3 +- python/api_v2/tests/test_role.py | 59 ++++++++++---------------------- 2 files changed, 20 insertions(+), 42 deletions(-) diff --git a/python/adcm/tests/client.py b/python/adcm/tests/client.py index 9c2a1830c9..24d97d80cc 100644 --- a/python/adcm/tests/client.py +++ b/python/adcm/tests/client.py @@ -27,7 +27,7 @@ ServiceComponent, TaskLog, ) -from rbac.models import Policy, User +from rbac.models import Policy, Role, User from rest_framework.response import Response from rest_framework.test import APIClient @@ -93,6 +93,7 @@ class V2RootNode(RootNode): Prototype: "prototypes", Policy: "rbac/policies", User: "rbac/users", + Role: "rbac/roles", } def __getitem__(self, item: PathObject | tuple[PathObject, str | int | WithID, ...]) -> APINode: diff --git a/python/api_v2/tests/test_role.py b/python/api_v2/tests/test_role.py index ad65f5ce51..eac8bfec80 100644 --- a/python/api_v2/tests/test_role.py +++ b/python/api_v2/tests/test_role.py @@ -11,7 +11,6 @@ # limitations under the License. from django.db.models import Count -from django.urls import reverse from rbac.models import Role from rbac.services.group import create as create_group from rbac.services.policy import policy_create @@ -42,15 +41,13 @@ def setUp(self) -> None: ) def test_retrieve_not_found_fail(self): - response = self.client.get( - path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": self.cluster_config_role.pk + 10}) - ) + response = (self.client.v2 / "rbac" / "roles" / str(self.get_non_existent_pk(model=Role))).get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_retrieve_hidden_not_found_fail(self): hidden_role = Role.objects.filter(type="hidden").first() - response = self.client.get(path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": hidden_role.pk})) + response = self.client.v2[hidden_role].get() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) @@ -58,30 +55,25 @@ def test_retrieve_hidden_child_not_shown_success(self): role_with_hidden_children = ( Role.objects.annotate(num_children=Count("child")).filter(num_children__gt=0, child__type="hidden").first() ) - response = self.client.get( - path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": role_with_hidden_children.pk}) - ) + response = self.client.v2[role_with_hidden_children].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()["children"]), 0) def test_retrieve_success(self): - response = self.client.get( - path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": self.cluster_config_role.pk}) - ) + response = self.client.v2[self.cluster_config_role].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["id"], self.cluster_config_role.pk) def test_list_success(self): - response = self.client.get(path=reverse(viewname="v2:rbac:role-list")) + response = (self.client.v2 / "rbac" / "roles").get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertGreater(len(response.json()["results"]), 1) def test_create_success(self): - response = self.client.post( - path=reverse(viewname="v2:rbac:role-list"), + response = (self.client.v2 / "rbac" / "roles").post( data={"display_name": "Edit cluster configuration", "children": [self.edit_cluster_config_role.pk]}, ) @@ -89,7 +81,7 @@ def test_create_success(self): self.assertTrue(Role.objects.filter(id=response.json()["id"]).exists()) def test_create_required_field_failed(self): - response = self.client.post(path=reverse(viewname="v2:rbac:role-list"), data={"display_name": "test"}) + response = (self.client.v2 / "rbac" / "roles").post(data={"display_name": "test"}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.assertDictEqual( @@ -97,8 +89,7 @@ def test_create_required_field_failed(self): ) def test_create_already_exists_failed(self): - response = self.client.post( - path=reverse(viewname="v2:rbac:role-list"), + response = (self.client.v2 / "rbac" / "roles").post( data={ "display_name": "Change cluster config", "children": [self.view_cluster_config_role.pk], @@ -116,8 +107,7 @@ def test_create_already_exists_failed(self): ) def test_update_required_filed_success(self): - response = self.client.patch( - path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": self.cluster_config_role.pk}), + response = self.client.v2[self.cluster_config_role].patch( data={ "display_name": "New change cluster config", "children": [self.edit_cluster_config_role.pk], @@ -131,8 +121,7 @@ def test_update_required_filed_success(self): self.assertEqual([self.edit_cluster_config_role], list(self.cluster_config_role.child.all())) def test_partial_update_success(self): - response = self.client.patch( - path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": self.cluster_config_role.pk}), + response = self.client.v2[self.cluster_config_role].patch( data={"display_name": "New change cluster config"}, ) @@ -142,8 +131,7 @@ def test_partial_update_success(self): self.assertEqual("New change cluster config", self.cluster_config_role.display_name) def test_update_built_in_failed(self): - response = self.client.patch( - path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": self.view_cluster_config_role.pk}), + response = self.client.v2[self.view_cluster_config_role].patch( data={"built_in": False}, ) self.assertEqual(response.status_code, HTTP_409_CONFLICT) @@ -157,9 +145,7 @@ def test_update_built_in_failed(self): ) def test_delete_success(self): - response = self.client.delete( - path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": self.cluster_config_role.pk}) - ) + response = self.client.v2[self.cluster_config_role].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.assertFalse(Role.objects.filter(pk=self.cluster_config_role.pk).exists()) @@ -167,7 +153,7 @@ def test_delete_success(self): def test_delete_failed(self): built_in_role = Role.objects.filter(built_in=True).exclude(type="hidden").first() - response = self.client.delete(path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": built_in_role.pk})) + response = self.client.v2[built_in_role].delete() self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertDictEqual( @@ -186,9 +172,7 @@ def test_delete_role_in_policy_fail(self): object=[self.cluster_1], ) - response = self.client.delete( - path=reverse(viewname="v2:rbac:role-detail", kwargs={"pk": custom_role_in_policy.pk}) - ) + response = self.client.v2[custom_role_in_policy].delete() self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertDictEqual( @@ -199,12 +183,7 @@ def test_delete_role_in_policy_fail(self): def test_ordering_success(self): limit = 10 - response = self.client.get( - path=reverse( - viewname="v2:rbac:role-list", - ), - data={"ordering": "-displayName", "limit": limit}, - ) + response = (self.client.v2 / "rbac" / "roles").get(query={"ordering": "-displayName", "limit": limit}) self.assertEqual(response.status_code, HTTP_200_OK) @@ -215,7 +194,7 @@ def test_ordering_success(self): def test_filtering_by_display_name_success(self): filter_name = "cReAtE" - response = self.client.get(path=reverse(viewname="v2:rbac:role-list"), data={"displayName": filter_name}) + response = (self.client.v2 / "rbac" / "roles").get(query={"displayName": filter_name}) self.assertEqual(response.status_code, HTTP_200_OK) @@ -224,15 +203,13 @@ def test_filtering_by_display_name_success(self): self.assertListEqual(sorted(response_pks), sorted(db_pks)) def test_filtering_by_categories_success(self): - response = self.client.get(path=reverse(viewname="v2:rbac:role-list"), data={"categories": "cluster_one"}) + response = (self.client.v2 / "rbac" / "roles").get(query={"categories": "cluster_one"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 34) def test_list_object_candidates_success(self): - response = self.client.get( - path=reverse(viewname="v2:rbac:role-object-candidates", kwargs={"pk": self.cluster_config_role.pk}) - ) + response = self.client.v2[self.cluster_config_role, "object-candidates"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()["cluster"]), 2) From 010b62fb76a47c8e994371f12c20395a5d441da9 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Thu, 30 May 2024 06:57:20 +0000 Subject: [PATCH 142/208] ADCM-5598 Adjust status calculations in Status Server --- go/adcm/status/status.go | 20 ++++++++++++++++++-- python/cm/services/job/run/_task.py | 8 ++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/go/adcm/status/status.go b/go/adcm/status/status.go index 6a99213381..2cd39a059c 100644 --- a/go/adcm/status/status.go +++ b/go/adcm/status/status.go @@ -201,8 +201,24 @@ func getClusterServiceStatus(h Hub, clusterId int) (int, map[int]serviceStatus) srvStatus, hcStatus := getServiceStatus(h, clusterId, serviceId) componentStatusMap := make(map[int]Status) for _, hcStatusEntry := range hcStatus { - status, _ := getComponentStatus(h, hcStatusEntry.Component) - componentStatusMap[hcStatusEntry.Component] = status + entry, exists := componentStatusMap[hcStatusEntry.Component] + hostOrComponentInMM := h.MMObjects.IsComponentInMM(hcStatusEntry.Component) || h.MMObjects.IsHostInMM(hcStatusEntry.Host) + if !exists { + if hostOrComponentInMM { + componentStatusMap[hcStatusEntry.Component] = Status{Status: 0} + } else { + componentStatusMap[hcStatusEntry.Component] = Status{Status: hcStatusEntry.Status} + } + + continue + } + + if hostOrComponentInMM || entry.Status != 0 { + // it's in MM OR already not ok, no need to calculate + continue + } + + componentStatusMap[hcStatusEntry.Component] = Status{Status: hcStatusEntry.Status} } services[serviceId] = serviceStatus{ Status: srvStatus.Status, diff --git a/python/cm/services/job/run/_task.py b/python/cm/services/job/run/_task.py index bd9c101862..aa00e6058e 100644 --- a/python/cm/services/job/run/_task.py +++ b/python/cm/services/job/run/_task.py @@ -34,6 +34,10 @@ def restart_task(task: TaskLog) -> None: def _run_task(task: TaskLog, command: Literal["start", "restart"]): + tree = Tree(obj=task.task_object) + affected_objs = (node.value for node in tree.get_all_affected(node=tree.built_from)) + lock_affected_objects(task=task, objects=affected_objs) + err_file = open( # noqa: SIM115 Path(settings.LOG_DIR, "task_runner.err"), "a+", @@ -50,7 +54,3 @@ def _run_task(task: TaskLog, command: Literal["start", "restart"]): args=cmd, stderr=err_file, env=get_env_with_venv_path(venv=task.action.venv) ) logger.info("task run #%s, python process %s", task.pk, proc.pid) - - tree = Tree(obj=task.task_object) - affected_objs = (node.value for node in tree.get_all_affected(node=tree.built_from)) - lock_affected_objects(task=task, objects=affected_objs) From 134e81fe6343695370fe61934981c031eb35052c Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Thu, 30 May 2024 07:22:07 +0000 Subject: [PATCH 143/208] ADCM-5607: statistics sender --- python/cm/collect_statistics/errors.py | 27 +++++ python/cm/collect_statistics/senders.py | 81 ++++++++++++- .../commands/collect_statistics_new.py | 41 ++++++- python/cm/tests/test_management_commands.py | 108 +++++++++++++++++- 4 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 python/cm/collect_statistics/errors.py diff --git a/python/cm/collect_statistics/errors.py b/python/cm/collect_statistics/errors.py new file mode 100644 index 0000000000..a9fe1e3f64 --- /dev/null +++ b/python/cm/collect_statistics/errors.py @@ -0,0 +1,27 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +class BaseStatisticsError(Exception): + pass + + +class SenderError(BaseStatisticsError): + pass + + +class SenderConnectionError(SenderError): + pass + + +class RetriesExceededError(SenderError): + pass diff --git a/python/cm/collect_statistics/senders.py b/python/cm/collect_statistics/senders.py index 5872f42fcf..4e7d708ea6 100644 --- a/python/cm/collect_statistics/senders.py +++ b/python/cm/collect_statistics/senders.py @@ -10,12 +10,91 @@ # See the License for the specific language governing permissions and # limitations under the License. +from collections import deque +from dataclasses import dataclass from pathlib import Path +from time import sleep, time from typing import Collection +from requests.exceptions import ConnectionError +from rest_framework.status import HTTP_201_CREATED, HTTP_405_METHOD_NOT_ALLOWED +import requests + +from cm.collect_statistics.errors import RetriesExceededError, SenderConnectionError from cm.collect_statistics.types import Sender +@dataclass(frozen=True, slots=True) +class SenderSettings: + url: str + adcm_uuid: str + retries_limit: int + retries_frequency: int + request_timeout: float + + class StatisticSender(Sender[Path]): + __slots__ = ("settings",) + + def __init__(self, settings: SenderSettings): + self.settings = settings + def send(self, targets: Collection[Path]) -> None: - pass + if not targets: + return + + self._check_connection() + + failed = deque() + + for try_number in range(self.settings.retries_limit): + last_try_timestamp = time() + + for target in targets: + if not self._send(target=target): + failed.append(target) + + if not failed: + break + + targets, failed = failed, deque() + + if try_number < self.settings.retries_limit - 1: # skip last iteration self._sleep() call + self._sleep(timestamp=last_try_timestamp, frequency=self.settings.retries_frequency) + + else: + raise RetriesExceededError(f"None of the {self.settings.retries_limit} attempts was successful") + + def _send(self, target: Path) -> bool: + with target.open(mode="rb") as f: + try: + response = requests.post( + url=self.settings.url, + headers={"Adcm-UUID": self.settings.adcm_uuid, "accept": "application/json"}, + files={"file": f}, + timeout=self.settings.request_timeout, + ) + except ConnectionError: + return False + + return response.status_code == HTTP_201_CREATED + + def _check_connection(self) -> None: + """Expecting 405 response on HEAD request without headers""" + + try: + response = requests.head(url=self.settings.url, headers={}, timeout=self.settings.request_timeout) + except ConnectionError as e: + raise SenderConnectionError(f"Check connection: can't connect to {self.settings.url}") from e + + if response.status_code != HTTP_405_METHOD_NOT_ALLOWED: + raise SenderConnectionError( + f"Check connection: wrong return code for {self.settings.url}: {response.status_code}" + ) + + @staticmethod + def _sleep(timestamp: float, frequency: int) -> None: + sleep_seconds = timestamp + frequency - time() + sleep_seconds = max(sleep_seconds, 0) + + sleep(sleep_seconds) diff --git a/python/cm/management/commands/collect_statistics_new.py b/python/cm/management/commands/collect_statistics_new.py index 6f9cbd6c1a..aa8b63968d 100644 --- a/python/cm/management/commands/collect_statistics_new.py +++ b/python/cm/management/commands/collect_statistics_new.py @@ -10,11 +10,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import NamedTuple +from urllib.parse import urlunparse +import os import socket from django.conf import settings from django.core.management import BaseCommand +from cm.adcm_config.config import get_adcm_config from cm.collect_statistics.collectors import ( ADCMEntities, CommunityBundleCollector, @@ -22,10 +26,21 @@ RBACCollector, ) from cm.collect_statistics.encoders import TarFileEncoder -from cm.collect_statistics.senders import StatisticSender +from cm.collect_statistics.senders import SenderSettings, StatisticSender from cm.collect_statistics.storages import JSONFile, TarFileWithJSONFileStorage, TarFileWithTarFileStorage from cm.models import ADCM +SENDER_REQUEST_TIMEOUT = 15.0 + + +class URLComponents(NamedTuple): + scheme: str + netloc: str + path: str + params: str = "" + query: str = "" + fragment: str = "" + def is_internal() -> bool: try: @@ -35,6 +50,21 @@ def is_internal() -> bool: return False +def get_statistics_url() -> str: + scheme = "http" + url_path = "/api/v1/statistic/adcm" + + if (netloc := os.getenv("STATISTICS_URL")) is None: + _, config = get_adcm_config(section="statistics_collection") + netloc = config["url"] + + if len(splitted := netloc.split("://")) == 2: + scheme = splitted[0] + netloc = splitted[1] + + return urlunparse(components=URLComponents(scheme=scheme, netloc=netloc, path=url_path)) + + class Command(BaseCommand): help = "Collect data and send to Statistic Server" @@ -89,5 +119,12 @@ def handle(self, *_, full: bool, send: bool, encode: bool, **__): encoder.encode(final_archive) if send: - sender = StatisticSender() + sender_settings = SenderSettings( + url=get_statistics_url(), + adcm_uuid=statistics_data["adcm"]["uuid"], + retries_limit=int(os.getenv("STATISTICS_RETRIES", 10)), + retries_frequency=int(os.getenv("STATISTICS_FREQUENCY", 1 * 60 * 60)), # in seconds + request_timeout=SENDER_REQUEST_TIMEOUT, + ) + sender = StatisticSender(settings=sender_settings) sender.send([community_archive]) diff --git a/python/cm/tests/test_management_commands.py b/python/cm/tests/test_management_commands.py index 9614bc5d13..4feb1f7dc6 100644 --- a/python/cm/tests/test_management_commands.py +++ b/python/cm/tests/test_management_commands.py @@ -10,12 +10,19 @@ # See the License for the specific language governing permissions and # limitations under the License. +from pathlib import Path +from unittest.mock import Mock, call, mock_open, patch -from api_v2.tests.base import BaseAPITestCase +from api_v2.tests.base import BaseAPITestCase, ParallelReadyTestCase from django.conf import settings from django.core.management import load_command_class +from django.test import TestCase from rbac.models import Policy, Role, User +from requests.exceptions import ConnectionError +from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_405_METHOD_NOT_ALLOWED +from cm.collect_statistics.errors import RetriesExceededError, SenderConnectionError +from cm.collect_statistics.senders import SenderSettings, StatisticSender from cm.models import ADCM, Bundle, ServiceComponent from cm.tests.utils import gen_cluster, gen_provider @@ -196,3 +203,102 @@ def test_data_success(self): self.assertListEqual(data["data"]["providers"], expected_data["data"]["providers"]) self.assertListEqual(data["data"]["users"], expected_data["data"]["users"]) self.assertListEqual(data["data"]["roles"], expected_data["data"]["roles"]) + + +class MockResponse: + def __init__(self, status_code): + self.status_code = status_code + + +class TestSender(TestCase, ParallelReadyTestCase): + maxDiff = None + + def setUp(self): + self.settings = SenderSettings( + url="https://www.test.url", + adcm_uuid="TEST", + retries_limit=2, + retries_frequency=0, + request_timeout=0.1, + ) + + @patch.object(target=Path, attribute="open", new_callable=mock_open()) + @patch("cm.collect_statistics.senders.requests") + def test_success(self, mocked_requests, mocked_open): + mocked_requests.head.return_value = MockResponse(status_code=HTTP_405_METHOD_NOT_ALLOWED) + mocked_requests.post.return_value = MockResponse(status_code=HTTP_201_CREATED) + + sender = StatisticSender(settings=self.settings) + sender.send(targets=[Path("/some/path.file"), Path("/other/path.file")]) + + self.assertEqual(mocked_open.call_count, 2) + + mocked_requests.head.assert_called_once_with( + url=self.settings.url, headers={}, timeout=self.settings.request_timeout + ) + + self.assertEqual(mocked_requests.post.call_count, 2) + self.assertListEqual( + mocked_requests.post.call_args_list, + [ + call( + url=self.settings.url, + headers={"Adcm-UUID": "TEST", "accept": "application/json"}, + files={"file": mocked_open().__enter__()}, + timeout=self.settings.request_timeout, + ) + ] + * 2, + ) + + @patch("cm.collect_statistics.senders.requests") + def test_connection_fail(self, mocked_requests): + sender = StatisticSender(settings=self.settings) + + mocked_requests.head.return_value = MockResponse(status_code=HTTP_200_OK) + + with self.assertRaises(expected_exception=SenderConnectionError) as err_status: + sender.send(targets=[Path("/some/path.file")]) + self.assertEqual( + str(err_status.exception), f"Check connection: wrong return code for {self.settings.url}: {HTTP_200_OK}" + ) + + mocked_requests.head.return_value = MockResponse(status_code=HTTP_405_METHOD_NOT_ALLOWED) + mocked_requests.head = Mock(side_effect=ConnectionError) + + with self.assertRaises(expected_exception=SenderConnectionError) as err_post: + sender.send(targets=[Path("/some/path.file")]) + self.assertEqual(str(err_post.exception), f"Check connection: can't connect to {self.settings.url}") + + @patch.object(target=Path, attribute="open", new_callable=mock_open()) + @patch("cm.collect_statistics.senders.requests") + def test_retries_fail(self, mocked_requests, mocked_open): # noqa: ARG002 + mocked_requests.head.return_value = MockResponse(status_code=HTTP_405_METHOD_NOT_ALLOWED) + mocked_requests.post = Mock(side_effect=ConnectionError) + + sender = StatisticSender(settings=self.settings) + with self.assertRaises(expected_exception=RetriesExceededError) as err_retries: + sender.send(targets=[Path("/some/path.file")]) + self.assertEqual( + str(err_retries.exception), f"None of the {self.settings.retries_limit} attempts was successful" + ) + + @patch.object(target=Path, attribute="open", new_callable=mock_open()) + @patch("cm.collect_statistics.senders.requests") + def test_retry_only_failed(self, mocked_requests, mocked_open): # noqa: ARG002 + mocked_requests.head.return_value = MockResponse(status_code=HTTP_405_METHOD_NOT_ALLOWED) + mocked_requests.post.side_effect = [ + MockResponse(status_code=HTTP_201_CREATED), + MockResponse(status_code=0), + MockResponse(status_code=HTTP_201_CREATED), + ] + + file_1, file_2 = Path("/some/path.file"), Path("/other/path.file") + + sender = StatisticSender(settings=self.settings) + with patch.object(target=sender, attribute="_send", wraps=sender._send) as mocked_inner_send: + sender.send(targets=[file_1, file_2]) + + self.assertListEqual( + mocked_inner_send.call_args_list, [call(target=file_1), call(target=file_2), call(target=file_2)] + ) From 610dcc4cbdde9673820cb739c6e8b45a869879e3 Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Thu, 30 May 2024 07:24:59 +0000 Subject: [PATCH 144/208] ADCM-5436: Rework and add unittests for `adcm_multi_state_unset` --- .../plugins/action/adcm_multi_state_unset.py | 112 +------- python/ansible_plugin/base.py | 6 +- .../ansible_plugin/executors/_validators.py | 11 +- .../executors/multi_state_set.py | 13 +- .../executors/multi_state_unset.py | 69 +++++ python/ansible_plugin/executors/state.py | 13 +- python/ansible_plugin/messages.py | 3 - ...tate_set.py => test_adcm_state_plugins.py} | 157 ++++++----- python/ansible_plugin/utils.py | 244 ------------------ 9 files changed, 202 insertions(+), 426 deletions(-) create mode 100644 python/ansible_plugin/executors/multi_state_unset.py rename python/ansible_plugin/tests/{test_adcm_state_multi_state_set.py => test_adcm_state_plugins.py} (60%) diff --git a/python/ansible/plugins/action/adcm_multi_state_unset.py b/python/ansible/plugins/action/adcm_multi_state_unset.py index 5ff35bb38a..96371a13ac 100644 --- a/python/ansible/plugins/action/adcm_multi_state_unset.py +++ b/python/ansible/plugins/action/adcm_multi_state_unset.py @@ -17,16 +17,6 @@ import adcm.init_django # noqa: F401, isort:skip -from ansible_plugin.utils import ( - ContextActionModule, - unset_cluster_multi_state, - unset_component_multi_state, - unset_component_multi_state_by_name, - unset_host_multi_state, - unset_provider_multi_state, - unset_service_multi_state, - unset_service_multi_state_by_name, -) ANSIBLE_METADATA = {"metadata_version": "1.1", "supported_by": "Arenadata"} @@ -44,9 +34,9 @@ choises: - cluster - service + - component - provider - host - - component description: type of object which should be changed - option-name: state @@ -66,6 +56,11 @@ description: useful in cluster and component context only. In that context you are able to set the state for a component belongs to the service + - option-name: host_id + required: false + type: int + description: ID of the host. Useful in provider context only + - option-name: missing_ok required: false type: boolean @@ -104,92 +99,9 @@ """ -class ActionModule(ContextActionModule): - TRANSFERS_FILES = False - _VALID_ARGS = frozenset(("type", "service_name", "component_name", "state", "missing_ok", "host_id")) - _MANDATORY_ARGS = ("type", "state") - - def _do_cluster(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - unset_cluster_multi_state, - context["cluster_id"], - self._task.args["state"], - self._task.args.get("missing_ok", False), - ) - res["state"] = self._task.args["state"] - return res - - def _do_service_by_name(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - unset_service_multi_state_by_name, - context["cluster_id"], - self._task.args["service_name"], - self._task.args["state"], - self._task.args.get("missing_ok", False), - ) - res["state"] = self._task.args["state"] - return res - - def _do_service(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - unset_service_multi_state, - context["cluster_id"], - context["service_id"], - self._task.args["state"], - self._task.args.get("missing_ok", False), - ) - res["state"] = self._task.args["state"] - return res - - def _do_host(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - unset_host_multi_state, - context["host_id"], - self._task.args["state"], - self._task.args.get("missing_ok", False), - ) - res["state"] = self._task.args["state"] - return res - - def _do_provider(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - unset_provider_multi_state, - context["provider_id"], - self._task.args["state"], - self._task.args.get("missing_ok", False), - ) - res["state"] = self._task.args["state"] - return res - - def _do_host_from_provider(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - unset_host_multi_state, - self._task.args["host_id"], - self._task.args["state"], - self._task.args.get("missing_ok", False), - ) - res["state"] = self._task.args["state"] - return res - - def _do_component_by_name(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - unset_component_multi_state_by_name, - context["cluster_id"], - context["service_id"], - self._task.args["component_name"], - self._task.args.get("service_name", None), - self._task.args["state"], - self._task.args.get("missing_ok", False), - ) - res["state"] = self._task.args["state"] - return res - - def _do_component(self, task_vars, context): # noqa: ARG002 - res = self._wrap_call( - unset_component_multi_state, - context["component_id"], - self._task.args["state"], - self._task.args.get("missing_ok", False), - ) - res["state"] = self._task.args["state"] - return res +from ansible_plugin.base import ADCMAnsiblePlugin +from ansible_plugin.executors.multi_state_unset import ADCMMultiStateUnsetPluginExecutor + + +class ActionModule(ADCMAnsiblePlugin): + executor_class = ADCMMultiStateUnsetPluginExecutor diff --git a/python/ansible_plugin/base.py b/python/ansible_plugin/base.py index d67bdaa8fd..0c519f9d49 100644 --- a/python/ansible_plugin/base.py +++ b/python/ansible_plugin/base.py @@ -12,7 +12,7 @@ from abc import abstractmethod from dataclasses import dataclass -from typing import Any, Collection, Generic, Literal, Mapping, Protocol, TypeAlias, TypeVar +from typing import Any, Collection, Generic, Literal, Mapping, Protocol, TypeAlias, TypedDict, TypeVar import fcntl from ansible.errors import AnsibleActionFail @@ -252,6 +252,10 @@ class SingleStateArgument(BaseModel): state: str +class SingleStateReturnValue(TypedDict): + state: str + + @dataclass(frozen=True, slots=True) class CallResult(Generic[ReturnValue]): # If value is a mapping of some sort, it'll be unpacked into return dict. diff --git a/python/ansible_plugin/executors/_validators.py b/python/ansible_plugin/executors/_validators.py index 1359d9e3dc..340a73e0a1 100644 --- a/python/ansible_plugin/executors/_validators.py +++ b/python/ansible_plugin/executors/_validators.py @@ -14,7 +14,7 @@ from core.types import ADCMCoreType, CoreObjectDescriptor -from ansible_plugin.base import VarsContextSection +from ansible_plugin.base import VarsContextSection, retrieve_orm_object from ansible_plugin.errors import PluginTargetError, PluginValidationError _CLUSTER_TYPES = {ADCMCoreType.CLUSTER, ADCMCoreType.SERVICE, ADCMCoreType.COMPONENT} @@ -46,11 +46,18 @@ def validate_target_allowed_for_context_owner( f"Allowed: {', '.join(sorted(map(attrgetter('value'), owner_types)))}." ) - if target.type == ADCMCoreType.HOST and target.id != context_owner.id: + if context_owner.type == ADCMCoreType.HOST and target.type == ADCMCoreType.HOST and target.id != context_owner.id: # only case it'll happen (in terms of plugin call): # context is "host" AND "type" is "host" AND "host_id" is specified as not the same as one in context return PluginTargetError(message="Wrong context. One host can't be changed from another's context.") + if ( + context_owner.type == ADCMCoreType.HOSTPROVIDER + and target.type == ADCMCoreType.HOST + and retrieve_orm_object(object_=target).provider_id != context_owner.id + ): + return PluginTargetError(message="Wrong context. Can't operate on not own host.") + return None diff --git a/python/ansible_plugin/executors/multi_state_set.py b/python/ansible_plugin/executors/multi_state_set.py index bf2c4c8e2e..0879c1fee0 100644 --- a/python/ansible_plugin/executors/multi_state_set.py +++ b/python/ansible_plugin/executors/multi_state_set.py @@ -10,7 +10,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Collection, TypedDict +from typing import Collection from core.types import CoreObjectDescriptor @@ -21,6 +21,7 @@ PluginExecutorConfig, RuntimeEnvironment, SingleStateArgument, + SingleStateReturnValue, TargetConfig, from_arguments_root, retrieve_orm_object, @@ -28,11 +29,7 @@ from ansible_plugin.executors._validators import validate_target_allowed_for_context_owner, validate_type_is_present -class ChangeMultiStateReturnValue(TypedDict): - state: str - - -class ADCMMultiStateSetPluginExecutor(ADCMAnsiblePluginExecutor[SingleStateArgument, ChangeMultiStateReturnValue]): +class ADCMMultiStateSetPluginExecutor(ADCMAnsiblePluginExecutor[SingleStateArgument, SingleStateReturnValue]): _config = PluginExecutorConfig( arguments=ArgumentsConfig(represent_as=SingleStateArgument), target=TargetConfig(detectors=(from_arguments_root,), validators=(validate_type_is_present,)), @@ -43,7 +40,7 @@ def __call__( targets: Collection[CoreObjectDescriptor], arguments: SingleStateArgument, runtime: RuntimeEnvironment, - ) -> CallResult[ChangeMultiStateReturnValue]: + ) -> CallResult[SingleStateReturnValue]: target, *_ = targets if error := validate_target_allowed_for_context_owner(context_owner=runtime.context_owner, target=target): @@ -52,4 +49,4 @@ def __call__( target_object = retrieve_orm_object(object_=target) target_object.set_multi_state(arguments.state) - return CallResult(value=ChangeMultiStateReturnValue(state=arguments.state), changed=True, error=None) + return CallResult(value=SingleStateReturnValue(state=arguments.state), changed=True, error=None) diff --git a/python/ansible_plugin/executors/multi_state_unset.py b/python/ansible_plugin/executors/multi_state_unset.py new file mode 100644 index 0000000000..81f1ea776d --- /dev/null +++ b/python/ansible_plugin/executors/multi_state_unset.py @@ -0,0 +1,69 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from contextlib import suppress +from typing import Collection + +from cm.status_api import send_object_update_event +from core.types import CoreObjectDescriptor +from pydantic import BaseModel + +from ansible_plugin.base import ( + ADCMAnsiblePluginExecutor, + ArgumentsConfig, + CallResult, + PluginExecutorConfig, + RuntimeEnvironment, + SingleStateReturnValue, + TargetConfig, + from_arguments_root, + retrieve_orm_object, +) +from ansible_plugin.errors import PluginRuntimeError +from ansible_plugin.executors._validators import validate_target_allowed_for_context_owner, validate_type_is_present + + +class MultiStateUnsetArguments(BaseModel): + state: str + missing_ok: bool = False + + +class ADCMMultiStateUnsetPluginExecutor(ADCMAnsiblePluginExecutor[MultiStateUnsetArguments, SingleStateReturnValue]): + _config = PluginExecutorConfig( + arguments=ArgumentsConfig(represent_as=MultiStateUnsetArguments), + target=TargetConfig(detectors=(from_arguments_root,), validators=(validate_type_is_present,)), + ) + + def __call__( + self, + targets: Collection[CoreObjectDescriptor], + arguments: MultiStateUnsetArguments, + runtime: RuntimeEnvironment, + ) -> CallResult[SingleStateReturnValue]: + target, *_ = targets + + if error := validate_target_allowed_for_context_owner(context_owner=runtime.context_owner, target=target): + return CallResult(value=None, changed=False, error=error) + + target_object = retrieve_orm_object(object_=target) + + if not arguments.missing_ok and arguments.state not in target_object.multi_state: + raise PluginRuntimeError( + f'Can not delete missing multi-state "{arguments.state}". ' + f'Set missing_ok to "True" or choose an existing multi-state' + ) + + target_object.unset_multi_state(arguments.state) + with suppress(Exception): + send_object_update_event(object_=target_object, changes={"state": arguments.state}) + + return CallResult(value=SingleStateReturnValue(state=arguments.state), changed=True, error=None) diff --git a/python/ansible_plugin/executors/state.py b/python/ansible_plugin/executors/state.py index 9f865f53a8..76b53d87c0 100644 --- a/python/ansible_plugin/executors/state.py +++ b/python/ansible_plugin/executors/state.py @@ -11,7 +11,7 @@ # limitations under the License. from contextlib import suppress -from typing import Collection, TypedDict +from typing import Collection from cm.status_api import send_object_update_event from core.types import CoreObjectDescriptor @@ -23,6 +23,7 @@ PluginExecutorConfig, RuntimeEnvironment, SingleStateArgument, + SingleStateReturnValue, TargetConfig, from_arguments_root, retrieve_orm_object, @@ -30,11 +31,7 @@ from ansible_plugin.executors._validators import validate_target_allowed_for_context_owner, validate_type_is_present -class ChangeStateReturnValue(TypedDict): - state: str - - -class ADCMStatePluginExecutor(ADCMAnsiblePluginExecutor[SingleStateArgument, ChangeStateReturnValue]): +class ADCMStatePluginExecutor(ADCMAnsiblePluginExecutor[SingleStateArgument, SingleStateReturnValue]): _config = PluginExecutorConfig( arguments=ArgumentsConfig(represent_as=SingleStateArgument), target=TargetConfig(detectors=(from_arguments_root,), validators=(validate_type_is_present,)), @@ -45,7 +42,7 @@ def __call__( targets: Collection[CoreObjectDescriptor], arguments: SingleStateArgument, runtime: RuntimeEnvironment, - ) -> CallResult[ChangeStateReturnValue]: + ) -> CallResult[SingleStateReturnValue]: target, *_ = targets if error := validate_target_allowed_for_context_owner(context_owner=runtime.context_owner, target=target): @@ -56,4 +53,4 @@ def __call__( with suppress(Exception): send_object_update_event(object_=target_object, changes={"state": arguments.state}) - return CallResult(value=ChangeStateReturnValue(state=arguments.state), changed=True, error=None) + return CallResult(value=SingleStateReturnValue(state=arguments.state), changed=True, error=None) diff --git a/python/ansible_plugin/messages.py b/python/ansible_plugin/messages.py index 9feaa9ea11..7df970df8d 100644 --- a/python/ansible_plugin/messages.py +++ b/python/ansible_plugin/messages.py @@ -38,6 +38,3 @@ MSG_MANDATORY_ARGS = "Arguments {} are mandatory" MSG_NO_ROUTE = "Incorrect combination of args" MSG_NO_SERVICE_NAME = "You must specify service name in arguments." -MSG_NO_MULTI_STATE_TO_DELETE = ( - "You try to delete absent multi_state. You should define missing_ok as True or choose an existing multi_state" -) diff --git a/python/ansible_plugin/tests/test_adcm_state_multi_state_set.py b/python/ansible_plugin/tests/test_adcm_state_plugins.py similarity index 60% rename from python/ansible_plugin/tests/test_adcm_state_multi_state_set.py rename to python/ansible_plugin/tests/test_adcm_state_plugins.py index be4392527a..a7b3fee3fe 100644 --- a/python/ansible_plugin/tests/test_adcm_state_multi_state_set.py +++ b/python/ansible_plugin/tests/test_adcm_state_plugins.py @@ -17,13 +17,14 @@ from ansible_plugin.base import ADCMAnsiblePluginExecutor from ansible_plugin.executors.multi_state_set import ADCMMultiStateSetPluginExecutor +from ansible_plugin.executors.multi_state_unset import ADCMMultiStateUnsetPluginExecutor from ansible_plugin.executors.state import ADCMStatePluginExecutor from ansible_plugin.tests.base import BaseTestEffectsOfADCMAnsiblePlugins ADCMObject: TypeAlias = Cluster | ClusterObject | ServiceComponent | HostProvider | Host -class TestADCMStateMultiStatePluginExecutors(BaseTestEffectsOfADCMAnsiblePlugins): +class TestADCMStatePluginExecutors(BaseTestEffectsOfADCMAnsiblePlugins): def setUp(self) -> None: super().setUp() @@ -31,7 +32,10 @@ def setUp(self) -> None: self.service = services.get(prototype__name="service_1") self.component = self.service.servicecomponent_set.first() - self.new_state = "brand new object's (multi)state" + self.target_state = "brand new object's (multi)state" + self.default_multi_state = "default multi-state" + + self.another_provider = self.add_provider(bundle=self.provider_bundle, name="another_provider") provider = self.add_provider(bundle=self.provider_bundle, name="Control provider") cluster = self.add_cluster(bundle=self.cluster_bundle, name="Control cluster") @@ -39,6 +43,47 @@ def setUp(self) -> None: other_components = ServiceComponent.objects.filter(cluster=self.cluster).exclude(pk=self.component.pk) self.control_objects = [cluster, service_2, *list(other_components), provider, self.host_2] + self.allowed_owner_target_args = ( + (self.cluster, self.cluster, {"type": "cluster", "state": self.target_state}), + ( + self.cluster, + self.service, + {"type": "service", "service_name": self.service.name, "state": self.target_state}, + ), + ( + self.cluster, + self.component, + { + "type": "component", + "service_name": self.service.name, + "component_name": self.component.name, + "state": self.target_state, + }, + ), + (self.service, self.cluster, {"type": "cluster", "state": self.target_state}), + (self.service, self.service, {"type": "service", "state": self.target_state}), + ( + self.service, + self.component, + {"type": "component", "component_name": self.component.name, "state": self.target_state}, + ), + (self.component, self.cluster, {"type": "cluster", "state": self.target_state}), + (self.component, self.service, {"type": "service", "state": self.target_state}), + (self.component, self.component, {"type": "component", "state": self.target_state}), + (self.provider, self.provider, {"type": "provider", "state": self.target_state}), + (self.provider, self.host_1, {"type": "host", "host_id": self.host_1.pk, "state": self.target_state}), + (self.host_1, self.provider, {"type": "provider", "state": self.target_state}), + (self.host_1, self.host_1, {"type": "host", "state": self.target_state}), + ) + self.forbidden_owner_target_args = ( + (self.host_1, self.host_2, {"type": "host", "host_id": self.host_2.pk, "state": self.target_state}), + ( + self.another_provider, + self.host_2, + {"type": "host", "host_id": self.host_2.pk, "state": self.target_state}, + ), + ) + def _execute_test( self, owner: ADCMObject, @@ -48,13 +93,27 @@ def _execute_test( expected_value: str | list[str] | None = None, expect_fail: bool = False, ) -> None: + target._multi_state = {} + target.save(update_fields=["_multi_state"]) + match executor_class.__name__: case ADCMStatePluginExecutor.__name__: model_field = "state" control_value = ["created"] + expected_value = control_value[0] if expect_fail else expected_value case ADCMMultiStateSetPluginExecutor.__name__: + target.set_multi_state(multi_state=self.default_multi_state) + model_field = "multi_state" + control_value = [[]] + expected_value = [self.default_multi_state] if expect_fail else expected_value + case ADCMMultiStateUnsetPluginExecutor.__name__: + target.set_multi_state(multi_state=self.default_multi_state) + target.set_multi_state(multi_state=self.target_state) model_field = "multi_state" control_value = [[]] + expected_value = ( + sorted([self.target_state, self.default_multi_state]) if expect_fail else expected_value + ) case _: raise NotImplementedError(str(executor_class)) @@ -76,7 +135,6 @@ def _execute_test( self.assertTrue(result.changed) target.refresh_from_db() - expected_value = control_value[0] if expect_fail else expected_value self.assertEqual(getattr(target, model_field), expected_value) if expect_fail: @@ -90,43 +148,20 @@ def _execute_test( self.assertListEqual(states, control_value * len(self.control_objects)) - def test_set_states(self): - for owner, target, call_args in ( - (self.cluster, self.cluster, {"type": "cluster", "state": self.new_state}), - ( - self.cluster, - self.service, - {"type": "service", "service_name": self.service.name, "state": self.new_state}, - ), - ( - self.cluster, - self.component, - { - "type": "component", - "service_name": self.service.name, - "component_name": self.component.name, - "state": self.new_state, - }, - ), - (self.service, self.cluster, {"type": "cluster", "state": self.new_state}), - (self.service, self.service, {"type": "service", "state": self.new_state}), - ( - self.service, - self.component, - {"type": "component", "component_name": self.component.name, "state": self.new_state}, - ), - (self.component, self.cluster, {"type": "cluster", "state": self.new_state}), - (self.component, self.service, {"type": "service", "state": self.new_state}), - (self.component, self.component, {"type": "component", "state": self.new_state}), - (self.provider, self.provider, {"type": "provider", "state": self.new_state}), - (self.provider, self.host_1, {"type": "host", "host_id": self.host_1.pk, "state": self.new_state}), - (self.host_1, self.provider, {"type": "provider", "state": self.new_state}), - (self.host_1, self.host_1, {"type": "host", "state": self.new_state}), - ): - for executor_class, expected_value in ( - (ADCMStatePluginExecutor, self.new_state), - (ADCMMultiStateSetPluginExecutor, [self.new_state]), + def test_success_scenarios(self): + for owner, target, call_args in self.allowed_owner_target_args: + for executor_class, expected_value, extra_args in ( + (ADCMStatePluginExecutor, self.target_state, {}), + (ADCMMultiStateSetPluginExecutor, sorted([self.default_multi_state, self.target_state]), {}), + (ADCMMultiStateUnsetPluginExecutor, [self.default_multi_state], {}), + ( + ADCMMultiStateUnsetPluginExecutor, + sorted([self.default_multi_state, self.target_state]), + {"state": "absent state", "missing_ok": True}, + ), ): + call_args = {**call_args, **extra_args} + with self.subTest( owner=owner, target=target, @@ -142,11 +177,13 @@ def test_set_states(self): expected_value=expected_value, ) - def test_forbidden_owner_targert_pairs(self): - for owner, target, call_args in ( - (self.host_1, self.host_2, {"type": "host", "host_id": self.host_2.pk, "state": self.new_state}), - ): - for executor_class in (ADCMStatePluginExecutor, ADCMMultiStateSetPluginExecutor): + def test_fail_scenarios(self): + for owner, target, call_args in self.allowed_owner_target_args: + for executor_class, extra_args in ( + (ADCMMultiStateUnsetPluginExecutor, {"state": "absent state", "missing_ok": False}), + (ADCMMultiStateUnsetPluginExecutor, {"state": "absent state"}), # check default missing_ok + ): + call_args = {**call_args, **extra_args} with self.subTest(owner=owner, target=target, call_args=call_args, executor_class=executor_class): self._execute_test( owner=owner, @@ -156,20 +193,20 @@ def test_forbidden_owner_targert_pairs(self): expect_fail=True, ) - def test_multi_state_adds_value(self): - self._execute_test( - owner=self.service, - target=self.cluster, - call_arguments={"type": "cluster", "state": self.new_state}, - executor_class=ADCMMultiStateSetPluginExecutor, - expected_value=[self.new_state], - ) + def test_forbidden_owner_targert_pairs(self): + for owner, target, call_args in self.forbidden_owner_target_args: + for executor_class, extra_args in ( + (ADCMStatePluginExecutor, {}), + (ADCMMultiStateSetPluginExecutor, {}), + (ADCMMultiStateUnsetPluginExecutor, {"state": "absent state", "missing_ok": True}), + ): + call_args = {**call_args, **extra_args} - another_state = "another state" - self._execute_test( - owner=self.component, - target=self.cluster, - call_arguments={"type": "cluster", "state": another_state}, - executor_class=ADCMMultiStateSetPluginExecutor, - expected_value=sorted([self.new_state, another_state]), - ) + with self.subTest(owner=owner, target=target, call_args=call_args, executor_class=executor_class): + self._execute_test( + owner=owner, + target=target, + call_arguments=call_args, + executor_class=executor_class, + expect_fail=True, + ) diff --git a/python/ansible_plugin/utils.py b/python/ansible_plugin/utils.py index 919e574d94..58d2c4db31 100644 --- a/python/ansible_plugin/utils.py +++ b/python/ansible_plugin/utils.py @@ -13,13 +13,9 @@ from collections import defaultdict from typing import Any import json -import fcntl # isort: off from ansible.errors import AnsibleError -from ansible.plugins.action import ActionBase -from ansible.utils.vars import merge_hash -from django.conf import settings from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType @@ -28,46 +24,22 @@ MSG_NO_CONTEXT, MSG_WRONG_CONTEXT, MSG_WRONG_CONTEXT_ID, - MSG_NO_CLUSTER_CONTEXT, - MSG_MANDATORY_ARGS, - MSG_NO_ROUTE, - MSG_NO_SERVICE_NAME, - MSG_NO_MULTI_STATE_TO_DELETE, ) from cm.adcm_config.config import get_option_value -from cm.errors import AdcmEx from cm.models import ( - ADCMEntity, CheckLog, Cluster, ClusterObject, GroupCheckLog, - Host, - HostProvider, JobLog, LogStorage, Prototype, - ServiceComponent, ) -from cm.status_api import send_object_update_event from rbac.models import Role, Policy from rbac.roles import assign_group_perm # isort: on -def job_lock(job_id): - file_descriptor = open( # noqa: SIM115 - settings.RUN_DIR / f"{job_id}/config.json", - encoding=settings.ENCODING_UTF_8, - ) - try: - fcntl.flock(file_descriptor.fileno(), fcntl.LOCK_EX) - - return file_descriptor - except OSError as e: - raise AdcmEx("LOCK_ERROR", e) from e - - def check_context_type(task_vars: dict, context_types: tuple, err_msg: str | None = None) -> None: """ Check context type. Check if inventory.json and config.json were passed @@ -108,178 +80,15 @@ def get_object_id_from_context( return context[id_type], None -class ContextActionModule(ActionBase): - TRANSFERS_FILES = False - _VALID_ARGS = None - _MANDATORY_ARGS = None - - def _wrap_call(self, func, *args): - try: - func(*args) - except AdcmEx as e: - return {"failed": True, "msg": e.msg} - - return {"changed": True} - - def _check_mandatory(self): - for arg in self._MANDATORY_ARGS: - if arg not in self._task.args: - raise AnsibleError(MSG_MANDATORY_ARGS.format(self._MANDATORY_ARGS)) - - def _get_job_var(self, task_vars, name): - try: - return task_vars["job"][name] - except KeyError as error: - raise AnsibleError(MSG_NO_CLUSTER_CONTEXT) from error - - def _do_cluster(self, task_vars, context): - raise NotImplementedError - - def _do_service_by_name(self, task_vars, context): - raise NotImplementedError - - def _do_service(self, task_vars, context): - raise NotImplementedError - - def _do_host(self, task_vars, context): - raise NotImplementedError - - def _do_component(self, task_vars, context): - raise NotImplementedError - - def _do_component_by_name(self, task_vars, context): - raise NotImplementedError - - def _do_provider(self, task_vars, context): - raise NotImplementedError - - def _do_host_from_provider(self, task_vars, context): - raise NotImplementedError - - def run(self, tmp=None, task_vars=None): - self._check_mandatory() - obj_type = self._task.args["type"] - job_id = task_vars["job"]["id"] - file_descriptor = job_lock(job_id) - - if obj_type == "cluster": - check_context_type(task_vars=task_vars, context_types=("cluster", "service", "component")) - res = self._do_cluster(task_vars, {"cluster_id": self._get_job_var(task_vars, "cluster_id")}) - elif obj_type == "service" and "service_name" in self._task.args: - check_context_type(task_vars=task_vars, context_types=("cluster", "service", "component")) - res = self._do_service_by_name(task_vars, {"cluster_id": self._get_job_var(task_vars, "cluster_id")}) - elif obj_type == "service": - check_context_type(task_vars=task_vars, context_types=("service", "component")) - res = self._do_service( - task_vars, - { - "cluster_id": self._get_job_var(task_vars, "cluster_id"), - "service_id": self._get_job_var(task_vars, "service_id"), - }, - ) - elif obj_type == "host" and "host_id" in self._task.args: - check_context_type(task_vars=task_vars, context_types=("provider",)) - res = self._do_host_from_provider(task_vars, {}) - elif obj_type == "host": - check_context_type(task_vars=task_vars, context_types=("host",)) - res = self._do_host(task_vars, {"host_id": self._get_job_var(task_vars, "host_id")}) - elif obj_type == "provider": - check_context_type(task_vars=task_vars, context_types=("provider", "host")) - res = self._do_provider(task_vars, {"provider_id": self._get_job_var(task_vars, "provider_id")}) - elif obj_type == "component" and "component_name" in self._task.args: - if "service_name" in self._task.args: - check_context_type(task_vars=task_vars, context_types=("cluster", "service", "component")) - res = self._do_component_by_name( - task_vars, - { - "cluster_id": self._get_job_var(task_vars, "cluster_id"), - "service_id": None, - }, - ) - else: - check_context_type(task_vars=task_vars, context_types=("cluster", "service", "component")) - if task_vars["job"].get("service_id", None) is None: - raise AnsibleError(MSG_NO_SERVICE_NAME) - res = self._do_component_by_name( - task_vars, - { - "cluster_id": self._get_job_var(task_vars, "cluster_id"), - "service_id": self._get_job_var(task_vars, "service_id"), - }, - ) - elif obj_type == "component": - check_context_type(task_vars=task_vars, context_types=("component",)) - res = self._do_component(task_vars, {"component_id": self._get_job_var(task_vars, "component_id")}) - else: - raise AnsibleError(MSG_NO_ROUTE) - - result = super().run(tmp, task_vars) - file_descriptor.close() - - return merge_hash(result, res) - - # Helper functions for ansible plugins -def get_component_by_name(cluster_id, service_id, component_name, service_name): - if service_id is not None: - comp = ServiceComponent.obj.get(cluster_id=cluster_id, service_id=service_id, prototype__name=component_name) - else: - comp = ServiceComponent.obj.get( - cluster_id=cluster_id, - service__prototype__name=service_name, - prototype__name=component_name, - ) - return comp - - def get_service_by_name(cluster_id, service_name): cluster = Cluster.obj.get(id=cluster_id) proto = Prototype.obj.get(type="service", name=service_name, bundle=cluster.prototype.bundle) return ClusterObject.obj.get(cluster=cluster, prototype=proto) -def _set_object_multi_state(obj: ADCMEntity, multi_state: str) -> ADCMEntity: - obj.set_multi_state(multi_state) - return obj - - -def set_cluster_multi_state(cluster_id, multi_state): - obj = Cluster.obj.get(id=cluster_id) - return _set_object_multi_state(obj, multi_state) - - -def set_service_multi_state_by_name(cluster_id, service_name, multi_state): - obj = get_service_by_name(cluster_id, service_name) - return _set_object_multi_state(obj, multi_state) - - -def set_service_multi_state(cluster_id, service_id, multi_state): - obj = ClusterObject.obj.get(id=service_id, cluster__id=cluster_id, prototype__type="service") - return _set_object_multi_state(obj, multi_state) - - -def set_component_multi_state_by_name(cluster_id, service_id, component_name, service_name, multi_state): - obj = get_component_by_name(cluster_id, service_id, component_name, service_name) - return _set_object_multi_state(obj, multi_state) - - -def set_component_multi_state(component_id, multi_state): - obj = ServiceComponent.obj.get(id=component_id) - return _set_object_multi_state(obj, multi_state) - - -def set_provider_multi_state(provider_id, multi_state): - obj = HostProvider.obj.get(id=provider_id) - return _set_object_multi_state(obj, multi_state) - - -def set_host_multi_state(host_id, multi_state): - obj = Host.obj.get(id=host_id) - return _set_object_multi_state(obj, multi_state) - - def cast_to_type(field_type: str, value: Any, limits: dict) -> Any: try: match field_type: @@ -295,53 +104,6 @@ def cast_to_type(field_type: str, value: Any, limits: dict) -> Any: raise AnsibleError(f"Could not convert '{value}' to '{field_type}'") from error -def check_missing_ok(obj: ADCMEntity, multi_state: str, missing_ok): - if missing_ok is False and multi_state not in obj.multi_state: - raise AnsibleError(MSG_NO_MULTI_STATE_TO_DELETE) - - -def _unset_object_multi_state(obj: ADCMEntity, multi_state: str, missing_ok) -> ADCMEntity: - check_missing_ok(obj, multi_state, missing_ok) - obj.unset_multi_state(multi_state) - send_object_update_event(object_=obj, changes={"state": multi_state}) - return obj - - -def unset_cluster_multi_state(cluster_id, multi_state, missing_ok): - obj = Cluster.obj.get(id=cluster_id) - return _unset_object_multi_state(obj, multi_state, missing_ok) - - -def unset_service_multi_state_by_name(cluster_id, service_name, multi_state, missing_ok): - obj = get_service_by_name(cluster_id, service_name) - return _unset_object_multi_state(obj, multi_state, missing_ok) - - -def unset_service_multi_state(cluster_id, service_id, multi_state, missing_ok): - obj = ClusterObject.obj.get(id=service_id, cluster__id=cluster_id, prototype__type="service") - return _unset_object_multi_state(obj, multi_state, missing_ok) - - -def unset_component_multi_state_by_name(cluster_id, service_id, component_name, service_name, multi_state, missing_ok): - obj = get_component_by_name(cluster_id, service_id, component_name, service_name) - return _unset_object_multi_state(obj, multi_state, missing_ok) - - -def unset_component_multi_state(component_id, multi_state, missing_ok): - obj = ServiceComponent.obj.get(id=component_id) - return _unset_object_multi_state(obj, multi_state, missing_ok) - - -def unset_provider_multi_state(provider_id, multi_state, missing_ok): - obj = HostProvider.obj.get(id=provider_id) - return _unset_object_multi_state(obj, multi_state, missing_ok) - - -def unset_host_multi_state(host_id, multi_state, missing_ok): - obj = Host.obj.get(id=host_id) - return _unset_object_multi_state(obj, multi_state, missing_ok) - - def assign_view_logstorage_permissions_by_job(log_storage: LogStorage) -> None: task_role = Role.objects.filter(name=f"View role for task {log_storage.job.task_id}", built_in=True).first() view_logstorage_permission, _ = Permission.objects.get_or_create( @@ -353,12 +115,6 @@ def assign_view_logstorage_permissions_by_job(log_storage: LogStorage) -> None: assign_group_perm(policy=policy, permission=view_logstorage_permission, obj=log_storage) -def create_custom_log(job_id: int, name: str, log_format: str, body: str) -> LogStorage: - log = LogStorage.objects.create(job_id=job_id, name=name, type="custom", format=log_format, body=body) - assign_view_logstorage_permissions_by_job(log_storage=log) - return log - - def get_checklogs_data_by_job_id(job_id: int) -> list[dict[str, Any]]: data = [] group_subs = defaultdict(list) From 6cacbe3d9a5295ea2b01dd2e9da5d81d8eb50019 Mon Sep 17 00:00:00 2001 From: Artem Starovoitov Date: Thu, 30 May 2024 08:59:31 +0000 Subject: [PATCH 145/208] ADCM-5634: fix adcm_check validation --- python/ansible/plugins/action/adcm_check.py | 5 +- python/ansible_plugin/executors/check.py | 43 ++++--------- .../ansible_plugin/tests/test_adcm_check.py | 64 +++++-------------- 3 files changed, 29 insertions(+), 83 deletions(-) diff --git a/python/ansible/plugins/action/adcm_check.py b/python/ansible/plugins/action/adcm_check.py index b476366923..68180a9cc3 100644 --- a/python/ansible/plugins/action/adcm_check.py +++ b/python/ansible/plugins/action/adcm_check.py @@ -86,10 +86,7 @@ result: yes """ -RETURN = r""" -result: - check: JSON log of all checks for this ADCM job -""" +RETURN = "" import sys diff --git a/python/ansible_plugin/executors/check.py b/python/ansible_plugin/executors/check.py index cd201ad2c1..6ef781cc0a 100644 --- a/python/ansible_plugin/executors/check.py +++ b/python/ansible_plugin/executors/check.py @@ -11,13 +11,11 @@ # limitations under the License. from typing import Collection, TypedDict -import json from cm.errors import AdcmEx from cm.logger import logger from cm.models import CheckLog, GroupCheckLog, JobLog, LogStorage from core.types import CoreObjectDescriptor -from django.db import IntegrityError from django.db.transaction import atomic from pydantic import BaseModel, model_validator from typing_extensions import Self @@ -33,7 +31,7 @@ from ansible_plugin.errors import ( PluginRuntimeError, ) -from ansible_plugin.utils import assign_view_logstorage_permissions_by_job, get_checklogs_data_by_job_id +from ansible_plugin.utils import assign_view_logstorage_permissions_by_job class CheckArguments(BaseModel): @@ -55,17 +53,12 @@ def check_msg_is_specified_if_no_fail_success_msg(self) -> Self: return self @model_validator(mode="after") - def check_success_msg_is_specified_if_no_msg(self) -> Self: - if self.msg is None and self.success_msg is None: - message = "'success_msg' must be specified if 'msg' are not specified" - raise ValueError(message) - - return self + def check_success_msg_and_fail_msg_are_specified_if_no_msg(self) -> Self: + if self.msg: + return self - @model_validator(mode="after") - def check_fail_msg_is_specified_if_no_msg(self) -> Self: - if self.msg is None and self.success_msg is None: - message = "'fail_msg' must be specified if 'msg' are not specified" + if self.success_msg is None or self.fail_msg is None: + message = "Both success_msg and fail_msg should be specified when msg is absent" raise ValueError(message) return self @@ -88,7 +81,7 @@ class JSONLogReturnValue(TypedDict): check: dict -class ADCMCheckPluginExecutor(ADCMAnsiblePluginExecutor[CheckArguments, JSONLogReturnValue]): +class ADCMCheckPluginExecutor(ADCMAnsiblePluginExecutor[CheckArguments, None]): _config = PluginExecutorConfig( arguments=ArgumentsConfig(represent_as=CheckArguments), ) @@ -125,8 +118,6 @@ def __call__( ", ".join([f"{k}: {v}" for k, v in check_data.items() if v]), ) - check = {} - try: with atomic(): job = JobLog.objects.get(id=runtime.vars.job.id) @@ -149,23 +140,13 @@ def __call__( group.message = msg group.result = result - group.save() - - check = get_checklogs_data_by_job_id(runtime.vars.job.id) + group.save(update_fields=["message", "result"]) - log_storage, _ = LogStorage.objects.get_or_create( - job=job, name="ansible", type="check", format="json", body=json.dumps(check) - ) + log_storage, _ = LogStorage.objects.get_or_create(job=job, name="ansible", type="check", format="json") assign_view_logstorage_permissions_by_job(log_storage) except AdcmEx as e: error_message = f"Failed to create checklog: {check_data}, group: {group_data}, error: {e}" - return CallResult(value={}, changed=False, error=PluginRuntimeError(message=error_message)) - except IntegrityError as e: - return CallResult( - value={}, - changed=False, - error=PluginRuntimeError(message=f"Failed to perform check due to IntegrityError: {e}"), - ) - - return CallResult(value=JSONLogReturnValue(check=check), changed=True, error=None) + return CallResult(value=None, changed=False, error=PluginRuntimeError(message=error_message)) + + return CallResult(value=None, changed=True, error=None) diff --git a/python/ansible_plugin/tests/test_adcm_check.py b/python/ansible_plugin/tests/test_adcm_check.py index d423d5397d..0f4a509da3 100644 --- a/python/ansible_plugin/tests/test_adcm_check.py +++ b/python/ansible_plugin/tests/test_adcm_check.py @@ -46,9 +46,7 @@ def test_adcm_check_success(self) -> None: result = executor.execute() self.assertIsNone(result.error) - self.assertDictEqual( - result.value, {"check": [{"message": "test_message", "result": True, "title": "title", "type": "check"}]} - ) + self.assertIsNone(None) self.assertTrue(result.changed) def test_adcm_check_no_title_fail(self) -> None: @@ -108,7 +106,7 @@ def test_adcm_check_no_msg_fail(self) -> None: self.assertDictEqual(result.value, {}) self.assertFalse(result.changed) - def test_adcm_check_no_msg_but_there_success_msg_success(self) -> None: + def test_adcm_check_no_msg_but_there_success_msg_fail(self) -> None: task = self.prepare_task(owner=self.cluster, name="dummy") job, *_ = JobRepoImpl.get_task_jobs(task.id) @@ -123,11 +121,10 @@ def test_adcm_check_no_msg_but_there_success_msg_success(self) -> None: ) result = executor.execute() - self.assertIsNone(result.error) - self.assertDictEqual( - result.value, {"check": [{"message": "success", "result": True, "title": "title", "type": "check"}]} - ) - self.assertTrue(result.changed) + self.assertIsInstance(result.error, PluginValidationError) + self.assertIn("Arguments doesn't match expected schema", result.error.message) + self.assertDictEqual(result.value, {}) + self.assertFalse(result.changed) def test_adcm_check_no_msg_but_there_fail_msg_fail(self) -> None: task = self.prepare_task(owner=self.cluster, name="dummy") @@ -166,9 +163,7 @@ def test_adcm_check_no_msg_but_there_success_msg_and_fail_msg_success(self) -> N result = executor.execute() self.assertIsNone(result.error) - self.assertDictEqual( - result.value, {"check": [{"message": "fail", "result": False, "title": "title", "type": "check"}]} - ) + self.assertIsNone(None) self.assertTrue(result.changed) def test_adcm_check_no_msg_and_there_success_msg_and_fail_msg_success(self) -> None: @@ -188,9 +183,7 @@ def test_adcm_check_no_msg_and_there_success_msg_and_fail_msg_success(self) -> N result = executor.execute() self.assertIsNone(result.error) - self.assertDictEqual( - result.value, {"check": [{"message": "fail", "result": False, "title": "title", "type": "check"}]} - ) + self.assertIsNone(None) self.assertTrue(result.changed) def test_adcm_check_group_title_and_group_success_msg_success(self) -> None: @@ -211,20 +204,7 @@ def test_adcm_check_group_title_and_group_success_msg_success(self) -> None: result = executor.execute() self.assertIsNone(result.error) - self.assertDictEqual( - result.value, - { - "check": [ - { - "content": [{"message": "test_message", "result": True, "title": "title", "type": "check"}], - "message": "success group", - "result": True, - "title": "group", - "type": "group", - } - ] - }, - ) + self.assertIsNone(None) self.assertTrue(result.changed) def test_adcm_check_group_title_and_group_fail_msg_success(self) -> None: @@ -245,20 +225,7 @@ def test_adcm_check_group_title_and_group_fail_msg_success(self) -> None: result = executor.execute() self.assertIsNone(result.error) - self.assertDictEqual( - result.value, - { - "check": [ - { - "content": [{"message": "test_message", "result": True, "title": "title", "type": "check"}], - "message": None, - "result": True, - "title": "group", - "type": "group", - } - ] - }, - ) + self.assertIsNone(None) self.assertTrue(result.changed) def test_adcm_check_group_title_no_group_msg_fail(self) -> None: @@ -282,7 +249,7 @@ def test_adcm_check_group_title_no_group_msg_fail(self) -> None: self.assertDictEqual(result.value, {}) self.assertFalse(result.changed) - def test_adcm_check_double_call_val_success_fail(self) -> None: + def test_adcm_check_double_call_val_success(self) -> None: task = self.prepare_task(owner=self.cluster, name="dummy") job, *_ = JobRepoImpl.get_task_jobs(task.id) @@ -300,12 +267,13 @@ def test_adcm_check_double_call_val_success_fail(self) -> None: executor.execute() result = executor.execute() - self.assertIn("Failed to perform check due to IntegrityError", result.error.message) - self.assertDictEqual(result.value, {}) - self.assertFalse(result.changed) + self.assertIsNone( + result.value, + ) + self.assertTrue(result.changed) self.assertEqual(GroupCheckLog.objects.all().count(), 1) - self.assertEqual(CheckLog.objects.all().count(), 1) + self.assertEqual(CheckLog.objects.all().count(), 2) self.assertEqual(LogStorage.objects.all().count(), 3) def test_adcm_check_double_call_fail(self) -> None: From abe34562f8d34ace41ab4280cc9d09ed16129430 Mon Sep 17 00:00:00 2001 From: astarovo Date: Fri, 31 May 2024 08:10:19 +0300 Subject: [PATCH 146/208] ADCM-5634: fix adcm_check validation --- python/ansible_plugin/executors/check.py | 9 +++++-- .../ansible_plugin/tests/test_adcm_check.py | 24 ++++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/python/ansible_plugin/executors/check.py b/python/ansible_plugin/executors/check.py index 6ef781cc0a..8b2bda115d 100644 --- a/python/ansible_plugin/executors/check.py +++ b/python/ansible_plugin/executors/check.py @@ -64,14 +64,19 @@ def check_success_msg_and_fail_msg_are_specified_if_no_msg(self) -> Self: return self @model_validator(mode="after") - def check_group_msg_if_group_is_specified(self) -> Self: + def check_group_msg_if_group_is_specified_if_no_msg(self) -> Self: + if self.msg: + return self + if ( self.group_title is not None and self.group_success_msg is None and self.group_title is not None and self.group_fail_msg is None ): - message = "either 'group_fail_msg' or 'group_success_msg' must be specified if 'group_titile' is specified" + message = ( + "either 'group_fail_msg' or 'group_success_msg' or msg must be specified if 'group_title' is specified" + ) raise ValueError(message) return self diff --git a/python/ansible_plugin/tests/test_adcm_check.py b/python/ansible_plugin/tests/test_adcm_check.py index 0f4a509da3..d173bc57c7 100644 --- a/python/ansible_plugin/tests/test_adcm_check.py +++ b/python/ansible_plugin/tests/test_adcm_check.py @@ -183,7 +183,7 @@ def test_adcm_check_no_msg_and_there_success_msg_and_fail_msg_success(self) -> N result = executor.execute() self.assertIsNone(result.error) - self.assertIsNone(None) + self.assertIsNone(result.value) self.assertTrue(result.changed) def test_adcm_check_group_title_and_group_success_msg_success(self) -> None: @@ -228,7 +228,7 @@ def test_adcm_check_group_title_and_group_fail_msg_success(self) -> None: self.assertIsNone(None) self.assertTrue(result.changed) - def test_adcm_check_group_title_no_group_msg_fail(self) -> None: + def test_adcm_check_group_title_no_group_msg_but_there_msg_success(self) -> None: task = self.prepare_task(owner=self.cluster, name="dummy") job, *_ = JobRepoImpl.get_task_jobs(task.id) @@ -244,6 +244,25 @@ def test_adcm_check_group_title_no_group_msg_fail(self) -> None: ) result = executor.execute() + self.assertIsNone(result.error) + self.assertIsNone(result.value, None) + self.assertTrue(result.changed) + + def test_adcm_check_group_title_no_group_msg_fail(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMCheckPluginExecutor, + call_arguments=""" + title: title + result: true + group_title: group + """, + call_context=job, + ) + result = executor.execute() + self.assertIsInstance(result.error, PluginValidationError) self.assertIn("Arguments doesn't match expected schema", result.error.message) self.assertDictEqual(result.value, {}) @@ -285,7 +304,6 @@ def test_adcm_check_double_call_fail(self) -> None: call_arguments=""" title: title result: true - msg: test_message group_title: group """, call_context=job, From fd8e4e2e8581d37c4155e390bb9b9d2b990a7911 Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Fri, 31 May 2024 06:58:11 +0000 Subject: [PATCH 147/208] ADCM-5574: remove `reverse` from test_import.py --- python/adcm/tests/client.py | 50 +++++++++++++++---- python/api_v2/tests/test_import.py | 78 +++++++----------------------- 2 files changed, 57 insertions(+), 71 deletions(-) diff --git a/python/adcm/tests/client.py b/python/adcm/tests/client.py index 24d97d80cc..6231bd2825 100644 --- a/python/adcm/tests/client.py +++ b/python/adcm/tests/client.py @@ -27,6 +27,7 @@ ServiceComponent, TaskLog, ) +from django.test.client import AsyncClient from rbac.models import Policy, Role, User from rest_framework.response import Response from rest_framework.test import APIClient @@ -39,19 +40,23 @@ class WithID(Protocol): id: int +API_NODES_SLOTS = ("_client", "_path", "_resolved_path", "_node_class") + + class APINode: - __slots__ = ("_client", "_path", "_resolved_path") + __slots__ = API_NODES_SLOTS - def __init__(self, *path: str, client: APIClient): + def __init__(self, *path: str, client: APIClient | AsyncClient, node_class: type["APINode"] | type["AsyncAPINode"]): self._client = client self._path = tuple(path) self._resolved_path = None + self._node_class = node_class def __truediv__(self, other: str | int | WithID): if isinstance(other, (str, int)): - return APINode(*self._path, str(other), client=self._client) + return self._node_class(*self._path, str(other), client=self._client, node_class=self._node_class) - return APINode(*self._path, str(other.id), client=self._client) + return self._node_class(*self._path, str(other.id), client=self._client, node_class=self._node_class) @property def path(self) -> str: @@ -76,6 +81,13 @@ def delete(self) -> Response: return self._client.delete(path=self.path) +class AsyncAPINode(APINode): + __slots__ = API_NODES_SLOTS + + def post(self, *, data: dict | list[dict] | None = None, format_: str | None = None) -> Response: + return self._client.post(path=self.path, data=data, content_type=format_) + + class RootNode(APINode, ABC): @abstractmethod def __getitem__(self, item) -> APINode: @@ -105,10 +117,12 @@ def __getitem__(self, item: PathObject | tuple[PathObject, str | int | WithID, . root_endpoint = self._CLASS_ROOT_EP_MAP.get(path_object.__class__) if root_endpoint: - return APINode(*self._path, root_endpoint, str(path_object.id), *tail, client=self._client) + return self._node_class( + *self._path, root_endpoint, str(path_object.id), *tail, client=self._client, node_class=self._node_class + ) if isinstance(path_object, ClusterObject): - return APINode( + return self._node_class( *self._path, "clusters", str(path_object.cluster_id), @@ -116,10 +130,11 @@ def __getitem__(self, item: PathObject | tuple[PathObject, str | int | WithID, . str(path_object.id), *tail, client=self._client, + node_class=self._node_class, ) if isinstance(path_object, ServiceComponent): - return APINode( + return self._node_class( *self._path, "clusters", str(path_object.cluster_id), @@ -129,6 +144,7 @@ def __getitem__(self, item: PathObject | tuple[PathObject, str | int | WithID, . str(path_object.id), *tail, client=self._client, + node_class=self._node_class, ) if isinstance(path_object, GroupConfig): @@ -136,8 +152,15 @@ def __getitem__(self, item: PathObject | tuple[PathObject, str | int | WithID, . return self[path_object.object] / "/".join(("config-groups", str(path_object.id), *tail)) if isinstance(path_object, LogStorage): - return APINode( - *self._path, "jobs", str(path_object.job_id), "logs", str(path_object.id), *tail, client=self._client + return self._node_class( + *self._path, + "jobs", + str(path_object.job_id), + "logs", + str(path_object.id), + *tail, + client=self._client, + node_class=self._node_class, ) message = f"Node auto-detection isn't defined for {path_object.__class__}" @@ -148,4 +171,11 @@ class ADCMTestClient(APIClient): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.v2 = V2RootNode("api", "v2", client=self) + self.v2 = V2RootNode("api", "v2", client=self, node_class=APINode) + + +class ADCMAsyncTestClient(AsyncClient): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.v2 = V2RootNode("api", "v2", client=self, node_class=AsyncAPINode) diff --git a/python/api_v2/tests/test_import.py b/python/api_v2/tests/test_import.py index 3002741f34..43b8211d9e 100644 --- a/python/api_v2/tests/test_import.py +++ b/python/api_v2/tests/test_import.py @@ -13,10 +13,9 @@ import asyncio from adcm.tests.base import APPLICATION_JSON +from adcm.tests.client import ADCMAsyncTestClient from asgiref.sync import sync_to_async from cm.models import ClusterBind -from django.test import AsyncClient -from django.urls import reverse from rbac.models import User from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_403_FORBIDDEN @@ -44,13 +43,11 @@ def setUp(self) -> None: self.import_service_2 = self.add_services_to_cluster( service_names=["service_import_2"], cluster=self.import_cluster ).get() - self.aclient = AsyncClient() + self.aclient = ADCMAsyncTestClient() self.aclient.force_login(User.objects.get(username="admin")) def test_cluster_imports_list_success(self): - response = self.client.get( - path=reverse(viewname="v2:cluster-import-list", kwargs={"cluster_pk": self.import_cluster.pk}) - ) + response = self.client.v2[self.import_cluster, "imports"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) @@ -95,12 +92,7 @@ def test_cluster_imports_list_success(self): ) def test_service_imports_list_success(self): - response = self.client.get( - path=reverse( - viewname="v2:service-import-list", - kwargs={"cluster_pk": self.import_cluster.pk, "service_pk": self.import_service.pk}, - ) - ) + response = self.client.v2[self.import_service, "imports"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) @@ -146,8 +138,7 @@ def test_service_imports_list_success(self): ) def test_cluster_imports_create_success(self): - response = self.client.post( - path=reverse(viewname="v2:cluster-import-list", kwargs={"cluster_pk": self.import_cluster.pk}), + response = self.client.v2[self.import_cluster, "imports"].post( data=[ {"source": {"id": self.export_cluster.pk, "type": "cluster"}}, {"source": {"id": self.export_service.pk, "type": "service"}}, @@ -169,10 +160,7 @@ def test_cluster_imports_create_success(self): def test_cluster_imports_create_empty_success(self): ClusterBind.objects.create(cluster=self.import_cluster, source_cluster=self.export_cluster) - response = self.client.post( - path=reverse(viewname="v2:cluster-import-list", kwargs={"cluster_pk": self.import_cluster.pk}), - data=[], - ) + response = self.client.v2[self.import_cluster, "imports"].post(data=[]) self.assertEqual(response.status_code, HTTP_201_CREATED) self.assertFalse(ClusterBind.objects.exists()) @@ -182,10 +170,7 @@ def test_another_cluster_imports_model_permission_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object import"): - response = self.client.get( - path=reverse(viewname="v2:cluster-import-list", kwargs={"cluster_pk": self.import_cluster.pk}), - data=[], - ) + response = self.client.v2[self.import_cluster, "imports"].get(query=[]) self.assertEqual(response.status_code, HTTP_200_OK) @@ -194,10 +179,7 @@ def test_another_cluster_imports_object_permission_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.import_cluster, role_name="View imports"): - response = self.client.get( - path=reverse(viewname="v2:cluster-import-list", kwargs={"cluster_pk": self.import_cluster.pk}), - data=[], - ) + response = self.client.v2[self.import_cluster, "imports"].get(query=[]) self.assertEqual(response.status_code, HTTP_200_OK) @@ -207,8 +189,7 @@ def test_adcm_5488_another_cluster_imports_object_permission_create_success(self self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.import_cluster, role_name="Manage imports"): with self.grant_permissions(to=self.test_user, on=self.export_cluster, role_name="View imports"): - response = self.client.post( - path=reverse(viewname="v2:cluster-import-list", kwargs={"cluster_pk": self.import_cluster.pk}), + response = self.client.v2[self.import_cluster, "imports"].post( data=[ {"source": {"id": self.export_cluster.pk, "type": "cluster"}}, {"source": {"id": self.export_service.pk, "type": "service"}}, @@ -238,10 +219,7 @@ def test_another_cluster_imports_create_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=self.import_cluster, role_name="Map hosts"): - response = self.client.post( - path=reverse(viewname="v2:cluster-import-list", kwargs={"cluster_pk": self.import_cluster.pk}), - data=[], - ) + response = self.client.v2[self.import_cluster, "imports"].post(data=[]) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -250,10 +228,7 @@ def test_model_role_imports_create_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object host-components"): - response = self.client.post( - path=reverse(viewname="v2:cluster-import-list", kwargs={"cluster_pk": self.import_cluster.pk}), - data=[], - ) + response = self.client.v2[self.import_cluster, "imports"].post(data=[]) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -263,10 +238,7 @@ def test_model_and_object_role_imports_create_denied(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object host-components"): with self.grant_permissions(to=self.test_user, on=self.import_cluster, role_name="Map hosts"): - response = self.client.post( - path=reverse(viewname="v2:cluster-import-list", kwargs={"cluster_pk": self.import_cluster.pk}), - data=[], - ) + response = self.client.v2[self.import_cluster, "imports"].post(data=[]) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -277,12 +249,7 @@ def test_another_service_imports_list_denied(self): with self.grant_permissions( to=self.test_user, on=self.import_service, role_name="View service configurations" ): - response = self.client.get( - path=reverse( - viewname="v2:service-import-list", - kwargs={"cluster_pk": self.import_cluster.pk, "service_pk": self.import_service.pk}, - ) - ) + response = self.client.v2[self.import_service, "imports"].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -293,12 +260,7 @@ def test_model_role_service_imports_list_denied(self): with self.grant_permissions( to=self.test_user, on=self.import_service, role_name="View service configurations" ): - response = self.client.get( - path=reverse( - viewname="v2:service-import-list", - kwargs={"cluster_pk": self.import_cluster.pk, "service_pk": self.import_service.pk}, - ) - ) + response = self.client.v2[self.import_service, "imports"].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) @@ -308,21 +270,15 @@ def test_model_and_object_role_service_imports_list_denied(self): with self.grant_permissions( to=self.test_user, on=self.import_service, role_name="View service configurations" ): - response = self.client.get( - path=reverse( - viewname="v2:service-import-list", - kwargs={"cluster_pk": self.import_cluster.pk, "service_pk": self.import_service.pk}, - ) - ) + response = self.client.v2[self.import_service, "imports"].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) async def test_adcm_5295_cluster_imports_no_requests_race_success(self): async def import_list(): - resp = await self.aclient.post( - path=reverse(viewname="v2:cluster-import-list", kwargs={"cluster_pk": self.import_cluster.pk}), + resp = await self.aclient.v2[self.import_cluster, "imports"].post( data=[{"source": {"id": self.export_service.pk, "type": "service"}}], - content_type=APPLICATION_JSON, + format_=APPLICATION_JSON, ) count = await sync_to_async(ClusterBind.objects.count)() return resp, count From 3335ec34ec3c7a152115f1fdb6259a8b5543c7b2 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Fri, 31 May 2024 07:40:10 +0000 Subject: [PATCH 148/208] ADCM-5637 United bundle collectors to have edition configurable Changed: 1. United bundle collectors to have edition configurable 2. Moved collectors test to `test_management_commands.py` --- python/cm/collect_statistics/collectors.py | 19 +-- .../commands/collect_statistics_new.py | 22 +-- python/cm/tests/test_collect_statistics.py | 142 ------------------ python/cm/tests/test_management_commands.py | 126 ++++++++++++++++ 4 files changed, 143 insertions(+), 166 deletions(-) delete mode 100644 python/cm/tests/test_collect_statistics.py diff --git a/python/cm/collect_statistics/collectors.py b/python/cm/collect_statistics/collectors.py index dd9155a55f..cf2ea1807b 100644 --- a/python/cm/collect_statistics/collectors.py +++ b/python/cm/collect_statistics/collectors.py @@ -12,7 +12,7 @@ from collections import defaultdict from hashlib import md5 -from typing import Literal +from typing import Collection, Literal from django.db.models import Count, F from pydantic import BaseModel @@ -89,15 +89,16 @@ def __call__(self) -> RBACEntities: class BundleCollector: - EDITION: str - - def __init__(self, date_format: str): + def __init__(self, date_format: str, include_editions: Collection[str]): self._date_format = date_format + self._editions = include_editions def __call__(self) -> ADCMEntities: bundles: dict[int, BundleData] = { entry.pop("id"): BundleData(date=entry.pop("date").strftime(self._date_format), **entry) - for entry in Bundle.objects.filter(edition=self.EDITION).values("id", *BundleData.__annotations__.keys()) + for entry in Bundle.objects.filter(edition__in=self._editions).values( + "id", *BundleData.__annotations__.keys() + ) } hostproviders_data = [ @@ -143,11 +144,3 @@ def __call__(self) -> ADCMEntities: bundles=bundles.values(), providers=hostproviders_data, ) - - -class CommunityBundleCollector(BundleCollector): - EDITION = "community" - - -class EnterpriseBundleCollector(BundleCollector): - EDITION = "enterprise" diff --git a/python/cm/management/commands/collect_statistics_new.py b/python/cm/management/commands/collect_statistics_new.py index aa8b63968d..b408ea5c6d 100644 --- a/python/cm/management/commands/collect_statistics_new.py +++ b/python/cm/management/commands/collect_statistics_new.py @@ -19,18 +19,17 @@ from django.core.management import BaseCommand from cm.adcm_config.config import get_adcm_config -from cm.collect_statistics.collectors import ( - ADCMEntities, - CommunityBundleCollector, - EnterpriseBundleCollector, - RBACCollector, -) +from cm.collect_statistics.collectors import ADCMEntities, BundleCollector, RBACCollector from cm.collect_statistics.encoders import TarFileEncoder from cm.collect_statistics.senders import SenderSettings, StatisticSender from cm.collect_statistics.storages import JSONFile, TarFileWithJSONFileStorage, TarFileWithTarFileStorage from cm.models import ADCM SENDER_REQUEST_TIMEOUT = 15.0 +DATE_FORMAT = "%Y-%m-%d %H:%M:%S" + +collect_community = BundleCollector(date_format=DATE_FORMAT, include_editions=["community"]) +collect_enterprise = BundleCollector(date_format=DATE_FORMAT, include_editions=["enterprise"]) class URLComponents(NamedTuple): @@ -68,14 +67,15 @@ def get_statistics_url() -> str: class Command(BaseCommand): help = "Collect data and send to Statistic Server" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + def add_arguments(self, parser): parser.add_argument("--full", action="store_true", help="collect all data") parser.add_argument("--send", action="store_true", help="send data to Statistic Server") parser.add_argument("--encode", action="store_true", help="encode data") def handle(self, *_, full: bool, send: bool, encode: bool, **__): - date_format = "%Y-%m-%d %H:%M:%S" - statistics_data = { "adcm": { "uuid": str(ADCM.objects.values_list("uuid", flat=True).get()), @@ -84,9 +84,9 @@ def handle(self, *_, full: bool, send: bool, encode: bool, **__): }, "format_version": "0.2", } - rbac_entries_data: dict = RBACCollector(date_format=date_format)().model_dump() + rbac_entries_data: dict = RBACCollector(date_format=DATE_FORMAT)().model_dump() - community_bundle_data: ADCMEntities = CommunityBundleCollector(date_format=date_format)() + community_bundle_data: ADCMEntities = collect_community() community_storage = TarFileWithJSONFileStorage() community_storage.add( @@ -101,7 +101,7 @@ def handle(self, *_, full: bool, send: bool, encode: bool, **__): final_storage.add(community_archive) if full: - enterprise_bundle_data: ADCMEntities = EnterpriseBundleCollector(date_format=date_format)() + enterprise_bundle_data: ADCMEntities = collect_enterprise() enterprise_storage = TarFileWithJSONFileStorage() enterprise_storage.add( diff --git a/python/cm/tests/test_collect_statistics.py b/python/cm/tests/test_collect_statistics.py deleted file mode 100644 index 4a7c4e2a20..0000000000 --- a/python/cm/tests/test_collect_statistics.py +++ /dev/null @@ -1,142 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from hashlib import md5 -from operator import itemgetter -from pathlib import Path - -from adcm.tests.base import BaseTestCase, BusinessLogicMixin -from django.utils import timezone - -from cm.collect_statistics.collectors import CommunityBundleCollector -from cm.models import Bundle - - -class TestBundle(BaseTestCase, BusinessLogicMixin): - def setUp(self) -> None: - super().setUp() - - self.bundles_dir = Path(__file__).parent / "bundles" - self.maxDiff = None - - def test_collect_community_bundle_collector(self) -> None: - # prepare data - bundle_cluster_reg = self.add_bundle(self.bundles_dir / "cluster_1") - bundle_cluster_full = self.add_bundle(self.bundles_dir / "cluster_full_config") - bundle_prov_reg = self.add_bundle(self.bundles_dir / "provider") - bundle_prov_full = self.add_bundle(self.bundles_dir / "provider_full_config") - - cluster_reg_1 = self.add_cluster(bundle=bundle_cluster_reg, name="Regular 1") - cluster_full = self.add_cluster(bundle=bundle_cluster_full, name="Full 1") - cluster_reg_2 = self.add_cluster(bundle=bundle_cluster_reg, name="Regular 2") - - provider_full_1 = self.add_provider(bundle=bundle_prov_full, name="Prov Full 1") - provider_full_2 = self.add_provider(bundle=bundle_prov_full, name="Prov Full 2") - provider_reg_1 = self.add_provider(bundle=bundle_prov_reg, name="Prov Reg 1") - - host_1 = self.add_host(provider=provider_full_1, fqdn="host-1", cluster=cluster_reg_1) - host_2 = self.add_host(provider=provider_full_1, fqdn="host-2", cluster=cluster_reg_2) - self.add_host(provider=provider_reg_1, fqdn="host-3", cluster=cluster_reg_1) - - self.add_services_to_cluster(["service_one_component"], cluster=cluster_reg_1) - service_2 = self.add_services_to_cluster(["service_two_components"], cluster=cluster_reg_1).get() - service_3 = self.add_services_to_cluster(["service_two_components"], cluster=cluster_reg_2).get() - - component_1, component_2 = service_2.servicecomponent_set.order_by("id").all() - component_3 = service_3.servicecomponent_set.order_by("id").first() - self.set_hostcomponent(cluster=cluster_reg_1, entries=((host_1, component_1), (host_1, component_2))) - - self.set_hostcomponent(cluster=cluster_reg_2, entries=((host_2, component_3),)) - - # prepare expected - order_hc_by = itemgetter("component_name") - by_name = itemgetter("name") - collect = CommunityBundleCollector(date_format="%Y") - current_year = str(timezone.now().year) - host_1_name_hash = md5(host_1.fqdn.encode("utf-8")).hexdigest() # noqa: S324 - host_2_name_hash = md5(host_2.fqdn.encode("utf-8")).hexdigest() # noqa: S324 - expected_bundles = [ - {"name": bundle.name, "version": bundle.version, "edition": "community", "date": current_year} - for bundle in ( - bundle_cluster_reg, - bundle_cluster_full, - bundle_prov_reg, - bundle_prov_full, - Bundle.objects.get(name="ADCM"), - ) - ] - expected = { - "bundles": sorted(expected_bundles, key=by_name), - "providers": sorted( - [ - {"name": provider_full_1.name, "bundle": expected_bundles[3], "host_count": 2}, - {"name": provider_full_2.name, "bundle": expected_bundles[3], "host_count": 0}, - {"name": provider_reg_1.name, "bundle": expected_bundles[2], "host_count": 1}, - ], - key=by_name, - ), - "clusters": sorted( - [ - { - "name": cluster_full.name, - "host_count": 0, - "bundle": expected_bundles[1], - "host_component_map": [], - }, - { - "name": cluster_reg_1.name, - "host_count": 2, - "bundle": expected_bundles[0], - "host_component_map": sorted( - [ - { - "host_name": host_1_name_hash, - "component_name": component_1.name, - "service_name": service_2.name, - }, - { - "host_name": host_1_name_hash, - "component_name": component_2.name, - "service_name": service_2.name, - }, - ], - key=order_hc_by, - ), - }, - { - "name": cluster_reg_2.name, - "host_count": 1, - "bundle": expected_bundles[0], - "host_component_map": [ - { - "host_name": host_2_name_hash, - "component_name": component_3.name, - "service_name": service_3.name, - }, - ], - }, - ], - key=by_name, - ), - } - - # check - actual = collect().model_dump() - - # order for reproducible comparison - for root_key in actual: - actual[root_key] = sorted(actual[root_key], key=by_name) - - for entry in actual["clusters"]: - entry["host_component_map"] = sorted(entry["host_component_map"], key=order_hc_by) - - self.assertDictEqual(actual, expected) diff --git a/python/cm/tests/test_management_commands.py b/python/cm/tests/test_management_commands.py index 4feb1f7dc6..5b3f864d2b 100644 --- a/python/cm/tests/test_management_commands.py +++ b/python/cm/tests/test_management_commands.py @@ -10,17 +10,22 @@ # See the License for the specific language governing permissions and # limitations under the License. +from hashlib import md5 +from operator import itemgetter from pathlib import Path from unittest.mock import Mock, call, mock_open, patch +from adcm.tests.base import BaseTestCase, BusinessLogicMixin from api_v2.tests.base import BaseAPITestCase, ParallelReadyTestCase from django.conf import settings from django.core.management import load_command_class from django.test import TestCase +from django.utils import timezone from rbac.models import Policy, Role, User from requests.exceptions import ConnectionError from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_405_METHOD_NOT_ALLOWED +from cm.collect_statistics.collectors import BundleCollector from cm.collect_statistics.errors import RetriesExceededError, SenderConnectionError from cm.collect_statistics.senders import SenderSettings, StatisticSender from cm.models import ADCM, Bundle, ServiceComponent @@ -302,3 +307,124 @@ def test_retry_only_failed(self, mocked_requests, mocked_open): # noqa: ARG002 self.assertListEqual( mocked_inner_send.call_args_list, [call(target=file_1), call(target=file_2), call(target=file_2)] ) + + +class TestBundleCollector(BaseTestCase, BusinessLogicMixin): + def setUp(self) -> None: + super().setUp() + + self.bundles_dir = Path(__file__).parent / "bundles" + self.maxDiff = None + + def test_collect_community_bundle_collector(self) -> None: + # prepare data + bundle_cluster_reg = self.add_bundle(self.bundles_dir / "cluster_1") + bundle_cluster_full = self.add_bundle(self.bundles_dir / "cluster_full_config") + bundle_prov_reg = self.add_bundle(self.bundles_dir / "provider") + bundle_prov_full = self.add_bundle(self.bundles_dir / "provider_full_config") + + cluster_reg_1 = self.add_cluster(bundle=bundle_cluster_reg, name="Regular 1") + cluster_full = self.add_cluster(bundle=bundle_cluster_full, name="Full 1") + cluster_reg_2 = self.add_cluster(bundle=bundle_cluster_reg, name="Regular 2") + + provider_full_1 = self.add_provider(bundle=bundle_prov_full, name="Prov Full 1") + provider_full_2 = self.add_provider(bundle=bundle_prov_full, name="Prov Full 2") + provider_reg_1 = self.add_provider(bundle=bundle_prov_reg, name="Prov Reg 1") + + host_1 = self.add_host(provider=provider_full_1, fqdn="host-1", cluster=cluster_reg_1) + host_2 = self.add_host(provider=provider_full_1, fqdn="host-2", cluster=cluster_reg_2) + self.add_host(provider=provider_reg_1, fqdn="host-3", cluster=cluster_reg_1) + + self.add_services_to_cluster(["service_one_component"], cluster=cluster_reg_1) + service_2 = self.add_services_to_cluster(["service_two_components"], cluster=cluster_reg_1).get() + service_3 = self.add_services_to_cluster(["service_two_components"], cluster=cluster_reg_2).get() + + component_1, component_2 = service_2.servicecomponent_set.order_by("id").all() + component_3 = service_3.servicecomponent_set.order_by("id").first() + self.set_hostcomponent(cluster=cluster_reg_1, entries=((host_1, component_1), (host_1, component_2))) + + self.set_hostcomponent(cluster=cluster_reg_2, entries=((host_2, component_3),)) + + # prepare expected + order_hc_by = itemgetter("component_name") + by_name = itemgetter("name") + collect = BundleCollector(date_format="%Y", include_editions=["community"]) + current_year = str(timezone.now().year) + host_1_name_hash = md5(host_1.fqdn.encode("utf-8")).hexdigest() # noqa: S324 + host_2_name_hash = md5(host_2.fqdn.encode("utf-8")).hexdigest() # noqa: S324 + expected_bundles = [ + {"name": bundle.name, "version": bundle.version, "edition": "community", "date": current_year} + for bundle in ( + bundle_cluster_reg, + bundle_cluster_full, + bundle_prov_reg, + bundle_prov_full, + Bundle.objects.get(name="ADCM"), + ) + ] + expected = { + "bundles": sorted(expected_bundles, key=by_name), + "providers": sorted( + [ + {"name": provider_full_1.name, "bundle": expected_bundles[3], "host_count": 2}, + {"name": provider_full_2.name, "bundle": expected_bundles[3], "host_count": 0}, + {"name": provider_reg_1.name, "bundle": expected_bundles[2], "host_count": 1}, + ], + key=by_name, + ), + "clusters": sorted( + [ + { + "name": cluster_full.name, + "host_count": 0, + "bundle": expected_bundles[1], + "host_component_map": [], + }, + { + "name": cluster_reg_1.name, + "host_count": 2, + "bundle": expected_bundles[0], + "host_component_map": sorted( + [ + { + "host_name": host_1_name_hash, + "component_name": component_1.name, + "service_name": service_2.name, + }, + { + "host_name": host_1_name_hash, + "component_name": component_2.name, + "service_name": service_2.name, + }, + ], + key=order_hc_by, + ), + }, + { + "name": cluster_reg_2.name, + "host_count": 1, + "bundle": expected_bundles[0], + "host_component_map": [ + { + "host_name": host_2_name_hash, + "component_name": component_3.name, + "service_name": service_3.name, + }, + ], + }, + ], + key=by_name, + ), + } + + # check + actual = collect().model_dump() + + # order for reproducible comparison + for root_key in actual: + actual[root_key] = sorted(actual[root_key], key=by_name) + + for entry in actual["clusters"]: + entry["host_component_map"] = sorted(entry["host_component_map"], key=order_hc_by) + + self.assertDictEqual(actual, expected) From 113136ea0fa534aa5ddc133f53a42ac8c67b1377 Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Fri, 31 May 2024 12:17:18 +0000 Subject: [PATCH 149/208] ADCM-5570: remove `reverse` from test_group.py --- python/adcm/tests/client.py | 3 +- python/api_v2/tests/test_group.py | 53 ++++++++----------------------- 2 files changed, 16 insertions(+), 40 deletions(-) diff --git a/python/adcm/tests/client.py b/python/adcm/tests/client.py index 6231bd2825..d387e12bb1 100644 --- a/python/adcm/tests/client.py +++ b/python/adcm/tests/client.py @@ -28,7 +28,7 @@ TaskLog, ) from django.test.client import AsyncClient -from rbac.models import Policy, Role, User +from rbac.models import Group, Policy, Role, User from rest_framework.response import Response from rest_framework.test import APIClient @@ -106,6 +106,7 @@ class V2RootNode(RootNode): Policy: "rbac/policies", User: "rbac/users", Role: "rbac/roles", + Group: "rbac/groups", } def __getitem__(self, item: PathObject | tuple[PathObject, str | int | WithID, ...]) -> APINode: diff --git a/python/api_v2/tests/test_group.py b/python/api_v2/tests/test_group.py index f266818ff6..0be5150d4f 100644 --- a/python/api_v2/tests/test_group.py +++ b/python/api_v2/tests/test_group.py @@ -10,7 +10,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from django.urls import reverse from rbac.models import Group, OriginType from rest_framework.response import Response from rest_framework.status import ( @@ -31,7 +30,7 @@ def setUp(self) -> None: self.group_ldap = Group.objects.create(name="test_ldap_group", type=OriginType.LDAP) def test_list_success(self): - response: Response = self.client.get(path=reverse(viewname="v2:rbac:group-list")) + response: Response = (self.client.v2 / "rbac" / "groups").get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 2) @@ -49,16 +48,13 @@ def test_list_no_permissions_success(self): self.create_user(user_data=user_create_data) self.client.login(**user_credentials) - response: Response = self.client.get(path=reverse(viewname="v2:rbac:group-list")) + response: Response = (self.client.v2 / "rbac" / "groups").get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 0) def test_create_required_fields_success(self): - response: Response = self.client.post( - path=reverse(viewname="v2:rbac:group-list"), - data={"display_name": "new group name"}, - ) + response: Response = (self.client.v2 / "rbac" / "groups").post(data={"display_name": "new group name"}) self.assertEqual(response.status_code, HTTP_201_CREATED) self.assertEqual(Group.objects.count(), 3) @@ -72,10 +68,7 @@ def test_create_with_user_success(self): new_user = self.create_user() create_data = {"display_name": "new group name", "description": "new group description", "users": [new_user.pk]} - response: Response = self.client.post( - path=reverse(viewname="v2:rbac:group-list"), - data=create_data, - ) + response: Response = (self.client.v2 / "rbac" / "groups").post(data=create_data) self.assertEqual(response.status_code, HTTP_201_CREATED) self.assertEqual(Group.objects.count(), 3) @@ -89,10 +82,7 @@ def test_update_success(self): "users": [new_user.pk], } - response: Response = self.client.patch( - path=reverse(viewname="v2:rbac:group-detail", kwargs={"pk": self.group_local.pk}), - data=update_data, - ) + response: Response = self.client.v2[self.group_local].patch(data=update_data) self.assertEqual(response.status_code, HTTP_200_OK) self.group_local.refresh_from_db() @@ -100,10 +90,7 @@ def test_update_success(self): self.assertEqual(self.group_local.description, update_data["description"]) self.assertListEqual(list(self.group_local.user_set.values_list("id", flat=True)), update_data["users"]) - response: Response = self.client.patch( - path=reverse(viewname="v2:rbac:group-detail", kwargs={"pk": self.group_local.pk}), - data={"display_name": "new_display name"}, - ) + response: Response = self.client.v2[self.group_local].patch(data={"display_name": "new_display name"}) self.assertEqual(response.status_code, HTTP_200_OK) self.group_local.refresh_from_db() @@ -113,18 +100,14 @@ def test_update_success(self): def test_delete_success(self): group_ldap_pk = self.group_ldap.pk - response: Response = self.client.delete( - path=reverse(viewname="v2:rbac:group-detail", kwargs={"pk": group_ldap_pk}) - ) + response: Response = self.client.v2[self.group_ldap].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) with self.assertRaises(Group.DoesNotExist): Group.objects.get(pk=group_ldap_pk) def test_ordering_by_name_success(self): - response: Response = self.client.get( - path=reverse(viewname="v2:rbac:group-list"), data={"ordering": "displayName"} - ) + response: Response = (self.client.v2 / "rbac" / "groups").get(query={"ordering": "displayName"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertListEqual( @@ -132,9 +115,7 @@ def test_ordering_by_name_success(self): [group.display_name for group in Group.objects.order_by("name")], ) - response: Response = self.client.get( - path=reverse(viewname="v2:rbac:group-list"), data={"ordering": "-displayName"} - ) + response: Response = (self.client.v2 / "rbac" / "groups").get(query={"ordering": "-displayName"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertListEqual( [group["displayName"] for group in response.json()["results"]], @@ -142,29 +123,23 @@ def test_ordering_by_name_success(self): ) def test_filtering_by_display_name_success(self): - response: Response = self.client.get( - path=reverse(viewname="v2:rbac:group-list"), data={"displayName": "nonexistentname"} - ) + response: Response = (self.client.v2 / "rbac" / "groups").get(query={"displayName": "nonexistentname"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 0) - response: Response = self.client.get( - path=reverse(viewname="v2:rbac:group-list"), data={"displayName": "_lDaP_"} - ) + response: Response = (self.client.v2 / "rbac" / "groups").get(query={"displayName": "_lDaP_"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) def test_filtering_by_type_success(self): - response: Response = self.client.get(path=reverse(viewname="v2:rbac:group-list"), data={"type": "local"}) + response: Response = (self.client.v2 / "rbac" / "groups").get(query={"type": "local"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["count"], 1) def test_filtering_by_wrong_type_fail(self): - response: Response = self.client.get( - path=reverse(viewname="v2:rbac:group-list"), data={"type": "wrong-group-type"} - ) + response: Response = (self.client.v2 / "rbac" / "groups").get(query={"type": "wrong-group-type"}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -176,7 +151,7 @@ def test_update_add_remove_users_success(self) -> None: user_2 = self.create_user(user_data={"username": "somebody22", "password": "very_long_veryvery", "groups": []}) self.assertEqual(group.user_set.count(), 1) - update_path = reverse(viewname="v2:rbac:group-detail", kwargs={"pk": group.pk}) + update_path = self.client.v2[group].path response = self.client.patch(path=update_path, data={"users": []}) self.assertEqual(response.status_code, HTTP_200_OK) From 29bc253035d3e259749dafbcc6dbe7f97b799fd9 Mon Sep 17 00:00:00 2001 From: astarovo Date: Fri, 31 May 2024 19:08:29 +0300 Subject: [PATCH 150/208] ADCM-5606: Implement storages for data --- python/cm/collect_statistics/errors.py | 4 + python/cm/collect_statistics/storages.py | 68 ++++++++++-- python/cm/tests/test_management_commands.py | 115 ++++++++++++++++++++ 3 files changed, 180 insertions(+), 7 deletions(-) diff --git a/python/cm/collect_statistics/errors.py b/python/cm/collect_statistics/errors.py index a9fe1e3f64..1218c34842 100644 --- a/python/cm/collect_statistics/errors.py +++ b/python/cm/collect_statistics/errors.py @@ -25,3 +25,7 @@ class SenderConnectionError(SenderError): class RetriesExceededError(SenderError): pass + + +class StorageError(Exception): + pass diff --git a/python/cm/collect_statistics/storages.py b/python/cm/collect_statistics/storages.py index abd7021c2c..d7ee2b3b04 100644 --- a/python/cm/collect_statistics/storages.py +++ b/python/cm/collect_statistics/storages.py @@ -11,9 +11,15 @@ # limitations under the License. from pathlib import Path +from tempfile import mkdtemp +import io +import json +import tarfile +import datetime from pydantic import BaseModel +from cm.collect_statistics.errors import StorageError from cm.collect_statistics.types import Storage @@ -23,16 +29,64 @@ class JSONFile(BaseModel): class TarFileWithJSONFileStorage(Storage[JSONFile]): + def __init__(self, compresslevel=9, timeformat="%Y-%m-%d"): + self.json_files = [] + self.tmp_dir = Path(mkdtemp()).absolute() + self.compresslevel = compresslevel + self.timeformat = timeformat + def add(self, data: JSONFile) -> None: - pass + """ + Adds a JSON file to the storage. - def gather(self) -> Path: - pass + Args: + data (JSONFile): The JSON file to add. + """ + if not isinstance(data, JSONFile): + raise StorageError(f"Expected JSONFile, got {type(data)}") + if len(data.data) == 0: + return -class TarFileWithTarFileStorage(Storage[Path]): - def add(self, data: Path) -> None: - pass + self.json_files.append(data) def gather(self) -> Path: - pass + """ + Generates a tarball archive containing JSON files. + + This function creates a tarball archive named "{today_date}_statistics_full.tgz" + using the current date. It iterates over the JSON files stored in the `json_files` + list and adds each file to the tarball. The file name and size are set using the + `tarfile.TarInfo` object. The contents of each JSON file are encoded as UTF-8 and + added to the tarball using `tarfile.addfile`. + + Returns: + Path: The path to the generated tarball archive. + """ + if not self: + raise StorageError("No JSON files to gather") + + today_date = datetime.datetime.now(tz=datetime.timezone.utc).strftime(self.timeformat) + archive_name = self.tmp_dir / f"{today_date}_statistics_full.tgz" + archive_path = Path(archive_name) + + with tarfile.open(archive_name, "w:gz", compresslevel=self.compresslevel) as tar: + for json_file in self: + data = json.dumps(obj=json_file.data).encode("utf8") + tgz_info = tarfile.TarInfo(name=json_file.filename) + tgz_info.size = len(data) + tar.addfile(tgz_info, io.BytesIO(data)) + + return archive_path + + def clear(self) -> None: + self.json_files = [] + + def __bool__(self) -> bool: + return bool(self.json_files) + + def __len__(self) -> int: + return len(self.json_files) + + def __iter__(self) -> iter: + return iter(self.json_files) diff --git a/python/cm/tests/test_management_commands.py b/python/cm/tests/test_management_commands.py index 5b3f864d2b..b460894db9 100644 --- a/python/cm/tests/test_management_commands.py +++ b/python/cm/tests/test_management_commands.py @@ -14,6 +14,10 @@ from operator import itemgetter from pathlib import Path from unittest.mock import Mock, call, mock_open, patch +import os +import json +import tarfile +import datetime from adcm.tests.base import BaseTestCase, BusinessLogicMixin from api_v2.tests.base import BaseAPITestCase, ParallelReadyTestCase @@ -28,6 +32,7 @@ from cm.collect_statistics.collectors import BundleCollector from cm.collect_statistics.errors import RetriesExceededError, SenderConnectionError from cm.collect_statistics.senders import SenderSettings, StatisticSender +from cm.collect_statistics.storages import JSONFile, StorageError, TarFileWithJSONFileStorage from cm.models import ADCM, Bundle, ServiceComponent from cm.tests.utils import gen_cluster, gen_provider @@ -428,3 +433,113 @@ def test_collect_community_bundle_collector(self) -> None: entry["host_component_map"] = sorted(entry["host_component_map"], key=order_hc_by) self.assertDictEqual(actual, expected) + + +class TestStorage(TestStatistics): + def read_tar(self, path: Path) -> list[dict]: + content = [] + with tarfile.open(path) as tar: + for member in tar.getmembers(): + with tar.extractfile(member) as f: + content.append(json.loads(f.read())) + return content + + def test_storage_one_file_success(self): + data = load_command_class(app_name="cm", name="collect_statistics").collect_statistics() + + community_storage = TarFileWithJSONFileStorage() + expected_name = ( + f"{datetime.datetime.now(tz=datetime.timezone.utc).date().strftime('%Y-%m-%d')}_statistics_full.tgz" + ) + + community_storage.add( + JSONFile( + filename="data.json", + data=data, + ) + ) + community_archive = community_storage.gather() + self.assertTrue(community_archive.exists()) + self.assertTrue(community_archive.is_file()) + self.assertTrue(community_archive.suffix == ".tgz") + self.assertEqual(community_archive.name, expected_name) + + self.assertListEqual(self.read_tar(community_archive), [data]) + + def test_storage_archive_written_twice_success(self): + data = load_command_class(app_name="cm", name="collect_statistics").collect_statistics() + + community_storage = TarFileWithJSONFileStorage() + expected_name = ( + f"{datetime.datetime.now(tz=datetime.timezone.utc).date().strftime('%Y-%m-%d')}_statistics_full.tgz" + ) + + community_storage.add( + JSONFile( + filename="data.json", + data=data, + ) + ) + community_archive = community_storage.gather() + self.assertEqual(community_archive.name, expected_name) + self.assertListEqual(self.read_tar(community_archive), [data]) + + community_archive = community_storage.gather() + self.assertEqual(community_archive.name, expected_name) + self.assertListEqual(self.read_tar(community_archive), [data]) + + def test_storage_several_files_success(self): + data_cm = load_command_class(app_name="cm", name="collect_statistics").collect_statistics() + full_stat = [data_cm, data_cm, data_cm, data_cm, data_cm, data_cm] + + community_storage = TarFileWithJSONFileStorage() + + for data in full_stat: + community_storage.add( + JSONFile( + filename="data.json", + data=data, + ) + ) + community_archive = community_storage.gather() + + for content, expected_data in zip(self.read_tar(community_archive), full_stat): + self.assertDictEqual(content, expected_data) + + def test_storage_clear_fail(self): + data = load_command_class(app_name="cm", name="collect_statistics").collect_statistics() + community_storage = TarFileWithJSONFileStorage() + community_storage.add( + JSONFile( + filename="data.json", + data=data, + ) + ) + community_storage.clear() + with self.assertRaises(StorageError): + community_storage.gather() + + def test_storage_empty_json_fail(self): + community_storage = TarFileWithJSONFileStorage() + community_storage.add( + JSONFile( + filename="data.json", + data={}, + ) + ) + with self.assertRaises(StorageError): + community_storage.gather() + + def test_no_intermediate_files_created(self): + data = load_command_class(app_name="cm", name="collect_statistics").collect_statistics() + community_storage = TarFileWithJSONFileStorage() + + json_file = JSONFile( + filename="data.json", + data=data, + ) + + community_storage.add(json_file) + community_archive = community_storage.gather() + self.assertFalse(community_archive.is_dir()) + self.assertFalse(os.path.exists(json_file.filename)) From 672fa5d5bff2954e9e430d862fd3c56cb4e92d06 Mon Sep 17 00:00:00 2001 From: astarovo Date: Mon, 3 Jun 2024 15:48:40 +0300 Subject: [PATCH 151/208] ADCM-5646: Fix return value for adcm_check plugin --- python/ansible_plugin/executors/check.py | 4 ++-- python/ansible_plugin/tests/test_adcm_check.py | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/python/ansible_plugin/executors/check.py b/python/ansible_plugin/executors/check.py index 8b2bda115d..751d369d24 100644 --- a/python/ansible_plugin/executors/check.py +++ b/python/ansible_plugin/executors/check.py @@ -152,6 +152,6 @@ def __call__( assign_view_logstorage_permissions_by_job(log_storage) except AdcmEx as e: error_message = f"Failed to create checklog: {check_data}, group: {group_data}, error: {e}" - return CallResult(value=None, changed=False, error=PluginRuntimeError(message=error_message)) + return CallResult(value="", changed=False, error=PluginRuntimeError(message=error_message)) - return CallResult(value=None, changed=True, error=None) + return CallResult(value="", changed=True, error=None) diff --git a/python/ansible_plugin/tests/test_adcm_check.py b/python/ansible_plugin/tests/test_adcm_check.py index d173bc57c7..f4c1f8c8ad 100644 --- a/python/ansible_plugin/tests/test_adcm_check.py +++ b/python/ansible_plugin/tests/test_adcm_check.py @@ -183,7 +183,7 @@ def test_adcm_check_no_msg_and_there_success_msg_and_fail_msg_success(self) -> N result = executor.execute() self.assertIsNone(result.error) - self.assertIsNone(result.value) + self.assertEqual(result.value, "") self.assertTrue(result.changed) def test_adcm_check_group_title_and_group_success_msg_success(self) -> None: @@ -245,7 +245,7 @@ def test_adcm_check_group_title_no_group_msg_but_there_msg_success(self) -> None result = executor.execute() self.assertIsNone(result.error) - self.assertIsNone(result.value, None) + self.assertEqual(result.value, "") self.assertTrue(result.changed) def test_adcm_check_group_title_no_group_msg_fail(self) -> None: @@ -286,9 +286,7 @@ def test_adcm_check_double_call_val_success(self) -> None: executor.execute() result = executor.execute() - self.assertIsNone( - result.value, - ) + self.assertEqual(result.value, "") self.assertTrue(result.changed) self.assertEqual(GroupCheckLog.objects.all().count(), 1) From ee94d17ffb61cd6937e48fe2857498bf90dff9b6 Mon Sep 17 00:00:00 2001 From: Aleksandr Alferov Date: Tue, 4 Jun 2024 08:02:08 +0000 Subject: [PATCH 152/208] ADCM-5621 Implement encoder, fix controller --- data/tmp/.gitkeep | 0 python/adcm/settings.py | 1 + python/adcm/tests/base.py | 1 + python/audit/utils.py | 33 +++++ python/cm/collect_statistics/collectors.py | 10 +- python/cm/collect_statistics/encoders.py | 27 +++- python/cm/collect_statistics/storages.py | 10 +- .../commands/collect_statistics_new.py | 135 ++++++++++++------ python/cm/tests/test_management_commands.py | 63 +++++++- 9 files changed, 215 insertions(+), 65 deletions(-) create mode 100644 data/tmp/.gitkeep diff --git a/data/tmp/.gitkeep b/data/tmp/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/adcm/settings.py b/python/adcm/settings.py index 400c8c20c4..7259917b5a 100644 --- a/python/adcm/settings.py +++ b/python/adcm/settings.py @@ -37,6 +37,7 @@ FILE_DIR = STACK_DIR / "data" / "file" LOG_DIR = DATA_DIR / "log" VAR_DIR = DATA_DIR / "var" +TMP_DIR = DATA_DIR / "tmp" LOG_FILE = LOG_DIR / "adcm.log" SECRETS_FILE = VAR_DIR / "secrets.json" ADCM_TOKEN_FILE = VAR_DIR / "adcm_token" diff --git a/python/adcm/tests/base.py b/python/adcm/tests/base.py index f071414380..b096b29200 100644 --- a/python/adcm/tests/base.py +++ b/python/adcm/tests/base.py @@ -104,6 +104,7 @@ def _prepare_temporal_directories_for_adcm() -> dict: "FILE_DIR": stack / "data" / "file", "LOG_DIR": data / "log", "VAR_DIR": data / "var", + "TMP_DIR": data / "tmp", } for directory in temporary_directories.values(): diff --git a/python/audit/utils.py b/python/audit/utils.py index a6e54807d7..6ca5da2a34 100644 --- a/python/audit/utils.py +++ b/python/audit/utils.py @@ -12,6 +12,7 @@ from contextlib import suppress from functools import wraps +from typing import Callable import re from api.cluster.serializers import ClusterAuditSerializer @@ -648,3 +649,35 @@ def audit_job_finish(owner: NamedCoreObject, display_name: str, is_upgrade: bool ) cef_logger(audit_instance=audit_log, signature_id="Action completion") + + +def audit_background_task(start_operation_status: str, end_operation_status: str) -> Callable: + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapped(*args, **kwargs): + make_audit_log( + operation_type="statistics", + result=AuditLogOperationResult.SUCCESS, + operation_status=start_operation_status, + ) + try: + result = func(*args, **kwargs) + except Exception as error: + make_audit_log( + operation_type="statistics", + result=AuditLogOperationResult.FAIL, + operation_status=end_operation_status, + ) + raise error + + make_audit_log( + operation_type="statistics", + result=AuditLogOperationResult.SUCCESS, + operation_status=end_operation_status, + ) + + return result + + return wrapped + + return decorator diff --git a/python/cm/collect_statistics/collectors.py b/python/cm/collect_statistics/collectors.py index cf2ea1807b..3b45350037 100644 --- a/python/cm/collect_statistics/collectors.py +++ b/python/cm/collect_statistics/collectors.py @@ -14,7 +14,7 @@ from hashlib import md5 from typing import Collection, Literal -from django.db.models import Count, F +from django.db.models import Count, F, Q from pydantic import BaseModel from rbac.models import Policy, Role, User from typing_extensions import TypedDict @@ -89,16 +89,14 @@ def __call__(self) -> RBACEntities: class BundleCollector: - def __init__(self, date_format: str, include_editions: Collection[str]): + def __init__(self, date_format: str, filters: Collection[Q]): self._date_format = date_format - self._editions = include_editions + self._filters = filters def __call__(self) -> ADCMEntities: bundles: dict[int, BundleData] = { entry.pop("id"): BundleData(date=entry.pop("date").strftime(self._date_format), **entry) - for entry in Bundle.objects.filter(edition__in=self._editions).values( - "id", *BundleData.__annotations__.keys() - ) + for entry in Bundle.objects.filter(*self._filters).values("id", *BundleData.__annotations__.keys()) } hostproviders_data = [ diff --git a/python/cm/collect_statistics/encoders.py b/python/cm/collect_statistics/encoders.py index 9f519069dc..bd90fa1601 100644 --- a/python/cm/collect_statistics/encoders.py +++ b/python/cm/collect_statistics/encoders.py @@ -16,8 +16,27 @@ class TarFileEncoder(Encoder[Path]): - def encode(self, data: Path) -> Path: - pass + """Encode and decode a file in place""" - def decode(self, data: Path) -> Path: - pass + __slots__ = ["suffix"] + + def __init__(self, suffix: str) -> None: + if suffix and not suffix.startswith(".") or suffix == ".": + raise ValueError(f"Invalid suffix '{suffix}'") + + self.suffix = suffix + + def encode(self, path_file: Path) -> Path: + encoded_data = bytearray((byte + 1) % 256 for byte in path_file.read_bytes()) + encoded_file = path_file.rename(path_file.parent / f"{path_file.name}{self.suffix}") + encoded_file.write_bytes(encoded_data) + return encoded_file + + def decode(self, path_file: Path) -> Path: + if not path_file.name.endswith(self.suffix): + raise ValueError(f"The file name must end with '{self.suffix}'") + + decoded_data = bytearray((byte - 1) % 256 for byte in path_file.read_bytes()) + decoded_file = path_file.rename(path_file.parent / path_file.name[: -len(self.suffix)]) + decoded_file.write_bytes(decoded_data) + return decoded_file diff --git a/python/cm/collect_statistics/storages.py b/python/cm/collect_statistics/storages.py index d7ee2b3b04..f5512da806 100644 --- a/python/cm/collect_statistics/storages.py +++ b/python/cm/collect_statistics/storages.py @@ -29,11 +29,13 @@ class JSONFile(BaseModel): class TarFileWithJSONFileStorage(Storage[JSONFile]): - def __init__(self, compresslevel=9, timeformat="%Y-%m-%d"): + __slots__ = ("json_files", "tmp_dir", "compresslevel", "date_format") + + def __init__(self, compresslevel=9, date_format="%Y-%m-%d"): self.json_files = [] self.tmp_dir = Path(mkdtemp()).absolute() self.compresslevel = compresslevel - self.timeformat = timeformat + self.date_format = date_format def add(self, data: JSONFile) -> None: """ @@ -66,8 +68,8 @@ def gather(self) -> Path: if not self: raise StorageError("No JSON files to gather") - today_date = datetime.datetime.now(tz=datetime.timezone.utc).strftime(self.timeformat) - archive_name = self.tmp_dir / f"{today_date}_statistics_full.tgz" + today_date = datetime.datetime.now(tz=datetime.timezone.utc).strftime(self.date_format) + archive_name = self.tmp_dir / f"{today_date}_statistics.tar.gz" archive_path = Path(archive_name) with tarfile.open(archive_name, "w:gz", compresslevel=self.compresslevel) as tar: diff --git a/python/cm/management/commands/collect_statistics_new.py b/python/cm/management/commands/collect_statistics_new.py index b408ea5c6d..9ae904d310 100644 --- a/python/cm/management/commands/collect_statistics_new.py +++ b/python/cm/management/commands/collect_statistics_new.py @@ -10,26 +10,35 @@ # See the License for the specific language governing permissions and # limitations under the License. +from logging import getLogger from typing import NamedTuple from urllib.parse import urlunparse import os import socket +from audit.utils import audit_background_task from django.conf import settings from django.core.management import BaseCommand +from django.db.models import Q +from django.utils import timezone from cm.adcm_config.config import get_adcm_config from cm.collect_statistics.collectors import ADCMEntities, BundleCollector, RBACCollector from cm.collect_statistics.encoders import TarFileEncoder from cm.collect_statistics.senders import SenderSettings, StatisticSender -from cm.collect_statistics.storages import JSONFile, TarFileWithJSONFileStorage, TarFileWithTarFileStorage +from cm.collect_statistics.storages import JSONFile, TarFileWithJSONFileStorage from cm.models import ADCM SENDER_REQUEST_TIMEOUT = 15.0 -DATE_FORMAT = "%Y-%m-%d %H:%M:%S" +DATE_TIME_FORMAT = "%Y-%m-%d %H:%M:%S" +DATE_FORMAT = "%Y-%m-%d" +STATISTIC_DIR = settings.TMP_DIR / "statistics" +STATISTIC_DIR.mkdir(exist_ok=True) -collect_community = BundleCollector(date_format=DATE_FORMAT, include_editions=["community"]) -collect_enterprise = BundleCollector(date_format=DATE_FORMAT, include_editions=["enterprise"]) +logger = getLogger("background_tasks") + +collect_not_enterprise = BundleCollector(date_format=DATE_TIME_FORMAT, filters=[~Q(edition="enterprise")]) +collect_all = BundleCollector(date_format=DATE_TIME_FORMAT, filters=[]) class URLComponents(NamedTuple): @@ -64,6 +73,14 @@ def get_statistics_url() -> str: return urlunparse(components=URLComponents(scheme=scheme, netloc=netloc, path=url_path)) +def get_enabled() -> bool: + if os.getenv("STATISTICS_ENABLED") is not None: + return os.environ["STATISTICS_ENABLED"].upper() in {"1", "TRUE"} + + attr, _ = get_adcm_config(section="statistics_collection") + return bool(attr["active"]) + + class Command(BaseCommand): help = "Collect data and send to Statistic Server" @@ -71,11 +88,19 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def add_arguments(self, parser): - parser.add_argument("--full", action="store_true", help="collect all data") - parser.add_argument("--send", action="store_true", help="send data to Statistic Server") - parser.add_argument("--encode", action="store_true", help="encode data") + parser.add_argument( + "--mode", + choices=["send", "archive-all"], + help=( + "'send' - collect archive with only community bundles and send to Statistic Server, " + "'archive-all' - collect community and enterprise bundles to archive and return path to file" + ), + default="archive-all", + ) - def handle(self, *_, full: bool, send: bool, encode: bool, **__): + @audit_background_task(start_operation_status="launched", end_operation_status="completed") + def handle(self, *_, mode: str, **__): + logger.debug(msg="Statistics collector: started") statistics_data = { "adcm": { "uuid": str(ADCM.objects.values_list("uuid", flat=True).get()), @@ -84,47 +109,63 @@ def handle(self, *_, full: bool, send: bool, encode: bool, **__): }, "format_version": "0.2", } - rbac_entries_data: dict = RBACCollector(date_format=DATE_FORMAT)().model_dump() + logger.debug(msg="Statistics collector: RBAC data preparation") + rbac_entries_data: dict = RBACCollector(date_format=DATE_TIME_FORMAT)().model_dump() + storage = TarFileWithJSONFileStorage(date_format=DATE_FORMAT) - community_bundle_data: ADCMEntities = collect_community() - community_storage = TarFileWithJSONFileStorage() + match mode: + case "send": + logger.debug(msg="Statistics collector: 'send' mode is used") - community_storage.add( - JSONFile( - filename="community.json", - data={**statistics_data, **rbac_entries_data, **community_bundle_data.model_dump()}, - ) - ) - community_archive = community_storage.gather() + if not get_enabled(): + logger.debug(msg="Statistics collector: disabled") + return - final_storage = TarFileWithTarFileStorage() - final_storage.add(community_archive) + logger.debug( + msg="Statistics collector: bundles data preparation, collect everything except 'enterprise' edition" + ) + bundle_data: ADCMEntities = collect_not_enterprise() + storage.add( + JSONFile( + filename=f"{timezone.now().strftime(DATE_FORMAT)}_statistics.json", + data={**statistics_data, **rbac_entries_data, **bundle_data.model_dump()}, + ) + ) + logger.debug(msg="Statistics collector: archive preparation") + archive = storage.gather() + sender_settings = SenderSettings( + url=get_statistics_url(), + adcm_uuid=statistics_data["adcm"]["uuid"], + retries_limit=int(os.getenv("STATISTICS_RETRIES", 10)), + retries_frequency=int(os.getenv("STATISTICS_FREQUENCY", 1 * 60 * 60)), # in seconds + request_timeout=SENDER_REQUEST_TIMEOUT, + ) + logger.debug(msg="Statistics collector: sender preparation") + sender = StatisticSender(settings=sender_settings) + logger.debug(msg="Statistics collector: statistics sending has started") + sender.send([archive]) + logger.debug(msg="Statistics collector: sending statistics completed") + + case "archive-all": + logger.debug(msg="Statistics collector: 'archive-all' mode is used") + logger.debug(msg="Statistics collector: bundles data preparation, collect everything") + bundle_data: ADCMEntities = collect_all() + storage.add( + JSONFile( + filename=f"{timezone.now().strftime(DATE_FORMAT)}_statistics.json", + data={**statistics_data, **rbac_entries_data, **bundle_data.model_dump()}, + ) + ) + logger.debug(msg="Statistics collector: archive preparation") + archive = storage.gather() - if full: - enterprise_bundle_data: ADCMEntities = collect_enterprise() - enterprise_storage = TarFileWithJSONFileStorage() + logger.debug(msg="Statistics collector: archive encoding") + encoder = TarFileEncoder(suffix=".enc") + encoded_file = encoder.encode(path_file=archive) + encoded_file = encoded_file.replace(STATISTIC_DIR / encoded_file.name) - enterprise_storage.add( - JSONFile( - filename="enterprise.json", - data={**statistics_data, **rbac_entries_data, **enterprise_bundle_data.model_dump()}, - ) - ) - final_storage.add(enterprise_storage.gather()) - - final_archive = final_storage.gather() - - if encode: - encoder = TarFileEncoder() - encoder.encode(final_archive) - - if send: - sender_settings = SenderSettings( - url=get_statistics_url(), - adcm_uuid=statistics_data["adcm"]["uuid"], - retries_limit=int(os.getenv("STATISTICS_RETRIES", 10)), - retries_frequency=int(os.getenv("STATISTICS_FREQUENCY", 1 * 60 * 60)), # in seconds - request_timeout=SENDER_REQUEST_TIMEOUT, - ) - sender = StatisticSender(settings=sender_settings) - sender.send([community_archive]) + self.stdout.write(f"Data saved in: {str(encoded_file.absolute())}") + case _: + pass + + logger.debug(msg="Statistics collector: finished") diff --git a/python/cm/tests/test_management_commands.py b/python/cm/tests/test_management_commands.py index b460894db9..200b463b7b 100644 --- a/python/cm/tests/test_management_commands.py +++ b/python/cm/tests/test_management_commands.py @@ -13,6 +13,7 @@ from hashlib import md5 from operator import itemgetter from pathlib import Path +from tempfile import NamedTemporaryFile from unittest.mock import Mock, call, mock_open, patch import os import json @@ -23,6 +24,7 @@ from api_v2.tests.base import BaseAPITestCase, ParallelReadyTestCase from django.conf import settings from django.core.management import load_command_class +from django.db.models import Q from django.test import TestCase from django.utils import timezone from rbac.models import Policy, Role, User @@ -30,6 +32,7 @@ from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_405_METHOD_NOT_ALLOWED from cm.collect_statistics.collectors import BundleCollector +from cm.collect_statistics.encoders import TarFileEncoder from cm.collect_statistics.errors import RetriesExceededError, SenderConnectionError from cm.collect_statistics.senders import SenderSettings, StatisticSender from cm.collect_statistics.storages import JSONFile, StorageError, TarFileWithJSONFileStorage @@ -353,7 +356,7 @@ def test_collect_community_bundle_collector(self) -> None: # prepare expected order_hc_by = itemgetter("component_name") by_name = itemgetter("name") - collect = BundleCollector(date_format="%Y", include_editions=["community"]) + collect = BundleCollector(date_format="%Y", filters=[Q(edition="community")]) current_year = str(timezone.now().year) host_1_name_hash = md5(host_1.fqdn.encode("utf-8")).hexdigest() # noqa: S324 host_2_name_hash = md5(host_2.fqdn.encode("utf-8")).hexdigest() # noqa: S324 @@ -449,7 +452,7 @@ def test_storage_one_file_success(self): community_storage = TarFileWithJSONFileStorage() expected_name = ( - f"{datetime.datetime.now(tz=datetime.timezone.utc).date().strftime('%Y-%m-%d')}_statistics_full.tgz" + f"{datetime.datetime.now(tz=datetime.timezone.utc).date().strftime('%Y-%m-%d')}_statistics.tar.gz" ) community_storage.add( @@ -461,7 +464,7 @@ def test_storage_one_file_success(self): community_archive = community_storage.gather() self.assertTrue(community_archive.exists()) self.assertTrue(community_archive.is_file()) - self.assertTrue(community_archive.suffix == ".tgz") + self.assertTrue(community_archive.suffixes == [".tar", ".gz"]) self.assertEqual(community_archive.name, expected_name) self.assertListEqual(self.read_tar(community_archive), [data]) @@ -471,7 +474,7 @@ def test_storage_archive_written_twice_success(self): community_storage = TarFileWithJSONFileStorage() expected_name = ( - f"{datetime.datetime.now(tz=datetime.timezone.utc).date().strftime('%Y-%m-%d')}_statistics_full.tgz" + f"{datetime.datetime.now(tz=datetime.timezone.utc).date().strftime('%Y-%m-%d')}_statistics.tar.gz" ) community_storage.add( @@ -543,3 +546,55 @@ def test_no_intermediate_files_created(self): community_archive = community_storage.gather() self.assertFalse(community_archive.is_dir()) self.assertFalse(os.path.exists(json_file.filename)) + + +class TestEncoder(TestCase, ParallelReadyTestCase): + def test_uncorrected_suffix(self): + with self.assertRaises(ValueError) as error: + TarFileEncoder(suffix="enc") + + self.assertEqual(str(error.exception), "Invalid suffix 'enc'") + + def test_uncorrected_filename(self): + with self.assertRaises(ValueError) as error: + encoder = TarFileEncoder(suffix=".enc") + encoder.decode(Path("test.tar.gz")) + + self.assertEqual(str(error.exception), "The file name must end with '.enc'") + + def test_encode(self): + path_file = Path(NamedTemporaryFile(suffix=".tar.gz").name) + path_file.write_text("content") + + encoder = TarFileEncoder(suffix=".enc") + encoded_file = encoder.encode(path_file=path_file) + + self.assertTrue(encoded_file.exists()) + self.assertTrue(encoded_file.is_file()) + self.assertTrue(encoded_file.suffix == ".enc") + self.assertTrue(encoded_file.read_bytes() == b"dpoufou") + + def test_decode(self): + encoded_file = Path(NamedTemporaryFile(suffix=".tar.gz.enc").name) + encoded_file.write_bytes(b"dpoufou") + + encoder = TarFileEncoder(suffix=".enc") + decoded_file = encoder.decode(path_file=encoded_file) + + self.assertTrue(decoded_file.exists()) + self.assertTrue(decoded_file.is_file()) + self.assertTrue(decoded_file.suffixes == [".tar", ".gz"]) + self.assertTrue(decoded_file.read_bytes() == b"content") + + def test_encode_decode(self): + path_file = Path(NamedTemporaryFile(suffix=".tar.gz").name) + path_file.write_text("content") + + encoder = TarFileEncoder(suffix=".enc") + encoded_file = encoder.encode(path_file=path_file) + decoded_file = encoder.decode(path_file=encoded_file) + + self.assertTrue(decoded_file.exists()) + self.assertTrue(decoded_file.is_file()) + self.assertTrue(decoded_file.suffixes, [".tar", ".gz"]) + self.assertTrue(decoded_file.read_bytes() == b"content") From e6c51a5517e1220be46ab43d0477b35010b8964e Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Tue, 4 Jun 2024 08:43:03 +0000 Subject: [PATCH 153/208] ADCM-5641 Change `ansible.cfg` preparation for ansible jobs Changed: 1. `ansible.cfg` from job's `run` directory is used unconditionally now 2. `ansible.cfg` is either taken from bundle's root directory or static defaults are used 3. For Cluster/Service/Component's owned jobs contents of `ansible.cfg` are enriched/overridden from `AnsibleConfig` instance linked to cluster Removed: 1. `ansible_settings` section from ADCM's configuration 2. `jinja2_native` passing to `ansible.cfg` from action's definition (section `ansible_tags`) Added: 1. `AnsibleConfig` model + migration with adding defaults for all existing clusters 2. Creation of `AnsibleConfig` entry on cluster creation --- conf/adcm/config.yaml | 14 +-- .../responses/config_schemas/for_adcm.json | 41 ------- python/api_v2/tests/test_audit/test_adcm.py | 1 - python/api_v2/tests/test_config.py | 1 - python/cm/api.py | 15 +++ .../cm/migrations/0126_add_ansible_config.py | 60 +++++++++ python/cm/models.py | 10 ++ .../cm/services/job/run/_target_factories.py | 49 +++++--- python/cm/services/job/run/executors.py | 7 +- .../cluster_with_action_params/config.yaml | 11 +- .../action_configs/cluster.json.j2 | 3 - .../action_configs/cluster_on_host.json.j2 | 3 - .../action_configs/component.json.j2 | 3 - .../action_configs/component_on_host.json.j2 | 3 - .../action_configs/host.json.j2 | 3 - .../action_configs/hostprovider.json.j2 | 3 - .../job_bundle_relative_cluster.json.j2 | 3 - .../job_bundle_relative_service.json.j2 | 3 - .../job_proto_relative_cluster.json.j2 | 3 - .../job_proto_relative_service.json.j2 | 3 - .../action_configs/service.json.j2 | 3 - .../action_configs/service_on_host.json.j2 | 3 - ...task_mixed_bundle_relative_cluster.json.j2 | 3 - ...task_mixed_bundle_relative_service.json.j2 | 3 - .../task_mixed_proto_relative_cluster.json.j2 | 3 - .../task_mixed_proto_relative_service.json.j2 | 3 - python/cm/tests/test_action.py | 115 +++++++++--------- python/core/job/runners.py | 7 +- 28 files changed, 196 insertions(+), 183 deletions(-) create mode 100644 python/cm/migrations/0126_add_ansible_config.py diff --git a/conf/adcm/config.yaml b/conf/adcm/config.yaml index 4502ed10f2..a41b5948dd 100755 --- a/conf/adcm/config.yaml +++ b/conf/adcm/config.yaml @@ -2,7 +2,7 @@ type: adcm name: ADCM - version: 3.4 + version: 3.5 actions: run_ldap_sync: @@ -82,18 +82,6 @@ no_confirm: true ui_options: invisible: true - - name: "ansible_settings" - display_name: "Ansible Settings" - type: "group" - subs: - - name: "forks" - display_name: "Forks" - description: | - This is the default number of parallel processes to spawn when communicating with remote hosts. - type: integer - default: 5 - min: 1 - max: 100 - name: "logrotate" display_name: "Nginx Server Logrotate" type: "group" diff --git a/python/api_v2/tests/files/responses/config_schemas/for_adcm.json b/python/api_v2/tests/files/responses/config_schemas/for_adcm.json index 474e82d0b2..33dc28e2d7 100644 --- a/python/api_v2/tests/files/responses/config_schemas/for_adcm.json +++ b/python/api_v2/tests/files/responses/config_schemas/for_adcm.json @@ -265,46 +265,6 @@ "secret" ] }, - "ansible_settings": { - "title": "Ansible Settings", - "type": "object", - "description": "", - "default": {}, - "readOnly": false, - "adcmMeta": { - "isAdvanced": false, - "isInvisible": false, - "activation": null, - "synchronization": null, - "isSecret": false, - "stringExtra": null, - "enumExtra": null - }, - "additionalProperties": false, - "properties": { - "forks": { - "title": "Forks", - "type": "integer", - "description": "This is the default number of parallel processes to spawn when communicating with remote hosts.\n", - "default": 5, - "readOnly": false, - "adcmMeta": { - "isAdvanced": false, - "isInvisible": false, - "activation": null, - "synchronization": null, - "isSecret": false, - "stringExtra": null, - "enumExtra": null - }, - "minimum": 1, - "maximum": 100 - } - }, - "required": [ - "forks" - ] - }, "logrotate": { "title": "Nginx Server Logrotate", "type": "object", @@ -959,7 +919,6 @@ "statistics_collection", "google_oauth", "yandex_oauth", - "ansible_settings", "logrotate", "audit_data_retention", "ldap_integration", diff --git a/python/api_v2/tests/test_audit/test_adcm.py b/python/api_v2/tests/test_audit/test_adcm.py index a5ffe994c5..27ed79b783 100644 --- a/python/api_v2/tests/test_audit/test_adcm.py +++ b/python/api_v2/tests/test_audit/test_adcm.py @@ -40,7 +40,6 @@ def setUp(self) -> None: "global": {"adcm_url": "http://127.0.0.1:8000", "verification_public_key": "\n"}, "google_oauth": {"client_id": None, "secret": None}, "yandex_oauth": {"client_id": None, "secret": None}, - "ansible_settings": {"forks": 5}, "logrotate": {"size": "10M", "max_history": 10, "compress": False}, "audit_data_retention": { "log_rotation_on_fs": 365, diff --git a/python/api_v2/tests/test_config.py b/python/api_v2/tests/test_config.py index 9c1a8879f7..46d519f4c5 100644 --- a/python/api_v2/tests/test_config.py +++ b/python/api_v2/tests/test_config.py @@ -2284,7 +2284,6 @@ def test_create_success(self): "global": {"adcm_url": "http://127.0.0.1:8000", "verification_public_key": "\n"}, "google_oauth": {"client_id": None, "secret": None}, "yandex_oauth": {"client_id": None, "secret": None}, - "ansible_settings": {"forks": 5}, "logrotate": {"size": "10M", "max_history": 10, "compress": False}, "audit_data_retention": { "log_rotation_on_fs": 365, diff --git a/python/cm/api.py b/python/cm/api.py index 5317394a9d..22b926f754 100644 --- a/python/cm/api.py +++ b/python/cm/api.py @@ -48,6 +48,7 @@ from cm.models import ( ADCM, ADCMEntity, + AnsibleConfig, Cluster, ClusterBind, ClusterObject, @@ -76,6 +77,11 @@ ) from cm.utils import obj_ref +# There's no good place to place it for now. +# Since it's more about API than `cm.services.job`, it'll live here. +# But don't stick to it. +DEFAULT_FORKS_AMOUNT: str = "5" + def check_license(prototype: Prototype) -> None: if prototype.license == "unaccepted": @@ -110,14 +116,23 @@ def add_cluster(prototype: Prototype, name: str, description: str = "") -> Clust raise_adcm_ex("OBJ_TYPE_ERROR", f"Prototype type should be cluster, not {prototype.type}") check_license(prototype) + with atomic(): cluster = Cluster.objects.create(prototype=prototype, name=name, description=description) obj_conf = init_object_config(prototype, cluster) cluster.config = obj_conf cluster.save() + + AnsibleConfig.objects.create( + value={"defaults": {"forks": DEFAULT_FORKS_AMOUNT}}, + object_id=cluster.id, + object_type=ContentType.objects.get_for_model(Cluster), + ) + update_hierarchy_issues(cluster) reset_hc_map() + logger.info("cluster #%s %s is added", cluster.pk, cluster.name) return cluster diff --git a/python/cm/migrations/0126_add_ansible_config.py b/python/cm/migrations/0126_add_ansible_config.py new file mode 100644 index 0000000000..de362bae3f --- /dev/null +++ b/python/cm/migrations/0126_add_ansible_config.py @@ -0,0 +1,60 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated by Django 3.2.23 on 2024-06-03 08:32 + +from django.db import migrations, models +import django.db.models.deletion + + +def add_defaults_for_existing_clusters(apps, schema_editor): + AnsibleConfig = apps.get_model("cm", "AnsibleConfig") + Cluster = apps.get_model("cm", "Cluster") + ContentType = apps.get_model("contenttypes", "ContentType") + + default = {"defaults": {"forks": "5"}} + + content_type = ContentType.objects.get_for_model(Cluster) + + AnsibleConfig.objects.bulk_create( + objs=[ + AnsibleConfig(value=default, object_id=cluster_id, object_type=content_type) + for cluster_id in Cluster.objects.values_list("id", flat=True) + ] + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("cm", "0125_simplify_defaults"), + ] + + operations = [ + migrations.CreateModel( + name="AnsibleConfig", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("value", models.JSONField(default=dict)), + ("object_id", models.PositiveIntegerField()), + ( + "object_type", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype"), + ), + ], + ), + migrations.AddConstraint( + model_name="ansibleconfig", + constraint=models.UniqueConstraint(fields=("object_id", "object_type"), name="unique_ansibleconfig"), + ), + migrations.RunPython(add_defaults_for_existing_clusters, migrations.RunPython.noop), + ] diff --git a/python/cm/models.py b/python/cm/models.py index 48d604c107..aa11547b69 100644 --- a/python/cm/models.py +++ b/python/cm/models.py @@ -240,6 +240,16 @@ class Meta: unique_together = (("bundle", "type", "parent", "name", "version"),) +class AnsibleConfig(ADCMModel): + value = models.JSONField(default=dict, null=False) + object_id = models.PositiveIntegerField(null=False) + object_type = models.ForeignKey(ContentType, null=False, on_delete=models.CASCADE) + object = GenericForeignKey("object_type", "object_id") + + class Meta: + constraints = [models.UniqueConstraint(fields=["object_id", "object_type"], name="unique_ansibleconfig")] + + class ObjectConfig(ADCMModel): current = models.PositiveIntegerField() previous = models.PositiveIntegerField() diff --git a/python/cm/services/job/run/_target_factories.py b/python/cm/services/job/run/_target_factories.py index 880edd461e..ecd02d3fde 100644 --- a/python/cm/services/job/run/_target_factories.py +++ b/python/cm/services/job/run/_target_factories.py @@ -21,11 +21,13 @@ from core.job.runners import ExecutionTarget, ExternalSettings from core.job.types import Job, ScriptType, Task from core.types import ADCMCoreType +from django.contrib.contenttypes.models import ContentType from django.db.transaction import atomic from rbac.roles import re_apply_policy_for_jobs from cm.api import get_hc, save_hc from cm.models import ( + AnsibleConfig, Cluster, HostComponent, LogStorage, @@ -33,7 +35,6 @@ ServiceComponent, TaskLog, ) -from cm.services.adcm import adcm_config, get_adcm_config_id from cm.services.job._utils import cook_delta, get_old_hc from cm.services.job.checks import check_hostcomponentmap from cm.services.job.inventory import get_adcm_configuration, get_inventory_data @@ -54,6 +55,7 @@ ServiceActionType, ) from cm.status_api import send_prototype_and_state_update_event +from cm.utils import deep_merge class ExecutionTargetFactory: @@ -203,21 +205,9 @@ def prepare_ansible_environment(task: Task, job: Job, configuration: ExternalSet with (job_run_dir / "inventory.json").open(mode="w", encoding="utf-8") as file_descriptor: json.dump(obj=inventory, fp=file_descriptor, separators=(",", ":")) - config_parser = ConfigParser() - config_parser["defaults"] = { - "stdout_callback": "yaml", - "callback_whitelist": "profile_tasks", - } - - forks = adcm_config(get_adcm_config_id()).config["ansible_settings"]["forks"] - config_parser["defaults"]["forks"] = str(forks) - - jinja_2_native = getattr(job.params, "jinja2_native", None) - if jinja_2_native is not None: - config_parser["defaults"]["jinja2_native"] = str(jinja_2_native) - + ansible_cfg_config_parser: ConfigParser = prepare_ansible_cfg(task=task) with (job_run_dir / "ansible.cfg").open(mode="w", encoding="utf-8") as config_file: - config_parser.write(config_file) + ansible_cfg_config_parser.write(config_file) def prepare_ansible_inventory(task: Task) -> dict[str, Any]: @@ -296,6 +286,35 @@ def prepare_ansible_job_config(task: Task, job: Job, configuration: ExternalSett ).model_dump(exclude_unset=True) +def prepare_ansible_cfg(task: Task) -> ConfigParser: + config_parser = ConfigParser() + + ansible_cfg_from_bundle = task.bundle.root / "ansible.cfg" + if ansible_cfg_from_bundle.is_file(): + config_parser.read(filenames=ansible_cfg_from_bundle, encoding="utf-8") + else: + config_parser["defaults"] = { + "deprecation_warnings": False, + "callback_whitelist": "profile_tasks", + "stdout_callback": "yaml", + } + config_parser["ssh_connection"] = {"retries": "3", "pipelining": True} + + if task.owner.type in {ADCMCoreType.CLUSTER, ADCMCoreType.SERVICE, ADCMCoreType.COMPONENT}: + cluster_id = task.owner.id if task.owner.type == ADCMCoreType.CLUSTER else task.owner.related_objects.cluster.id + + settings_to_override = ( + AnsibleConfig.objects.values_list("value", flat=True) + .filter(object_id=cluster_id, object_type=ContentType.objects.get_for_model(Cluster)) + .first() + ) + # we consider that if we got settings, they are of correct form (string values), + # otherwise `deep_merge` might fail + deep_merge(origin=config_parser, renovator=settings_to_override or {}) + + return config_parser + + def _get_owner_specific_data( task: Task, ) -> ClusterActionType | ServiceActionType | ComponentActionType | HostProviderActionType | HostActionType: diff --git a/python/cm/services/job/run/executors.py b/python/cm/services/job/run/executors.py index f2486d1643..9e0773a6dc 100644 --- a/python/cm/services/job/run/executors.py +++ b/python/cm/services/job/run/executors.py @@ -67,11 +67,8 @@ def _get_environment_variables(self) -> dict: env = get_env_with_venv_path(venv=self._config.venv, existing_env=env) - # This condition is intended to support compatibility. - # Since older bundle versions may contain their own ansible.cfg - if not Path(self._config.bundle.root, "ansible.cfg").is_file(): - # bundle root dir (workdir) is used as in `stack_dir` in ansible job config - env["ANSIBLE_CONFIG"] = str(self._config.work_dir / "ansible.cfg") + # According to ADCM-4975 we now always use `ansible.cfg` from job's run directory + env["ANSIBLE_CONFIG"] = str(self._config.work_dir / "ansible.cfg") return env diff --git a/python/cm/tests/bundles/cluster_with_action_params/config.yaml b/python/cm/tests/bundles/cluster_with_action_params/config.yaml index d083b8e3c0..9570a31423 100755 --- a/python/cm/tests/bundles/cluster_with_action_params/config.yaml +++ b/python/cm/tests/bundles/cluster_with_action_params/config.yaml @@ -5,7 +5,7 @@ version: &version '1.0' edition: community config_group_customization: true - actions: + actions: &actions action_full: &action type: job allow_to_terminate: true @@ -84,3 +84,12 @@ required: false default: string +- type: service + name: same_actioned_service + version: 2 + + actions: *actions + + components: + same_actions: + actions: *actions diff --git a/python/cm/tests/files/response_templates/action_configs/cluster.json.j2 b/python/cm/tests/files/response_templates/action_configs/cluster.json.j2 index 1e2a1663cd..d7938a717c 100644 --- a/python/cm/tests/files/response_templates/action_configs/cluster.json.j2 +++ b/python/cm/tests/files/response_templates/action_configs/cluster.json.j2 @@ -16,9 +16,6 @@ "client_id": null, "secret": null }, - "ansible_settings": { - "forks": 5 - }, "logrotate": null, "audit_data_retention": { "log_rotation_on_fs": 365, diff --git a/python/cm/tests/files/response_templates/action_configs/cluster_on_host.json.j2 b/python/cm/tests/files/response_templates/action_configs/cluster_on_host.json.j2 index 86882e47c0..3c6855a4d9 100644 --- a/python/cm/tests/files/response_templates/action_configs/cluster_on_host.json.j2 +++ b/python/cm/tests/files/response_templates/action_configs/cluster_on_host.json.j2 @@ -16,9 +16,6 @@ "client_id": null, "secret": null }, - "ansible_settings": { - "forks": 5 - }, "logrotate": null, "audit_data_retention": { "log_rotation_on_fs": 365, diff --git a/python/cm/tests/files/response_templates/action_configs/component.json.j2 b/python/cm/tests/files/response_templates/action_configs/component.json.j2 index 083b8b5efc..461125a533 100644 --- a/python/cm/tests/files/response_templates/action_configs/component.json.j2 +++ b/python/cm/tests/files/response_templates/action_configs/component.json.j2 @@ -16,9 +16,6 @@ "client_id": null, "secret": null }, - "ansible_settings": { - "forks": 5 - }, "logrotate": null, "audit_data_retention": { "log_rotation_on_fs": 365, diff --git a/python/cm/tests/files/response_templates/action_configs/component_on_host.json.j2 b/python/cm/tests/files/response_templates/action_configs/component_on_host.json.j2 index 81017617a3..749689d59c 100644 --- a/python/cm/tests/files/response_templates/action_configs/component_on_host.json.j2 +++ b/python/cm/tests/files/response_templates/action_configs/component_on_host.json.j2 @@ -16,9 +16,6 @@ "client_id": null, "secret": null }, - "ansible_settings": { - "forks": 5 - }, "logrotate": null, "audit_data_retention": { "log_rotation_on_fs": 365, diff --git a/python/cm/tests/files/response_templates/action_configs/host.json.j2 b/python/cm/tests/files/response_templates/action_configs/host.json.j2 index a1f6915eef..9bdf7a53e6 100644 --- a/python/cm/tests/files/response_templates/action_configs/host.json.j2 +++ b/python/cm/tests/files/response_templates/action_configs/host.json.j2 @@ -16,9 +16,6 @@ "client_id": null, "secret": null }, - "ansible_settings": { - "forks": 5 - }, "logrotate": null, "audit_data_retention": { "log_rotation_on_fs": 365, diff --git a/python/cm/tests/files/response_templates/action_configs/hostprovider.json.j2 b/python/cm/tests/files/response_templates/action_configs/hostprovider.json.j2 index beb2880e38..e704499f0a 100644 --- a/python/cm/tests/files/response_templates/action_configs/hostprovider.json.j2 +++ b/python/cm/tests/files/response_templates/action_configs/hostprovider.json.j2 @@ -16,9 +16,6 @@ "client_id": null, "secret": null }, - "ansible_settings": { - "forks": 5 - }, "logrotate": null, "audit_data_retention": { "log_rotation_on_fs": 365, diff --git a/python/cm/tests/files/response_templates/action_configs/job_bundle_relative_cluster.json.j2 b/python/cm/tests/files/response_templates/action_configs/job_bundle_relative_cluster.json.j2 index 0d3a27eeea..3e3fb4abb7 100644 --- a/python/cm/tests/files/response_templates/action_configs/job_bundle_relative_cluster.json.j2 +++ b/python/cm/tests/files/response_templates/action_configs/job_bundle_relative_cluster.json.j2 @@ -16,9 +16,6 @@ "client_id": null, "secret": null }, - "ansible_settings": { - "forks": 5 - }, "logrotate": null, "audit_data_retention": { "log_rotation_on_fs": 365, diff --git a/python/cm/tests/files/response_templates/action_configs/job_bundle_relative_service.json.j2 b/python/cm/tests/files/response_templates/action_configs/job_bundle_relative_service.json.j2 index f2950c31c6..7fc0ce65b4 100644 --- a/python/cm/tests/files/response_templates/action_configs/job_bundle_relative_service.json.j2 +++ b/python/cm/tests/files/response_templates/action_configs/job_bundle_relative_service.json.j2 @@ -16,9 +16,6 @@ "client_id": null, "secret": null }, - "ansible_settings": { - "forks": 5 - }, "logrotate": null, "audit_data_retention": { "log_rotation_on_fs": 365, diff --git a/python/cm/tests/files/response_templates/action_configs/job_proto_relative_cluster.json.j2 b/python/cm/tests/files/response_templates/action_configs/job_proto_relative_cluster.json.j2 index ddc2bb4abd..bba54575a9 100644 --- a/python/cm/tests/files/response_templates/action_configs/job_proto_relative_cluster.json.j2 +++ b/python/cm/tests/files/response_templates/action_configs/job_proto_relative_cluster.json.j2 @@ -16,9 +16,6 @@ "client_id": null, "secret": null }, - "ansible_settings": { - "forks": 5 - }, "logrotate": null, "audit_data_retention": { "log_rotation_on_fs": 365, diff --git a/python/cm/tests/files/response_templates/action_configs/job_proto_relative_service.json.j2 b/python/cm/tests/files/response_templates/action_configs/job_proto_relative_service.json.j2 index 7995b9c712..14d97603d0 100644 --- a/python/cm/tests/files/response_templates/action_configs/job_proto_relative_service.json.j2 +++ b/python/cm/tests/files/response_templates/action_configs/job_proto_relative_service.json.j2 @@ -16,9 +16,6 @@ "client_id": null, "secret": null }, - "ansible_settings": { - "forks": 5 - }, "logrotate": null, "audit_data_retention": { "log_rotation_on_fs": 365, diff --git a/python/cm/tests/files/response_templates/action_configs/service.json.j2 b/python/cm/tests/files/response_templates/action_configs/service.json.j2 index 5c91470c28..44c2590dc7 100644 --- a/python/cm/tests/files/response_templates/action_configs/service.json.j2 +++ b/python/cm/tests/files/response_templates/action_configs/service.json.j2 @@ -16,9 +16,6 @@ "client_id": null, "secret": null }, - "ansible_settings": { - "forks": 5 - }, "logrotate": null, "audit_data_retention": { "log_rotation_on_fs": 365, diff --git a/python/cm/tests/files/response_templates/action_configs/service_on_host.json.j2 b/python/cm/tests/files/response_templates/action_configs/service_on_host.json.j2 index fbabb0b117..f3f6d05aaf 100644 --- a/python/cm/tests/files/response_templates/action_configs/service_on_host.json.j2 +++ b/python/cm/tests/files/response_templates/action_configs/service_on_host.json.j2 @@ -16,9 +16,6 @@ "client_id": null, "secret": null }, - "ansible_settings": { - "forks": 5 - }, "logrotate": null, "audit_data_retention": { "log_rotation_on_fs": 365, diff --git a/python/cm/tests/files/response_templates/action_configs/task_mixed_bundle_relative_cluster.json.j2 b/python/cm/tests/files/response_templates/action_configs/task_mixed_bundle_relative_cluster.json.j2 index be5df2e212..8b5f1a723a 100644 --- a/python/cm/tests/files/response_templates/action_configs/task_mixed_bundle_relative_cluster.json.j2 +++ b/python/cm/tests/files/response_templates/action_configs/task_mixed_bundle_relative_cluster.json.j2 @@ -16,9 +16,6 @@ "client_id": null, "secret": null }, - "ansible_settings": { - "forks": 5 - }, "logrotate": null, "audit_data_retention": { "log_rotation_on_fs": 365, diff --git a/python/cm/tests/files/response_templates/action_configs/task_mixed_bundle_relative_service.json.j2 b/python/cm/tests/files/response_templates/action_configs/task_mixed_bundle_relative_service.json.j2 index 432a1ad12e..8e16774ca2 100644 --- a/python/cm/tests/files/response_templates/action_configs/task_mixed_bundle_relative_service.json.j2 +++ b/python/cm/tests/files/response_templates/action_configs/task_mixed_bundle_relative_service.json.j2 @@ -16,9 +16,6 @@ "client_id": null, "secret": null }, - "ansible_settings": { - "forks": 5 - }, "logrotate": null, "audit_data_retention": { "log_rotation_on_fs": 365, diff --git a/python/cm/tests/files/response_templates/action_configs/task_mixed_proto_relative_cluster.json.j2 b/python/cm/tests/files/response_templates/action_configs/task_mixed_proto_relative_cluster.json.j2 index d0a3293035..37e780d607 100644 --- a/python/cm/tests/files/response_templates/action_configs/task_mixed_proto_relative_cluster.json.j2 +++ b/python/cm/tests/files/response_templates/action_configs/task_mixed_proto_relative_cluster.json.j2 @@ -16,9 +16,6 @@ "client_id": null, "secret": null }, - "ansible_settings": { - "forks": 5 - }, "logrotate": null, "audit_data_retention": { "log_rotation_on_fs": 365, diff --git a/python/cm/tests/files/response_templates/action_configs/task_mixed_proto_relative_service.json.j2 b/python/cm/tests/files/response_templates/action_configs/task_mixed_proto_relative_service.json.j2 index 7aab76925c..037e501463 100644 --- a/python/cm/tests/files/response_templates/action_configs/task_mixed_proto_relative_service.json.j2 +++ b/python/cm/tests/files/response_templates/action_configs/task_mixed_proto_relative_service.json.j2 @@ -16,9 +16,6 @@ "client_id": null, "secret": null }, - "ansible_settings": { - "forks": 5 - }, "logrotate": null, "audit_data_retention": { "log_rotation_on_fs": 365, diff --git a/python/cm/tests/test_action.py b/python/cm/tests/test_action.py index cd7de2a70c..8cc202ab51 100644 --- a/python/cm/tests/test_action.py +++ b/python/cm/tests/test_action.py @@ -14,7 +14,7 @@ from pathlib import Path import json -from adcm.tests.base import BaseTestCase +from adcm.tests.base import BaseTestCase, BusinessLogicMixin from core.job.runners import ADCMSettings, AnsibleSettings, ExternalSettings, IntegrationsSettings from django.conf import settings from django.urls import reverse @@ -414,7 +414,7 @@ def test_service_mm_affects_cluster_actions_success(self): ) -class TestActionParams(BaseTestCase): +class TestActionParams(BaseTestCase, BusinessLogicMixin): def setUp(self) -> None: super().setUp() @@ -423,6 +423,8 @@ def setUp(self) -> None: ) self.cluster = self.create_cluster(bundle_pk=bundle.pk, name="test_cluster_with_action_params") + self.service = self.add_services_to_cluster(["same_actioned_service"], cluster=self.cluster).get() + self.component = self.service.servicecomponent_set.get() self.action_full = Action.objects.get(prototype=self.cluster.prototype, name="action_full") self.action_jinja_2_native_false = Action.objects.get( @@ -432,10 +434,10 @@ def setUp(self) -> None: prototype=self.cluster.prototype, name="action_jinja2Native_absent" ) self.action_ansible_tags_absent = Action.objects.get( - prototype=self.cluster.prototype, name="action_ansibleTags_absent" + prototype=self.component.prototype, name="action_ansibleTags_absent" ) self.action_custom_fields_absent = Action.objects.get( - prototype=self.cluster.prototype, name="action_customFields_absent" + prototype=self.service.prototype, name="action_customFields_absent" ) self.configuration = ExternalSettings( @@ -444,9 +446,20 @@ def setUp(self) -> None: integrations=IntegrationsSettings(status_server_token=settings.STATUS_SECRET_KEY), ) - def _generate_and_read_target_files(self, action_pk: int) -> tuple[ConfigParser, dict]: + self.default_expected_ansible_cfg = { + "defaults": ( + ("stdout_callback", "yaml"), + ("deprecation_warnings", "False"), + ("callback_whitelist", "profile_tasks"), + ("forks", "5"), + ), + "ssh_connection": (("retries", "3"), ("pipelining", "True")), + } + + def _generate_and_read_target_files(self, action_pk: int, alternative_path: str = "") -> tuple[ConfigParser, dict]: response = self.client.post( - path=reverse( + path=alternative_path + or reverse( viewname="v2:cluster-action-run", kwargs={ "cluster_pk": self.cluster.pk, @@ -475,14 +488,6 @@ def _generate_and_read_target_files(self, action_pk: int) -> tuple[ConfigParser, return config_parser, json.loads(config_json_file.read_text(encoding="utf-8")) def test_params_full(self): - expexted_ansible_cfg = { - "defaults": ( - ("stdout_callback", "yaml"), - ("callback_whitelist", "profile_tasks"), - ("forks", "5"), - ("jinja2_native", "True"), - ) - } expected_job_params = { "ansible_tags": "ansible_tag1, ansible_tag2", "custom_list": [1, "two", 3.0], @@ -493,19 +498,13 @@ def test_params_full(self): ansible_cfg_content, config_json_content = self._generate_and_read_target_files(action_pk=self.action_full.pk) - self.assertListEqual(ansible_cfg_content.sections(), list(expexted_ansible_cfg.keys())) - self.assertSetEqual(set(ansible_cfg_content.items("defaults")), set(expexted_ansible_cfg["defaults"])) + self.assertListEqual(ansible_cfg_content.sections(), list(self.default_expected_ansible_cfg.keys())) + self.assertSetEqual( + set(ansible_cfg_content.items("defaults")), set(self.default_expected_ansible_cfg["defaults"]) + ) self.assertDictEqual(config_json_content["job"]["params"], expected_job_params) def test_params_jinja_2_native_false(self): - expexted_ansible_cfg = { - "defaults": ( - ("stdout_callback", "yaml"), - ("callback_whitelist", "profile_tasks"), - ("forks", "5"), - ("jinja2_native", "False"), - ) - } expected_job_params = { "ansible_tags": "ansible_tag1, ansible_tag2", "custom_list": [1, "two", 3.0], @@ -518,18 +517,13 @@ def test_params_jinja_2_native_false(self): action_pk=self.action_jinja_2_native_false.pk ) - self.assertListEqual(ansible_cfg_content.sections(), list(expexted_ansible_cfg.keys())) - self.assertSetEqual(set(ansible_cfg_content.items("defaults")), set(expexted_ansible_cfg["defaults"])) + self.assertListEqual(ansible_cfg_content.sections(), list(self.default_expected_ansible_cfg.keys())) + self.assertSetEqual( + set(ansible_cfg_content.items("defaults")), set(self.default_expected_ansible_cfg["defaults"]) + ) self.assertDictEqual(config_json_content["job"]["params"], expected_job_params) def test_params_jinja_2_native_absent(self): - expexted_ansible_cfg = { - "defaults": ( - ("stdout_callback", "yaml"), - ("callback_whitelist", "profile_tasks"), - ("forks", "5"), - ) - } expected_job_params = { "ansible_tags": "ansible_tag1, ansible_tag2", "custom_list": [1, "two", 3.0], @@ -541,19 +535,13 @@ def test_params_jinja_2_native_absent(self): action_pk=self.action_jinja_2_native_absent.pk ) - self.assertListEqual(ansible_cfg_content.sections(), list(expexted_ansible_cfg.keys())) - self.assertSetEqual(set(ansible_cfg_content.items("defaults")), set(expexted_ansible_cfg["defaults"])) + self.assertListEqual(ansible_cfg_content.sections(), list(self.default_expected_ansible_cfg.keys())) + self.assertSetEqual( + set(ansible_cfg_content.items("defaults")), set(self.default_expected_ansible_cfg["defaults"]) + ) self.assertDictEqual(config_json_content["job"]["params"], expected_job_params) def test_params_ansible_tags_absent(self): - expexted_ansible_cfg = { - "defaults": ( - ("stdout_callback", "yaml"), - ("callback_whitelist", "profile_tasks"), - ("forks", "5"), - ("jinja2_native", "True"), - ) - } expected_job_params = { "custom_list": [1, "two", 3.0], "custom_map": {"1": "two", "five": 6, "three": 4.0}, @@ -562,28 +550,41 @@ def test_params_ansible_tags_absent(self): } ansible_cfg_content, config_json_content = self._generate_and_read_target_files( - action_pk=self.action_ansible_tags_absent.pk + action_pk=self.action_ansible_tags_absent.pk, + alternative_path=reverse( + viewname="v2:component-action-run", + kwargs={ + "cluster_pk": self.cluster.pk, + "service_pk": self.service.pk, + "component_pk": self.component.pk, + "pk": self.action_ansible_tags_absent.pk, + }, + ), ) - self.assertListEqual(ansible_cfg_content.sections(), list(expexted_ansible_cfg.keys())) - self.assertSetEqual(set(ansible_cfg_content.items("defaults")), set(expexted_ansible_cfg["defaults"])) + self.assertListEqual(ansible_cfg_content.sections(), list(self.default_expected_ansible_cfg.keys())) + self.assertSetEqual( + set(ansible_cfg_content.items("defaults")), set(self.default_expected_ansible_cfg["defaults"]) + ) self.assertDictEqual(config_json_content["job"]["params"], expected_job_params) def test_params_custom_fields_absent(self): - expexted_ansible_cfg = { - "defaults": ( - ("stdout_callback", "yaml"), - ("callback_whitelist", "profile_tasks"), - ("forks", "5"), - ("jinja2_native", "True"), - ) - } expected_job_params = {"ansible_tags": "ansible_tag1, ansible_tag2", "jinja2_native": True} ansible_cfg_content, config_json_content = self._generate_and_read_target_files( - action_pk=self.action_custom_fields_absent.pk + action_pk=self.action_custom_fields_absent.pk, + alternative_path=reverse( + viewname="v2:service-action-run", + kwargs={ + "cluster_pk": self.cluster.pk, + "service_pk": self.service.pk, + "pk": self.action_custom_fields_absent.pk, + }, + ), ) - self.assertListEqual(ansible_cfg_content.sections(), list(expexted_ansible_cfg.keys())) - self.assertSetEqual(set(ansible_cfg_content.items("defaults")), set(expexted_ansible_cfg["defaults"])) + self.assertListEqual(ansible_cfg_content.sections(), list(self.default_expected_ansible_cfg.keys())) + self.assertSetEqual( + set(ansible_cfg_content.items("defaults")), set(self.default_expected_ansible_cfg["defaults"]) + ) self.assertDictEqual(config_json_content["job"]["params"], expected_job_params) diff --git a/python/core/job/runners.py b/python/core/job/runners.py index 4c16619ff3..62d754c1b7 100644 --- a/python/core/job/runners.py +++ b/python/core/job/runners.py @@ -47,7 +47,12 @@ def __call__(self, job: Job) -> None: class JobEnvironmentBuilder(Protocol): - def __call__(self, task: Task, job: Job, configuration: ExternalSettings) -> None: + def __call__( + self, + task: Task, + job: Job, + configuration: ExternalSettings, + ) -> None: ... From dfa3b21c0b2a9c90b4f34b76f6157451f0858521 Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Tue, 4 Jun 2024 11:29:51 +0000 Subject: [PATCH 154/208] ADCM-5636: add `allowed args for target type` validation for plugins with `typed` arguments --- python/ansible_plugin/base.py | 92 +++++++++++++------ .../ansible_plugin/executors/_validators.py | 17 +--- .../ansible_plugin/executors/change_flag.py | 5 +- python/ansible_plugin/executors/config.py | 7 +- .../executors/multi_state_set.py | 27 ++++-- .../executors/multi_state_unset.py | 23 +++-- python/ansible_plugin/executors/state.py | 27 ++++-- .../ansible_plugin/tests/test_adcm_config.py | 14 +++ .../tests/test_adcm_state_plugins.py | 43 ++++++++- .../tests/test_targets_extraction.py | 11 ++- 10 files changed, 182 insertions(+), 84 deletions(-) diff --git a/python/ansible_plugin/base.py b/python/ansible_plugin/base.py index 0c519f9d49..171b47fdcd 100644 --- a/python/ansible_plugin/base.py +++ b/python/ansible_plugin/base.py @@ -12,9 +12,14 @@ from abc import abstractmethod from dataclasses import dataclass -from typing import Any, Collection, Generic, Literal, Mapping, Protocol, TypeAlias, TypedDict, TypeVar +from typing import Any, Collection, Generic, Literal, Mapping, Protocol, TypeAlias, TypeVar import fcntl +try: # TODO: refactor when python >= 3.11 + from typing import Self +except ImportError: + from typing_extensions import Self + from ansible.errors import AnsibleActionFail from ansible.module_utils._text import to_native from ansible.plugins.action import ActionBase @@ -23,7 +28,7 @@ from core.types import ADCMCoreType, CoreObjectDescriptor, ObjectID from django.conf import settings from django.db.models import ObjectDoesNotExist -from pydantic import BaseModel, ValidationError, field_validator +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator from ansible_plugin.errors import ( ADCMPluginError, @@ -90,13 +95,6 @@ class RuntimeEnvironment(BaseModel): # Target -class TargetDetector(Protocol): - def __call__( - self, context_owner: CoreObjectDescriptor, context: VarsContextSection, raw_arguments: dict - ) -> tuple[CoreObjectDescriptor, ...]: - ... - - class CoreObjectTargetDescription(BaseModel): type: TargetTypeLiteral @@ -110,38 +108,77 @@ def convert_type_to_string(cls, v: Any) -> str: # requited to pre-process Ansible Strings return str(v) + @model_validator(mode="after") + def validate_args_allowed_for_type(self) -> Self: + match self.type: + case "cluster": + forbidden = ("service_name", "component_name", "host_id") + case "service": + forbidden = ("component_name", "host_id") + case "component": + forbidden = ("host_id",) + case "provider": + forbidden = ("service_name", "component_name", "host_id") + case "host": + forbidden = ("service_name", "component_name") + case _: + raise PluginTargetDetectionError(f"Unsupported type: {self.type}") + + if error_fields := [field for field in forbidden if getattr(self, field) is not None]: + raise ValidationError(f"{', '.join(error_fields)} option(s) is not allowed for {self.type} type") + + return self + + +class BaseTypedArguments(CoreObjectTargetDescription): + model_config = ConfigDict(extra="forbid") + + +class BaseArgumentsWithTypedObjects(BaseModel): + objects: list[CoreObjectTargetDescription] = Field(default_factory=list) + + model_config = ConfigDict(extra="forbid") + + +class TargetDetector(Protocol): + def __call__( + self, + context_owner: CoreObjectDescriptor, + context: VarsContextSection, + parsed_arguments: Any, + ) -> tuple[CoreObjectDescriptor, ...]: + ... + def from_objects( context_owner: CoreObjectDescriptor, # noqa: ARG001 context: VarsContextSection, - raw_arguments: dict, + parsed_arguments: Any, ) -> tuple[CoreObjectDescriptor, ...]: - if not isinstance(objects := raw_arguments.get("objects"), list): + if not isinstance(parsed_arguments, BaseArgumentsWithTypedObjects): return () return tuple( _from_target_description(target_description=target_description, context=context) - for target_description in (CoreObjectTargetDescription(**entry) for entry in objects) + for target_description in parsed_arguments.objects ) def from_arguments_root( context_owner: CoreObjectDescriptor, # noqa: ARG001 context: VarsContextSection, - raw_arguments: dict, + parsed_arguments: Any, ) -> tuple[CoreObjectDescriptor, ...]: - try: - target = CoreObjectTargetDescription(**raw_arguments) - except ValidationError: + if not isinstance(parsed_arguments, BaseTypedArguments): return () - return (_from_target_description(target_description=target, context=context),) + return (_from_target_description(target_description=parsed_arguments, context=context),) def from_context( context_owner: CoreObjectDescriptor, context: VarsContextSection, # noqa: ARG001 - raw_arguments: dict, # noqa: ARG001 + parsed_arguments: Any, # noqa: ARG001 ) -> tuple[CoreObjectDescriptor, ...]: return (context_owner,) @@ -248,14 +285,6 @@ class NoArguments(BaseModel): """ -class SingleStateArgument(BaseModel): - state: str - - -class SingleStateReturnValue(TypedDict): - state: str - - @dataclass(frozen=True, slots=True) class CallResult(Generic[ReturnValue]): # If value is a mapping of some sort, it'll be unpacked into return dict. @@ -389,7 +418,9 @@ def execute(self) -> CallResult[ReturnValue]: ) self._validate_context(context_owner=owner_from_context, context=call_context) self._validate_targets(context_owner=owner_from_context, context=call_context) - targets = self._detect_targets(context_owner=owner_from_context, context=call_context) + targets = self._detect_targets( + context_owner=owner_from_context, context=call_context, parsed_arguments=call_arguments + ) result = self( targets=targets, arguments=call_arguments, @@ -446,7 +477,10 @@ def _validate_targets(self, context_owner: CoreObjectDescriptor, context: VarsCo raise error def _detect_targets( - self, context_owner: CoreObjectDescriptor, context: VarsContextSection + self, + context_owner: CoreObjectDescriptor, + context: VarsContextSection, + parsed_arguments: BaseTypedArguments | BaseArgumentsWithTypedObjects, ) -> tuple[CoreObjectDescriptor, ...]: if not self._config.target.detectors: # Note that only in this case it's ok to return empty targets. @@ -454,7 +488,7 @@ def _detect_targets( return () for detector in self._config.target.detectors: - result = detector(context_owner=context_owner, context=context, raw_arguments=self._raw_arguments) + result = detector(context_owner=context_owner, context=context, parsed_arguments=parsed_arguments) if result: return result diff --git a/python/ansible_plugin/executors/_validators.py b/python/ansible_plugin/executors/_validators.py index 340a73e0a1..3da63a8940 100644 --- a/python/ansible_plugin/executors/_validators.py +++ b/python/ansible_plugin/executors/_validators.py @@ -14,8 +14,8 @@ from core.types import ADCMCoreType, CoreObjectDescriptor -from ansible_plugin.base import VarsContextSection, retrieve_orm_object -from ansible_plugin.errors import PluginTargetError, PluginValidationError +from ansible_plugin.base import retrieve_orm_object +from ansible_plugin.errors import PluginTargetError _CLUSTER_TYPES = {ADCMCoreType.CLUSTER, ADCMCoreType.SERVICE, ADCMCoreType.COMPONENT} _HOSTPROVIDER_TYPES = {ADCMCoreType.HOSTPROVIDER, ADCMCoreType.HOST} @@ -59,16 +59,3 @@ def validate_target_allowed_for_context_owner( return PluginTargetError(message="Wrong context. Can't operate on not own host.") return None - - -def validate_type_is_present( - context_owner: CoreObjectDescriptor, - context: VarsContextSection, # noqa: ARG001 - raw_arguments: dict, -) -> PluginValidationError | None: - _ = context, context_owner - - if "type" not in raw_arguments: - return PluginValidationError(message="`type` is required") - - return None diff --git a/python/ansible_plugin/executors/change_flag.py b/python/ansible_plugin/executors/change_flag.py index a7c3a044ad..fa7e607119 100644 --- a/python/ansible_plugin/executors/change_flag.py +++ b/python/ansible_plugin/executors/change_flag.py @@ -23,11 +23,12 @@ ) from core.types import ADCMCoreType, CoreObjectDescriptor from django.db.transaction import atomic -from pydantic import BaseModel, field_validator +from pydantic import field_validator from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, ArgumentsConfig, + BaseArgumentsWithTypedObjects, CallResult, PluginExecutorConfig, ReturnValue, @@ -45,7 +46,7 @@ class ChangeFlagOperation(str, Enum): DOWN = "down" -class ChangeFlagArguments(BaseModel): +class ChangeFlagArguments(BaseArgumentsWithTypedObjects): operation: ChangeFlagOperation name: str | None = None msg: str = "" diff --git a/python/ansible_plugin/executors/config.py b/python/ansible_plugin/executors/config.py index 875141b3dd..308e1d27e2 100644 --- a/python/ansible_plugin/executors/config.py +++ b/python/ansible_plugin/executors/config.py @@ -29,6 +29,7 @@ from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, ArgumentsConfig, + BaseTypedArguments, CallResult, PluginExecutorConfig, RuntimeEnvironment, @@ -36,7 +37,7 @@ from_arguments_root, ) from ansible_plugin.errors import PluginIncorrectCallError, PluginTargetDetectionError -from ansible_plugin.executors._validators import validate_target_allowed_for_context_owner, validate_type_is_present +from ansible_plugin.executors._validators import validate_target_allowed_for_context_owner from ansible_plugin.utils import cast_to_type # don't want to typehint due to serialization problems and serialization priority @@ -67,7 +68,7 @@ def check_either_value_or_active(self) -> Self: return self -class ChangeConfigArguments(ParameterToChange): +class ChangeConfigArguments(ParameterToChange, BaseTypedArguments): # new API to change multiple parameters parameters: list[ParameterToChange] | None = None @@ -99,7 +100,7 @@ class ChangeConfigReturn(TypedDict): class ADCMConfigPluginExecutor(ADCMAnsiblePluginExecutor[ChangeConfigArguments, ChangeConfigReturn]): _config = PluginExecutorConfig( arguments=ArgumentsConfig(represent_as=ChangeConfigArguments), - target=TargetConfig(detectors=(from_arguments_root,), validators=(validate_type_is_present,)), + target=TargetConfig(detectors=(from_arguments_root,)), ) @atomic() diff --git a/python/ansible_plugin/executors/multi_state_set.py b/python/ansible_plugin/executors/multi_state_set.py index 0879c1fee0..4005eb489b 100644 --- a/python/ansible_plugin/executors/multi_state_set.py +++ b/python/ansible_plugin/executors/multi_state_set.py @@ -10,37 +10,44 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Collection +from typing import Collection, TypedDict from core.types import CoreObjectDescriptor from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, ArgumentsConfig, + BaseTypedArguments, CallResult, PluginExecutorConfig, RuntimeEnvironment, - SingleStateArgument, - SingleStateReturnValue, TargetConfig, from_arguments_root, retrieve_orm_object, ) -from ansible_plugin.executors._validators import validate_target_allowed_for_context_owner, validate_type_is_present +from ansible_plugin.executors._validators import validate_target_allowed_for_context_owner -class ADCMMultiStateSetPluginExecutor(ADCMAnsiblePluginExecutor[SingleStateArgument, SingleStateReturnValue]): +class MultiStateSetArguments(BaseTypedArguments): + state: str + + +class MultiStateSetReturnValue(TypedDict): + state: str + + +class ADCMMultiStateSetPluginExecutor(ADCMAnsiblePluginExecutor[MultiStateSetArguments, MultiStateSetReturnValue]): _config = PluginExecutorConfig( - arguments=ArgumentsConfig(represent_as=SingleStateArgument), - target=TargetConfig(detectors=(from_arguments_root,), validators=(validate_type_is_present,)), + arguments=ArgumentsConfig(represent_as=MultiStateSetArguments), + target=TargetConfig(detectors=(from_arguments_root,)), ) def __call__( self, targets: Collection[CoreObjectDescriptor], - arguments: SingleStateArgument, + arguments: MultiStateSetArguments, runtime: RuntimeEnvironment, - ) -> CallResult[SingleStateReturnValue]: + ) -> CallResult[MultiStateSetReturnValue]: target, *_ = targets if error := validate_target_allowed_for_context_owner(context_owner=runtime.context_owner, target=target): @@ -49,4 +56,4 @@ def __call__( target_object = retrieve_orm_object(object_=target) target_object.set_multi_state(arguments.state) - return CallResult(value=SingleStateReturnValue(state=arguments.state), changed=True, error=None) + return CallResult(value=MultiStateSetReturnValue(state=arguments.state), changed=True, error=None) diff --git a/python/ansible_plugin/executors/multi_state_unset.py b/python/ansible_plugin/executors/multi_state_unset.py index 81f1ea776d..4677fedc78 100644 --- a/python/ansible_plugin/executors/multi_state_unset.py +++ b/python/ansible_plugin/executors/multi_state_unset.py @@ -11,36 +11,41 @@ # limitations under the License. from contextlib import suppress -from typing import Collection +from typing import Collection, TypedDict from cm.status_api import send_object_update_event from core.types import CoreObjectDescriptor -from pydantic import BaseModel from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, ArgumentsConfig, + BaseTypedArguments, CallResult, PluginExecutorConfig, RuntimeEnvironment, - SingleStateReturnValue, TargetConfig, from_arguments_root, retrieve_orm_object, ) from ansible_plugin.errors import PluginRuntimeError -from ansible_plugin.executors._validators import validate_target_allowed_for_context_owner, validate_type_is_present +from ansible_plugin.executors._validators import validate_target_allowed_for_context_owner -class MultiStateUnsetArguments(BaseModel): +class MultiStateUnsetArguments(BaseTypedArguments): state: str missing_ok: bool = False -class ADCMMultiStateUnsetPluginExecutor(ADCMAnsiblePluginExecutor[MultiStateUnsetArguments, SingleStateReturnValue]): +class MultiStateUnsetReturnValue(TypedDict): + state: str + + +class ADCMMultiStateUnsetPluginExecutor( + ADCMAnsiblePluginExecutor[MultiStateUnsetArguments, MultiStateUnsetReturnValue] +): _config = PluginExecutorConfig( arguments=ArgumentsConfig(represent_as=MultiStateUnsetArguments), - target=TargetConfig(detectors=(from_arguments_root,), validators=(validate_type_is_present,)), + target=TargetConfig(detectors=(from_arguments_root,)), ) def __call__( @@ -48,7 +53,7 @@ def __call__( targets: Collection[CoreObjectDescriptor], arguments: MultiStateUnsetArguments, runtime: RuntimeEnvironment, - ) -> CallResult[SingleStateReturnValue]: + ) -> CallResult[MultiStateUnsetReturnValue]: target, *_ = targets if error := validate_target_allowed_for_context_owner(context_owner=runtime.context_owner, target=target): @@ -66,4 +71,4 @@ def __call__( with suppress(Exception): send_object_update_event(object_=target_object, changes={"state": arguments.state}) - return CallResult(value=SingleStateReturnValue(state=arguments.state), changed=True, error=None) + return CallResult(value=MultiStateUnsetReturnValue(state=arguments.state), changed=True, error=None) diff --git a/python/ansible_plugin/executors/state.py b/python/ansible_plugin/executors/state.py index 76b53d87c0..347a554ba1 100644 --- a/python/ansible_plugin/executors/state.py +++ b/python/ansible_plugin/executors/state.py @@ -11,7 +11,7 @@ # limitations under the License. from contextlib import suppress -from typing import Collection +from typing import Collection, TypedDict from cm.status_api import send_object_update_event from core.types import CoreObjectDescriptor @@ -19,30 +19,37 @@ from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, ArgumentsConfig, + BaseTypedArguments, CallResult, PluginExecutorConfig, RuntimeEnvironment, - SingleStateArgument, - SingleStateReturnValue, TargetConfig, from_arguments_root, retrieve_orm_object, ) -from ansible_plugin.executors._validators import validate_target_allowed_for_context_owner, validate_type_is_present +from ansible_plugin.executors._validators import validate_target_allowed_for_context_owner -class ADCMStatePluginExecutor(ADCMAnsiblePluginExecutor[SingleStateArgument, SingleStateReturnValue]): +class StateArguments(BaseTypedArguments): + state: str + + +class StateReturnValue(TypedDict): + state: str + + +class ADCMStatePluginExecutor(ADCMAnsiblePluginExecutor[StateArguments, StateReturnValue]): _config = PluginExecutorConfig( - arguments=ArgumentsConfig(represent_as=SingleStateArgument), - target=TargetConfig(detectors=(from_arguments_root,), validators=(validate_type_is_present,)), + arguments=ArgumentsConfig(represent_as=StateArguments), + target=TargetConfig(detectors=(from_arguments_root,)), ) def __call__( self, targets: Collection[CoreObjectDescriptor], - arguments: SingleStateArgument, + arguments: StateArguments, runtime: RuntimeEnvironment, - ) -> CallResult[SingleStateReturnValue]: + ) -> CallResult[StateReturnValue]: target, *_ = targets if error := validate_target_allowed_for_context_owner(context_owner=runtime.context_owner, target=target): @@ -53,4 +60,4 @@ def __call__( with suppress(Exception): send_object_update_event(object_=target_object, changes={"state": arguments.state}) - return CallResult(value=SingleStateReturnValue(state=arguments.state), changed=True, error=None) + return CallResult(value=StateReturnValue(state=arguments.state), changed=True, error=None) diff --git a/python/ansible_plugin/tests/test_adcm_config.py b/python/ansible_plugin/tests/test_adcm_config.py index 2adb8e7b37..a46ded5a95 100644 --- a/python/ansible_plugin/tests/test_adcm_config.py +++ b/python/ansible_plugin/tests/test_adcm_config.py @@ -84,6 +84,20 @@ def test_simple_change_one_value_success(self) -> None: self.assertEqual(after.config["plain_s"], changed_value) self.assertEqual(after.config["g1"]["plain_s"], in_group_value) + def test_simple_change_not_allowed_arg_fail(self) -> None: + result = self.execute_plugin( + task=self.prepare_task(owner=self.cluster, name="dummy"), + call_arguments=""" + some_arg: some_value + type: cluster + key: plain_s + value: awesomenewstring + """, + ) + + self.assertIsNotNone(result.error) + self.assertFalse(result.changed) + def test_multi_change_with_activation_success(self) -> None: values_to_change = { "plain_i": 4, diff --git a/python/ansible_plugin/tests/test_adcm_state_plugins.py b/python/ansible_plugin/tests/test_adcm_state_plugins.py index a7b3fee3fe..2c52531a22 100644 --- a/python/ansible_plugin/tests/test_adcm_state_plugins.py +++ b/python/ansible_plugin/tests/test_adcm_state_plugins.py @@ -76,12 +76,49 @@ def setUp(self) -> None: (self.host_1, self.host_1, {"type": "host", "state": self.target_state}), ) self.forbidden_owner_target_args = ( - (self.host_1, self.host_2, {"type": "host", "host_id": self.host_2.pk, "state": self.target_state}), - ( + ( # owner host, target host, not self + self.host_1, + self.host_2, + {"type": "host", "host_id": self.host_2.pk, "state": self.target_state}, + ), + ( # foreign host self.another_provider, self.host_2, {"type": "host", "host_id": self.host_2.pk, "state": self.target_state}, ), + # forbidden args for target type + (self.cluster, self.cluster, {"type": "cluster", "state": self.target_state, "test": "test"}), + (self.service, self.cluster, {"type": "cluster", "state": self.target_state, "service_name": "some_name"}), + ( + self.component, + self.cluster, + {"type": "cluster", "state": self.target_state, "component_name": "some_name"}, + ), + (self.cluster, self.cluster, {"type": "cluster", "state": self.target_state, "host_id": 8}), + ( + self.service, + self.service, + {"type": "service", "state": self.target_state, "component_name": "some_name"}, + ), + (self.component, self.service, {"type": "service", "state": self.target_state, "host_id": 8}), + (self.component, self.component, {"type": "component", "state": self.target_state, "host_id": 8}), + ( + self.provider, + self.provider, + {"type": "provider", "state": self.target_state, "service_name": "some_name"}, + ), + ( + self.provider, + self.provider, + {"type": "provider", "state": self.target_state, "component_name": "some_name"}, + ), + (self.host_1, self.provider, {"type": "provider", "state": self.target_state, "host_id": 8}), + (self.host_1, self.host_1, {"type": "host", "state": self.target_state, "service_name": "some_name"}), + ( + self.provider, + self.host_1, + {"type": "host", "host_id": self.host_1.pk, "state": self.target_state, "component_name": "some_name"}, + ), ) def _execute_test( @@ -193,7 +230,7 @@ def test_fail_scenarios(self): expect_fail=True, ) - def test_forbidden_owner_targert_pairs(self): + def test_forbidden_owner_targert_args(self): for owner, target, call_args in self.forbidden_owner_target_args: for executor_class, extra_args in ( (ADCMStatePluginExecutor, {}), diff --git a/python/ansible_plugin/tests/test_targets_extraction.py b/python/ansible_plugin/tests/test_targets_extraction.py index f1bf84df36..397a3b6ebc 100644 --- a/python/ansible_plugin/tests/test_targets_extraction.py +++ b/python/ansible_plugin/tests/test_targets_extraction.py @@ -18,12 +18,17 @@ from cm.services.job.run.repo import JobRepoImpl from core.job.types import Task from core.types import ADCMCoreType, CoreObjectDescriptor -from pydantic import BaseModel -from ansible_plugin.base import ArgumentsConfig, PluginExecutorConfig, TargetConfig, from_objects +from ansible_plugin.base import ( + ArgumentsConfig, + BaseArgumentsWithTypedObjects, + PluginExecutorConfig, + TargetConfig, + from_objects, +) -class EmptyArguments(BaseModel): +class EmptyArguments(BaseArgumentsWithTypedObjects): ... From 2832d5c426d01df61af161b20fc00042733dca0f Mon Sep 17 00:00:00 2001 From: Aleksandr Alferov Date: Tue, 4 Jun 2024 17:05:30 +0300 Subject: [PATCH 155/208] ADCM-5654 Remove old collect_statistics command, fix tests --- os/etc/crontabs/root | 2 +- .../management/commands/collect_statistics.py | 424 +++++------------- .../commands/collect_statistics_new.py | 171 ------- python/cm/tests/test_management_commands.py | 352 +++++++-------- 4 files changed, 269 insertions(+), 680 deletions(-) delete mode 100644 python/cm/management/commands/collect_statistics_new.py diff --git a/os/etc/crontabs/root b/os/etc/crontabs/root index 721387bbd9..3ec8162372 100755 --- a/os/etc/crontabs/root +++ b/os/etc/crontabs/root @@ -4,4 +4,4 @@ 0 8 */1 * * python /adcm/python/manage.py logrotate --target all 0 10 */1 * * python /adcm/python/manage.py clearaudit */1 * * * * python /adcm/python/manage.py run_ldap_sync -0 0 * * 1 python /adcm/python/manage.py collect_statistics +0 0 * * 1 python /adcm/python/manage.py collect_statistics --mode send diff --git a/python/cm/management/commands/collect_statistics.py b/python/cm/management/commands/collect_statistics.py index 52d8e51650..9ae904d310 100644 --- a/python/cm/management/commands/collect_statistics.py +++ b/python/cm/management/commands/collect_statistics.py @@ -10,82 +10,38 @@ # See the License for the specific language governing permissions and # limitations under the License. -from dataclasses import asdict, dataclass -from datetime import datetime as dt -from hashlib import md5 from logging import getLogger -from pathlib import Path -from shutil import rmtree -from tarfile import TarFile -from tempfile import mkdtemp -from time import sleep, time from typing import NamedTuple from urllib.parse import urlunparse import os -import json +import socket -from audit.models import AuditLogOperationResult -from audit.utils import make_audit_log -from django.conf import settings as adcm_settings -from django.core.management.base import BaseCommand -from django.db.models import Count, Prefetch, QuerySet -from rbac.models import Policy, Role, User -from rest_framework.status import HTTP_201_CREATED, HTTP_405_METHOD_NOT_ALLOWED -import requests +from audit.utils import audit_background_task +from django.conf import settings +from django.core.management import BaseCommand +from django.db.models import Q +from django.utils import timezone from cm.adcm_config.config import get_adcm_config -from cm.models import ADCM, Bundle, Cluster, HostComponent, HostProvider - - -@dataclass -class ADCMData: - uuid: str - version: str - - -@dataclass -class BundleData: - name: str - version: str - edition: str - date: str - - -@dataclass -class HostComponentData: - host_name: str - component_name: str - service_name: str - - -@dataclass -class ClusterData: - name: str - host_count: int - bundle: dict - host_component_map: list[dict] - - -@dataclass -class HostProviderData: - name: str - host_count: int - bundle: dict - - -@dataclass -class UserData: - email: str - date_joined: str +from cm.collect_statistics.collectors import ADCMEntities, BundleCollector, RBACCollector +from cm.collect_statistics.encoders import TarFileEncoder +from cm.collect_statistics.senders import SenderSettings, StatisticSender +from cm.collect_statistics.storages import JSONFile, TarFileWithJSONFileStorage +from cm.models import ADCM + +SENDER_REQUEST_TIMEOUT = 15.0 +DATE_TIME_FORMAT = "%Y-%m-%d %H:%M:%S" +DATE_FORMAT = "%Y-%m-%d" +STATISTIC_DIR = settings.TMP_DIR / "statistics" +STATISTIC_DIR.mkdir(exist_ok=True) +logger = getLogger("background_tasks") -@dataclass -class RoleData: - name: str - built_in: bool +collect_not_enterprise = BundleCollector(date_format=DATE_TIME_FORMAT, filters=[~Q(edition="enterprise")]) +collect_all = BundleCollector(date_format=DATE_TIME_FORMAT, filters=[]) -class UrlComponents(NamedTuple): +class URLComponents(NamedTuple): scheme: str netloc: str path: str @@ -94,280 +50,122 @@ class UrlComponents(NamedTuple): fragment: str = "" -class RetryError(Exception): - pass - +def is_internal() -> bool: + try: + with socket.create_connection(("adsw.io", 80), timeout=1): + return True + except TimeoutError: + return False -logger = getLogger("background_tasks") +def get_statistics_url() -> str: + scheme = "http" + url_path = "/api/v1/statistic/adcm" -class StatisticsSettings: - def __init__(self): - adcm_uuid = str(ADCM.objects.get().uuid) + if (netloc := os.getenv("STATISTICS_URL")) is None: + _, config = get_adcm_config(section="statistics_collection") + netloc = config["url"] - self.enabled = self._get_enabled() + if len(splitted := netloc.split("://")) == 2: + scheme = splitted[0] + netloc = splitted[1] - self.url = self._get_url() - self.headers = {"Adcm-UUID": adcm_uuid, "accept": "application/json"} - self.timeout = 15 + return urlunparse(components=URLComponents(scheme=scheme, netloc=netloc, path=url_path)) - self.retries_limit = int(os.getenv("STATISTICS_RETRIES", 10)) - self.retries_frequency = int(os.getenv("STATISTICS_FREQUENCY", 1 * 60 * 60)) # in seconds - self.format_version = 0.1 - self.adcm_uuid = adcm_uuid +def get_enabled() -> bool: + if os.getenv("STATISTICS_ENABLED") is not None: + return os.environ["STATISTICS_ENABLED"].upper() in {"1", "TRUE"} - self.date_format = "%Y-%m-%d %H:%M:%S" - self.data_name = f"{dt.now().date().strftime('%Y_%m_%d')}_statistics" # noqa: DTZ005 - - @staticmethod - def _get_enabled() -> bool: - if os.getenv("STATISTICS_ENABLED") is not None: - return os.environ["STATISTICS_ENABLED"].upper() in {"1", "TRUE"} - - attr, _ = get_adcm_config(section="statistics_collection") - return bool(attr["active"]) - - @staticmethod - def _get_url() -> str: - url_path = "/api/v1/statistic/adcm" - scheme = "http" - - if os.getenv("STATISTICS_URL") is not None: - netloc = os.environ["STATISTICS_URL"] - else: - _, config = get_adcm_config(section="statistics_collection") - netloc = config["url"] - - if len(splitted := netloc.split("://")) == 2: - scheme = splitted[0] - netloc = splitted[1] - - return urlunparse(components=UrlComponents(scheme=scheme, netloc=netloc, path=url_path)) + attr, _ = get_adcm_config(section="statistics_collection") + return bool(attr["active"]) class Command(BaseCommand): + help = "Collect data and send to Statistic Server" + def __init__(self, *args, **kwargs): - self.settings = StatisticsSettings() super().__init__(*args, **kwargs) - def handle(self, *args, **options): # noqa: ARG002 - try: - self.tmp_dir = Path(mkdtemp()).absolute() - self.main() - except Exception: # noqa: BLE001ion-caught - self.log(msg="Unexpected error during statistics collection", method="exception") - finally: - rmtree(path=self.tmp_dir) - - def main(self): - if not self.settings.enabled: - self.log(msg="disabled") - return - - self.log(msg="started") - make_audit_log(operation_type="statistics", result=AuditLogOperationResult.SUCCESS, operation_status="launched") - - for try_number in range(self.settings.retries_limit): - self.last_try_timestamp = time() - - try: - self.check_connection() - archive_path = (self.tmp_dir / self.settings.data_name).with_suffix(".tar.gz") - if not archive_path.is_file(): - data = self.collect_statistics() - self.make_archive(target_path=archive_path, data=data) - self.send_data(file_path=archive_path) - make_audit_log( - operation_type="statistics", result=AuditLogOperationResult.SUCCESS, operation_status="completed" - ) - break - - except RetryError: - # skip last iteration sleep() call - if try_number < self.settings.retries_limit - 1: - self.sleep() - else: - make_audit_log( - operation_type="statistics", result=AuditLogOperationResult.FAIL, operation_status="completed" - ) - - self.log(msg="finished") - - def make_archive(self, target_path: Path, data: dict) -> None: - json_path = (self.tmp_dir / self.settings.data_name).with_suffix(".json") - with json_path.open(mode="w", encoding=adcm_settings.ENCODING_UTF_8) as json_file: - json.dump(obj=data, fp=json_file) - - with TarFile.open(name=target_path, mode="w:gz", encoding=adcm_settings.ENCODING_UTF_8, compresslevel=9) as tar: - tar.add(name=json_path, arcname=json_path.name) - - json_path.unlink() - self.log(msg=f"archive created {target_path}") - - def collect_statistics(self) -> dict: - self.log(msg="getting data...") - community_bundles_qs = Bundle.objects.filter(edition="community") + def add_arguments(self, parser): + parser.add_argument( + "--mode", + choices=["send", "archive-all"], + help=( + "'send' - collect archive with only community bundles and send to Statistic Server, " + "'archive-all' - collect community and enterprise bundles to archive and return path to file" + ), + default="archive-all", + ) - return { - "adcm": asdict(ADCMData(uuid=self.settings.adcm_uuid, version=adcm_settings.ADCM_VERSION)), - "format_version": self.settings.format_version, - "data": { - "clusters": self._get_clusters_data(bundles=community_bundles_qs), - "bundles": self._get_bundles_data(bundles=community_bundles_qs), - "providers": self._get_hostproviders_data(bundles=community_bundles_qs), - "users": self._get_users_data(), - "roles": self._get_roles_data(), + @audit_background_task(start_operation_status="launched", end_operation_status="completed") + def handle(self, *_, mode: str, **__): + logger.debug(msg="Statistics collector: started") + statistics_data = { + "adcm": { + "uuid": str(ADCM.objects.values_list("uuid", flat=True).get()), + "version": settings.ADCM_VERSION, + "is_internal": is_internal(), }, + "format_version": "0.2", } + logger.debug(msg="Statistics collector: RBAC data preparation") + rbac_entries_data: dict = RBACCollector(date_format=DATE_TIME_FORMAT)().model_dump() + storage = TarFileWithJSONFileStorage(date_format=DATE_FORMAT) - def check_connection(self) -> None: - """expecting 405 response on HEAD request without headers""" - - try: - response = requests.head(url=self.settings.url, headers={}, timeout=self.settings.timeout) - except requests.exceptions.ConnectionError as e: - self.log(msg=f"error connecting to `{self.settings.url}`", method="exception") - raise RetryError from e - - if response.status_code != HTTP_405_METHOD_NOT_ALLOWED: - self.log(msg=f"Bad response: {response.status_code}`, HEAD {self.settings.url}`") - raise RetryError - - self.log(msg="connection established") - - def send_data(self, file_path): - self.log(msg="sending data...") - with file_path.open(mode="rb") as archive: - try: - response = requests.post( - url=self.settings.url, - headers=self.settings.headers, - files={"file": archive}, - timeout=self.settings.timeout, - ) - except requests.exceptions.ConnectionError as e: - self.log(msg=f"error connecting to `{self.settings.url}`", method="exception") - raise RetryError from e - - if response.status_code != HTTP_201_CREATED: - raise RetryError - - self.log(msg="data succesfully sent") - - def sleep(self): - sleep_seconds = self.last_try_timestamp + self.settings.retries_frequency - time() - sleep_seconds = max(sleep_seconds, 0) - - self.log(f"sleeping for {sleep_seconds} seconds") - sleep(sleep_seconds) + match mode: + case "send": + logger.debug(msg="Statistics collector: 'send' mode is used") - def log(self, msg: str, method: str = "debug") -> None: - msg = f"Statistics collector: {msg}" - self.stdout.write(msg) - getattr(logger, method)(msg) + if not get_enabled(): + logger.debug(msg="Statistics collector: disabled") + return - @staticmethod - def _get_roles_data() -> list[dict]: - out_data = [] - - for role_data in Role.objects.filter( - pk__in=Policy.objects.filter(role__isnull=False).values_list("role_id", flat=True).distinct() - ).values("name", "built_in"): - out_data.append(asdict(RoleData(**role_data))) - - return out_data - - def _get_users_data(self) -> list[dict]: - out_data = [] - for user_data in User.objects.values("email", "date_joined"): - out_data.append( - asdict( - UserData( - email=user_data["email"], - date_joined=user_data["date_joined"].strftime(self.settings.date_format), - ) - ) - ) - - return out_data - - def _get_hostproviders_data(self, bundles: QuerySet[Bundle]) -> list[dict]: - out_data = [] - for hostprovider in ( - HostProvider.objects.filter(prototype__bundle__in=bundles) - .select_related("prototype__bundle") - .annotate(host_count=Count("host")) - ): - out_data.append( - asdict( - HostProviderData( - name=hostprovider.name, - host_count=hostprovider.host_count, - bundle=self._get_single_bundle_data(bundle=hostprovider.prototype.bundle), - ) + logger.debug( + msg="Statistics collector: bundles data preparation, collect everything except 'enterprise' edition" ) - ) - - return out_data - - @staticmethod - def _get_hostcomponent_data(cluster: Cluster) -> list[dict]: - out_data = [] - for hostcomponent in cluster.hostcomponent_set.all(): - out_data.append( - asdict( - HostComponentData( - host_name=md5( # noqa: S324 - hostcomponent.host.name.encode(encoding=adcm_settings.ENCODING_UTF_8) - ).hexdigest(), - component_name=hostcomponent.component.name, - service_name=hostcomponent.service.name, + bundle_data: ADCMEntities = collect_not_enterprise() + storage.add( + JSONFile( + filename=f"{timezone.now().strftime(DATE_FORMAT)}_statistics.json", + data={**statistics_data, **rbac_entries_data, **bundle_data.model_dump()}, ) ) - ) - - return out_data - - def _get_clusters_data(self, bundles: QuerySet[Bundle]) -> list[dict]: - out_data = [] - for cluster in ( - Cluster.objects.filter(prototype__bundle__in=bundles) - .select_related("prototype__bundle") - .prefetch_related( - Prefetch( - lookup="hostcomponent_set", - queryset=HostComponent.objects.select_related("host", "service", "component"), + logger.debug(msg="Statistics collector: archive preparation") + archive = storage.gather() + sender_settings = SenderSettings( + url=get_statistics_url(), + adcm_uuid=statistics_data["adcm"]["uuid"], + retries_limit=int(os.getenv("STATISTICS_RETRIES", 10)), + retries_frequency=int(os.getenv("STATISTICS_FREQUENCY", 1 * 60 * 60)), # in seconds + request_timeout=SENDER_REQUEST_TIMEOUT, ) - ) - .annotate(host_count=Count("host")) - ): - out_data.append( - asdict( - ClusterData( - name=cluster.name, - host_count=cluster.host_count, - bundle=self._get_single_bundle_data(bundle=cluster.prototype.bundle), - host_component_map=self._get_hostcomponent_data(cluster=cluster), + logger.debug(msg="Statistics collector: sender preparation") + sender = StatisticSender(settings=sender_settings) + logger.debug(msg="Statistics collector: statistics sending has started") + sender.send([archive]) + logger.debug(msg="Statistics collector: sending statistics completed") + + case "archive-all": + logger.debug(msg="Statistics collector: 'archive-all' mode is used") + logger.debug(msg="Statistics collector: bundles data preparation, collect everything") + bundle_data: ADCMEntities = collect_all() + storage.add( + JSONFile( + filename=f"{timezone.now().strftime(DATE_FORMAT)}_statistics.json", + data={**statistics_data, **rbac_entries_data, **bundle_data.model_dump()}, ) ) - ) - - return out_data + logger.debug(msg="Statistics collector: archive preparation") + archive = storage.gather() - def _get_single_bundle_data(self, bundle: Bundle) -> dict: - return asdict( - BundleData( - name=bundle.name, - version=bundle.version, - edition=bundle.edition, - date=bundle.date.strftime(self.settings.date_format), - ) - ) + logger.debug(msg="Statistics collector: archive encoding") + encoder = TarFileEncoder(suffix=".enc") + encoded_file = encoder.encode(path_file=archive) + encoded_file = encoded_file.replace(STATISTIC_DIR / encoded_file.name) - def _get_bundles_data(self, bundles: QuerySet[Bundle]) -> list[dict]: - out_data = [] - for bundle in bundles: - out_data.append(self._get_single_bundle_data(bundle=bundle)) + self.stdout.write(f"Data saved in: {str(encoded_file.absolute())}") + case _: + pass - return out_data + logger.debug(msg="Statistics collector: finished") diff --git a/python/cm/management/commands/collect_statistics_new.py b/python/cm/management/commands/collect_statistics_new.py deleted file mode 100644 index 9ae904d310..0000000000 --- a/python/cm/management/commands/collect_statistics_new.py +++ /dev/null @@ -1,171 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from logging import getLogger -from typing import NamedTuple -from urllib.parse import urlunparse -import os -import socket - -from audit.utils import audit_background_task -from django.conf import settings -from django.core.management import BaseCommand -from django.db.models import Q -from django.utils import timezone - -from cm.adcm_config.config import get_adcm_config -from cm.collect_statistics.collectors import ADCMEntities, BundleCollector, RBACCollector -from cm.collect_statistics.encoders import TarFileEncoder -from cm.collect_statistics.senders import SenderSettings, StatisticSender -from cm.collect_statistics.storages import JSONFile, TarFileWithJSONFileStorage -from cm.models import ADCM - -SENDER_REQUEST_TIMEOUT = 15.0 -DATE_TIME_FORMAT = "%Y-%m-%d %H:%M:%S" -DATE_FORMAT = "%Y-%m-%d" -STATISTIC_DIR = settings.TMP_DIR / "statistics" -STATISTIC_DIR.mkdir(exist_ok=True) - -logger = getLogger("background_tasks") - -collect_not_enterprise = BundleCollector(date_format=DATE_TIME_FORMAT, filters=[~Q(edition="enterprise")]) -collect_all = BundleCollector(date_format=DATE_TIME_FORMAT, filters=[]) - - -class URLComponents(NamedTuple): - scheme: str - netloc: str - path: str - params: str = "" - query: str = "" - fragment: str = "" - - -def is_internal() -> bool: - try: - with socket.create_connection(("adsw.io", 80), timeout=1): - return True - except TimeoutError: - return False - - -def get_statistics_url() -> str: - scheme = "http" - url_path = "/api/v1/statistic/adcm" - - if (netloc := os.getenv("STATISTICS_URL")) is None: - _, config = get_adcm_config(section="statistics_collection") - netloc = config["url"] - - if len(splitted := netloc.split("://")) == 2: - scheme = splitted[0] - netloc = splitted[1] - - return urlunparse(components=URLComponents(scheme=scheme, netloc=netloc, path=url_path)) - - -def get_enabled() -> bool: - if os.getenv("STATISTICS_ENABLED") is not None: - return os.environ["STATISTICS_ENABLED"].upper() in {"1", "TRUE"} - - attr, _ = get_adcm_config(section="statistics_collection") - return bool(attr["active"]) - - -class Command(BaseCommand): - help = "Collect data and send to Statistic Server" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def add_arguments(self, parser): - parser.add_argument( - "--mode", - choices=["send", "archive-all"], - help=( - "'send' - collect archive with only community bundles and send to Statistic Server, " - "'archive-all' - collect community and enterprise bundles to archive and return path to file" - ), - default="archive-all", - ) - - @audit_background_task(start_operation_status="launched", end_operation_status="completed") - def handle(self, *_, mode: str, **__): - logger.debug(msg="Statistics collector: started") - statistics_data = { - "adcm": { - "uuid": str(ADCM.objects.values_list("uuid", flat=True).get()), - "version": settings.ADCM_VERSION, - "is_internal": is_internal(), - }, - "format_version": "0.2", - } - logger.debug(msg="Statistics collector: RBAC data preparation") - rbac_entries_data: dict = RBACCollector(date_format=DATE_TIME_FORMAT)().model_dump() - storage = TarFileWithJSONFileStorage(date_format=DATE_FORMAT) - - match mode: - case "send": - logger.debug(msg="Statistics collector: 'send' mode is used") - - if not get_enabled(): - logger.debug(msg="Statistics collector: disabled") - return - - logger.debug( - msg="Statistics collector: bundles data preparation, collect everything except 'enterprise' edition" - ) - bundle_data: ADCMEntities = collect_not_enterprise() - storage.add( - JSONFile( - filename=f"{timezone.now().strftime(DATE_FORMAT)}_statistics.json", - data={**statistics_data, **rbac_entries_data, **bundle_data.model_dump()}, - ) - ) - logger.debug(msg="Statistics collector: archive preparation") - archive = storage.gather() - sender_settings = SenderSettings( - url=get_statistics_url(), - adcm_uuid=statistics_data["adcm"]["uuid"], - retries_limit=int(os.getenv("STATISTICS_RETRIES", 10)), - retries_frequency=int(os.getenv("STATISTICS_FREQUENCY", 1 * 60 * 60)), # in seconds - request_timeout=SENDER_REQUEST_TIMEOUT, - ) - logger.debug(msg="Statistics collector: sender preparation") - sender = StatisticSender(settings=sender_settings) - logger.debug(msg="Statistics collector: statistics sending has started") - sender.send([archive]) - logger.debug(msg="Statistics collector: sending statistics completed") - - case "archive-all": - logger.debug(msg="Statistics collector: 'archive-all' mode is used") - logger.debug(msg="Statistics collector: bundles data preparation, collect everything") - bundle_data: ADCMEntities = collect_all() - storage.add( - JSONFile( - filename=f"{timezone.now().strftime(DATE_FORMAT)}_statistics.json", - data={**statistics_data, **rbac_entries_data, **bundle_data.model_dump()}, - ) - ) - logger.debug(msg="Statistics collector: archive preparation") - archive = storage.gather() - - logger.debug(msg="Statistics collector: archive encoding") - encoder = TarFileEncoder(suffix=".enc") - encoded_file = encoder.encode(path_file=archive) - encoded_file = encoded_file.replace(STATISTIC_DIR / encoded_file.name) - - self.stdout.write(f"Data saved in: {str(encoded_file.absolute())}") - case _: - pass - - logger.debug(msg="Statistics collector: finished") diff --git a/python/cm/tests/test_management_commands.py b/python/cm/tests/test_management_commands.py index 200b463b7b..99d4af9303 100644 --- a/python/cm/tests/test_management_commands.py +++ b/python/cm/tests/test_management_commands.py @@ -23,7 +23,6 @@ from adcm.tests.base import BaseTestCase, BusinessLogicMixin from api_v2.tests.base import BaseAPITestCase, ParallelReadyTestCase from django.conf import settings -from django.core.management import load_command_class from django.db.models import Q from django.test import TestCase from django.utils import timezone @@ -40,184 +39,6 @@ from cm.tests.utils import gen_cluster, gen_provider -class TestStatistics(BaseAPITestCase): - def setUp(self) -> None: - super().setUp() - self.maxDiff = None - - enterprise_bundle_cluster = Bundle.objects.create( - name="enterprise_cluster", version="1.0", edition="enterprise" - ) - enterprise_bundle_provider = Bundle.objects.create( - name="enterprise_provider", version="1.2", edition="enterprise" - ) - - gen_cluster(name="enterprise_cluster", bundle=enterprise_bundle_cluster) - gen_provider(name="enterprise_provider", bundle=enterprise_bundle_provider) - - adcm_user_role = Role.objects.get(name="ADCM User") - Policy.objects.create(name="test policy", role=adcm_user_role, built_in=False) - - host_1 = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_1") - host_2 = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_2") - host_3 = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_3") - host_unmapped = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_unmapped") - self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_not_in_cluster") - - for host in (host_1, host_2, host_3, host_unmapped): - self.add_host_to_cluster(cluster=self.cluster_1, host=host) - - service = self.add_services_to_cluster(service_names=["service_1"], cluster=self.cluster_1).get() - component_1 = ServiceComponent.objects.get( - cluster=self.cluster_1, service=service, prototype__name="component_1" - ) - component_2 = ServiceComponent.objects.get( - cluster=self.cluster_1, service=service, prototype__name="component_2" - ) - - self.add_hostcomponent_map( - cluster=self.cluster_1, - hc_map=[ - { - "host_id": host_1.pk, - "service_id": service.pk, - "component_id": component_1.pk, - }, - { - "host_id": host_2.pk, - "service_id": service.pk, - "component_id": component_1.pk, - }, - { - "host_id": host_3.pk, - "service_id": service.pk, - "component_id": component_2.pk, - }, - ], - ) - - @staticmethod - def _get_expected_data() -> dict: - date_fmt = "%Y-%m-%d %H:%M:%S" - - users = [ - { - "date_joined": User.objects.get(username="admin").date_joined.strftime(date_fmt), - "email": "admin@example.com", - }, - {"date_joined": User.objects.get(username="status").date_joined.strftime(date_fmt), "email": ""}, - {"date_joined": User.objects.get(username="system").date_joined.strftime(date_fmt), "email": ""}, - ] - - bundles = [ - { - "name": "ADCM", - "version": Bundle.objects.get(name="ADCM").version, - "edition": "community", - "date": Bundle.objects.get(name="ADCM").date.strftime(date_fmt), - }, - { - "name": "cluster_one", - "version": "1.0", - "edition": "community", - "date": Bundle.objects.get(name="cluster_one").date.strftime(date_fmt), - }, - { - "name": "cluster_two", - "version": "1.0", - "edition": "community", - "date": Bundle.objects.get(name="cluster_two").date.strftime(date_fmt), - }, - { - "name": "provider", - "version": "1.0", - "edition": "community", - "date": Bundle.objects.get(name="provider").date.strftime(date_fmt), - }, - ] - - clusters = [ - { - "name": "cluster_1", - "host_count": 4, - "bundle": { - "name": "cluster_one", - "version": "1.0", - "edition": "community", - "date": Bundle.objects.get(name="cluster_one").date.strftime(date_fmt), - }, - "host_component_map": [ - { - "host_name": "379679191547aa70b797855c744bf684", - "component_name": "component_1", - "service_name": "service_1", - }, - { - "host_name": "889214cc620857cbf83f2ccc0c190162", - "component_name": "component_1", - "service_name": "service_1", - }, - { - "host_name": "11ee6e2ffdb6fd444dab9ad0a1fbda9d", - "component_name": "component_2", - "service_name": "service_1", - }, - ], - }, - { - "name": "cluster_2", - "host_count": 0, - "bundle": { - "name": "cluster_two", - "version": "1.0", - "edition": "community", - "date": Bundle.objects.get(name="cluster_two").date.strftime(date_fmt), - }, - "host_component_map": [], - }, - ] - - providers = [ - { - "bundle": { - "date": Bundle.objects.get(name="provider").date.strftime(date_fmt), - "edition": "community", - "name": "provider", - "version": "1.0", - }, - "host_count": 5, - "name": "provider", - } - ] - - roles = [{"built_in": True, "name": "ADCM User"}] - - return { - "adcm": {"uuid": str(ADCM.objects.get().uuid), "version": settings.ADCM_VERSION}, - "data": { - "bundles": bundles, - "clusters": clusters, - "providers": providers, - "roles": roles, - "users": users, - }, - "format_version": 0.1, - } - - def test_data_success(self): - data = load_command_class(app_name="cm", name="collect_statistics").collect_statistics() - expected_data = self._get_expected_data() - - self.assertDictEqual(data["adcm"], expected_data["adcm"]) - self.assertEqual(data["format_version"], expected_data["format_version"]) - - self.assertListEqual(data["data"]["bundles"], expected_data["data"]["bundles"]) - self.assertListEqual(data["data"]["clusters"], expected_data["data"]["clusters"]) - self.assertListEqual(data["data"]["providers"], expected_data["data"]["providers"]) - self.assertListEqual(data["data"]["users"], expected_data["data"]["users"]) - self.assertListEqual(data["data"]["roles"], expected_data["data"]["roles"]) - - class MockResponse: def __init__(self, status_code): self.status_code = status_code @@ -438,7 +259,155 @@ def test_collect_community_bundle_collector(self) -> None: self.assertDictEqual(actual, expected) -class TestStorage(TestStatistics): +class TestStorage(BaseAPITestCase): + def setUp(self) -> None: + super().setUp() + self.maxDiff = None + + enterprise_bundle_cluster = Bundle.objects.create( + name="enterprise_cluster", version="1.0", edition="enterprise" + ) + enterprise_bundle_provider = Bundle.objects.create( + name="enterprise_provider", version="1.2", edition="enterprise" + ) + + gen_cluster(name="enterprise_cluster", bundle=enterprise_bundle_cluster) + gen_provider(name="enterprise_provider", bundle=enterprise_bundle_provider) + + adcm_user_role = Role.objects.get(name="ADCM User") + Policy.objects.create(name="test policy", role=adcm_user_role, built_in=False) + + host_1 = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_1") + host_2 = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_2") + host_3 = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_3") + host_unmapped = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_unmapped") + self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="test_host_not_in_cluster") + + for host in (host_1, host_2, host_3, host_unmapped): + self.add_host_to_cluster(cluster=self.cluster_1, host=host) + + service = self.add_services_to_cluster(service_names=["service_1"], cluster=self.cluster_1).get() + component_1 = ServiceComponent.objects.get( + cluster=self.cluster_1, service=service, prototype__name="component_1" + ) + component_2 = ServiceComponent.objects.get( + cluster=self.cluster_1, service=service, prototype__name="component_2" + ) + + self.set_hostcomponent( + cluster=self.cluster_1, entries=[(host_1, component_1), (host_2, component_1), (host_3, component_2)] + ) + + self.expected_data = self._get_expected_data() + + @staticmethod + def _get_expected_data() -> dict: + date_fmt = "%Y-%m-%d %H:%M:%S" + + users = [ + { + "date_joined": User.objects.get(username="admin").date_joined.strftime(date_fmt), + "email": "admin@example.com", + }, + {"date_joined": User.objects.get(username="status").date_joined.strftime(date_fmt), "email": ""}, + {"date_joined": User.objects.get(username="system").date_joined.strftime(date_fmt), "email": ""}, + ] + + bundles = [ + { + "name": "ADCM", + "version": Bundle.objects.get(name="ADCM").version, + "edition": "community", + "date": Bundle.objects.get(name="ADCM").date.strftime(date_fmt), + }, + { + "name": "cluster_one", + "version": "1.0", + "edition": "community", + "date": Bundle.objects.get(name="cluster_one").date.strftime(date_fmt), + }, + { + "name": "cluster_two", + "version": "1.0", + "edition": "community", + "date": Bundle.objects.get(name="cluster_two").date.strftime(date_fmt), + }, + { + "name": "provider", + "version": "1.0", + "edition": "community", + "date": Bundle.objects.get(name="provider").date.strftime(date_fmt), + }, + ] + + clusters = [ + { + "name": "cluster_1", + "host_count": 4, + "bundle": { + "name": "cluster_one", + "version": "1.0", + "edition": "community", + "date": Bundle.objects.get(name="cluster_one").date.strftime(date_fmt), + }, + "host_component_map": [ + { + "host_name": "379679191547aa70b797855c744bf684", + "component_name": "component_1", + "service_name": "service_1", + }, + { + "host_name": "889214cc620857cbf83f2ccc0c190162", + "component_name": "component_1", + "service_name": "service_1", + }, + { + "host_name": "11ee6e2ffdb6fd444dab9ad0a1fbda9d", + "component_name": "component_2", + "service_name": "service_1", + }, + ], + }, + { + "name": "cluster_2", + "host_count": 0, + "bundle": { + "name": "cluster_two", + "version": "1.0", + "edition": "community", + "date": Bundle.objects.get(name="cluster_two").date.strftime(date_fmt), + }, + "host_component_map": [], + }, + ] + + providers = [ + { + "bundle": { + "date": Bundle.objects.get(name="provider").date.strftime(date_fmt), + "edition": "community", + "name": "provider", + "version": "1.0", + }, + "host_count": 5, + "name": "provider", + } + ] + + roles = [{"built_in": True, "name": "ADCM User"}] + + return { + "adcm": {"uuid": str(ADCM.objects.get().uuid), "version": settings.ADCM_VERSION}, + "data": { + "bundles": bundles, + "clusters": clusters, + "providers": providers, + "roles": roles, + "users": users, + }, + "format_version": 0.1, + } + def read_tar(self, path: Path) -> list[dict]: content = [] with tarfile.open(path) as tar: @@ -448,8 +417,6 @@ def read_tar(self, path: Path) -> list[dict]: return content def test_storage_one_file_success(self): - data = load_command_class(app_name="cm", name="collect_statistics").collect_statistics() - community_storage = TarFileWithJSONFileStorage() expected_name = ( f"{datetime.datetime.now(tz=datetime.timezone.utc).date().strftime('%Y-%m-%d')}_statistics.tar.gz" @@ -458,7 +425,7 @@ def test_storage_one_file_success(self): community_storage.add( JSONFile( filename="data.json", - data=data, + data=self.expected_data, ) ) community_archive = community_storage.gather() @@ -467,11 +434,9 @@ def test_storage_one_file_success(self): self.assertTrue(community_archive.suffixes == [".tar", ".gz"]) self.assertEqual(community_archive.name, expected_name) - self.assertListEqual(self.read_tar(community_archive), [data]) + self.assertListEqual(self.read_tar(community_archive), [self.expected_data]) def test_storage_archive_written_twice_success(self): - data = load_command_class(app_name="cm", name="collect_statistics").collect_statistics() - community_storage = TarFileWithJSONFileStorage() expected_name = ( f"{datetime.datetime.now(tz=datetime.timezone.utc).date().strftime('%Y-%m-%d')}_statistics.tar.gz" @@ -480,20 +445,19 @@ def test_storage_archive_written_twice_success(self): community_storage.add( JSONFile( filename="data.json", - data=data, + data=self.expected_data, ) ) community_archive = community_storage.gather() self.assertEqual(community_archive.name, expected_name) - self.assertListEqual(self.read_tar(community_archive), [data]) + self.assertListEqual(self.read_tar(community_archive), [self.expected_data]) community_archive = community_storage.gather() self.assertEqual(community_archive.name, expected_name) - self.assertListEqual(self.read_tar(community_archive), [data]) + self.assertListEqual(self.read_tar(community_archive), [self.expected_data]) def test_storage_several_files_success(self): - data_cm = load_command_class(app_name="cm", name="collect_statistics").collect_statistics() - full_stat = [data_cm, data_cm, data_cm, data_cm, data_cm, data_cm] + full_stat = [self.expected_data, self.expected_data, self.expected_data] community_storage = TarFileWithJSONFileStorage() @@ -510,12 +474,11 @@ def test_storage_several_files_success(self): self.assertDictEqual(content, expected_data) def test_storage_clear_fail(self): - data = load_command_class(app_name="cm", name="collect_statistics").collect_statistics() community_storage = TarFileWithJSONFileStorage() community_storage.add( JSONFile( filename="data.json", - data=data, + data=self.expected_data, ) ) community_storage.clear() @@ -534,12 +497,11 @@ def test_storage_empty_json_fail(self): community_storage.gather() def test_no_intermediate_files_created(self): - data = load_command_class(app_name="cm", name="collect_statistics").collect_statistics() community_storage = TarFileWithJSONFileStorage() json_file = JSONFile( filename="data.json", - data=data, + data=self.expected_data, ) community_storage.add(json_file) From 2e2245787d7492443c6036648343dd686c2f8324 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Wed, 5 Jun 2024 07:33:12 +0000 Subject: [PATCH 156/208] ADCM-5586 Replace `add_hostcomponent_map` with `set_hostcomponent` --- python/adcm/tests/base.py | 12 +------ .../tests/test_audit/test_group_config.py | 11 +------ python/api_v2/tests/test_cluster.py | 16 ++------- python/api_v2/tests/test_group_config.py | 22 ++----------- python/api_v2/tests/test_host.py | 28 ++++------------ python/api_v2/tests/test_maintenance_mode.py | 30 ++++------------- python/api_v2/tests/test_mapping.py | 5 +-- python/api_v2/tests/test_service.py | 11 +------ .../test_maintenance_mode.py | 11 +------ python/cm/tests/test_inventory/base.py | 18 ++-------- .../test_inventory/test_hc_acl_actions.py | 33 +++++++++---------- ...st_hc_acl_maintenance_mode_group_config.py | 21 ++++-------- python/cm/tests/test_management_commands.py | 21 ++---------- 13 files changed, 47 insertions(+), 192 deletions(-) diff --git a/python/adcm/tests/base.py b/python/adcm/tests/base.py index 0b5fc494b8..dfe01cba12 100644 --- a/python/adcm/tests/base.py +++ b/python/adcm/tests/base.py @@ -15,7 +15,7 @@ from pathlib import Path from shutil import rmtree from tempfile import mkdtemp -from typing import Callable, Iterable, TypedDict +from typing import Callable, Iterable import random import string import tarfile @@ -77,12 +77,6 @@ class TestUserCreateDTO(UserCreateDTO): password: str = "" -class HostComponentMapDictType(TypedDict): - host_id: int - service_id: int - component_id: int - - class ParallelReadyTestCase: def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) @@ -492,10 +486,6 @@ def add_services_to_cluster(service_names: list[str], cluster: Cluster) -> Query ) return bulk_add_services_to_cluster(cluster=cluster, prototypes=service_prototypes) - @staticmethod - def add_hostcomponent_map(cluster: Cluster, hc_map: list[HostComponentMapDictType]) -> list[HostComponent]: - return add_hc(cluster=cluster, hc_in=hc_map) - @staticmethod def set_hostcomponent(cluster: Cluster, entries: Iterable[tuple[Host, ServiceComponent]]) -> list[HostComponent]: return add_hc( diff --git a/python/api_v2/tests/test_audit/test_group_config.py b/python/api_v2/tests/test_audit/test_group_config.py index d9c7cf4e26..cbe8bf40ba 100644 --- a/python/api_v2/tests/test_audit/test_group_config.py +++ b/python/api_v2/tests/test_audit/test_group_config.py @@ -67,16 +67,7 @@ def setUp(self) -> None: object_type=ContentType.objects.get_for_model(self.provider), object_id=self.provider.pk, ) - self.add_hostcomponent_map( - cluster=self.cluster_1, - hc_map=[ - { - "host_id": self.host_for_service.pk, - "service_id": self.service_1.pk, - "component_id": self.component_1.pk, - } - ], - ) + self.set_hostcomponent(cluster=self.cluster_1, entries=[(self.host_for_service, self.component_1)]) self.cluster_config_data = { "config": { "activatable_group": {"integer": 100}, diff --git a/python/api_v2/tests/test_cluster.py b/python/api_v2/tests/test_cluster.py index 64c80f30d6..08c89ab2da 100644 --- a/python/api_v2/tests/test_cluster.py +++ b/python/api_v2/tests/test_cluster.py @@ -10,17 +10,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Iterable from unittest.mock import patch -from cm.api import add_hc +from adcm.tests.base import BusinessLogicMixin from cm.models import ( Action, ADCMEntityStatus, Cluster, ClusterObject, Host, - HostComponent, Prototype, ServiceComponent, ) @@ -608,17 +606,7 @@ def test_adcm_5051_post_change_mm_perm_wrong_object_fail(self): self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) -class TestClusterStatuses(BaseAPITestCase): - @staticmethod - def set_hostcomponent(cluster: Cluster, entries: Iterable[tuple[Host, ServiceComponent]]) -> list[HostComponent]: - return add_hc( - cluster=cluster, - hc_in=[ - {"host_id": host.pk, "component_id": component.pk, "service_id": component.service_id} - for host, component in entries - ], - ) - +class TestClusterStatuses(BaseAPITestCase, BusinessLogicMixin): def setUp(self) -> None: self.client.login(username="admin", password="admin") diff --git a/python/api_v2/tests/test_group_config.py b/python/api_v2/tests/test_group_config.py index 62f6ca906d..7f649e32ce 100644 --- a/python/api_v2/tests/test_group_config.py +++ b/python/api_v2/tests/test_group_config.py @@ -78,16 +78,7 @@ def setUp(self) -> None: self.component_2 = ServiceComponent.objects.get( cluster=self.cluster_1, service=self.service_1, prototype__name="component_2" ) - self.add_hostcomponent_map( - cluster=self.cluster_1, - hc_map=[ - { - "host_id": self.host_for_service.pk, - "service_id": self.service_1.pk, - "component_id": self.component_1.pk, - } - ], - ) + self.set_hostcomponent(cluster=self.cluster_1, entries=[(self.host_for_service, self.component_1)]) class TestGroupConfigNaming(BaseServiceGroupConfigTestCase): @@ -630,16 +621,7 @@ def setUp(self) -> None: bundle=self.provider_bundle, provider=self.provider, fqdn="host_for_component" ) self.add_host_to_cluster(cluster=self.cluster_1, host=self.host_for_component) - self.add_hostcomponent_map( - cluster=self.cluster_1, - hc_map=[ - { - "host_id": self.host_for_component.pk, - "service_id": self.service_1.pk, - "component_id": self.component_1.pk, - } - ], - ) + self.set_hostcomponent(cluster=self.cluster_1, entries=[(self.host_for_component, self.component_1)]) def test_list_success(self): response = self.client.v2[self.component_1, CONFIG_GROUPS].get() diff --git a/python/api_v2/tests/test_host.py b/python/api_v2/tests/test_host.py index b591fc2be5..1056cc595b 100644 --- a/python/api_v2/tests/test_host.py +++ b/python/api_v2/tests/test_host.py @@ -700,29 +700,13 @@ def setUp(self) -> None: self.component_2 = ServiceComponent.objects.get( cluster=self.cluster_1, service=self.service_1, prototype__name="component_2" ) - self.add_hostcomponent_map( + self.set_hostcomponent( cluster=self.cluster_1, - hc_map=[ - { - "host_id": self.host_1.pk, - "service_id": self.service_1.pk, - "component_id": self.component_1.pk, - }, - { - "host_id": self.host_1.pk, - "service_id": self.service_1.pk, - "component_id": self.component_2.pk, - }, - { - "host_id": self.host_2.pk, - "service_id": self.service_1.pk, - "component_id": self.component_1.pk, - }, - { - "host_id": self.host_2.pk, - "service_id": self.service_1.pk, - "component_id": self.component_2.pk, - }, + entries=[ + (self.host_1, self.component_1), + (self.host_1, self.component_2), + (self.host_2, self.component_1), + (self.host_2, self.component_2), ], ) diff --git a/python/api_v2/tests/test_maintenance_mode.py b/python/api_v2/tests/test_maintenance_mode.py index 65648f3322..375bd5e53e 100644 --- a/python/api_v2/tests/test_maintenance_mode.py +++ b/python/api_v2/tests/test_maintenance_mode.py @@ -73,10 +73,7 @@ def test_no_task_run_without_hc_service(self): def test_task_run_if_hc_exists_service(self): self.add_host_to_cluster(cluster=self.cluster, host=self.host) - self.add_hostcomponent_map( - cluster=self.cluster, - hc_map=[{"host_id": self.host.pk, "service_id": self.service.pk, "component_id": self.component.pk}], - ) + self.set_hostcomponent(cluster=self.cluster, entries=[(self.host, self.component)]) response, run_task_mock = self._do_change_mm_request(obj=self.service) @@ -100,10 +97,7 @@ def test_no_task_run_without_hc_component(self): def test_task_run_if_hc_exists_component(self): self.add_host_to_cluster(cluster=self.cluster, host=self.host) - self.add_hostcomponent_map( - cluster=self.cluster, - hc_map=[{"host_id": self.host.pk, "service_id": self.service.pk, "component_id": self.component.pk}], - ) + self.set_hostcomponent(cluster=self.cluster, entries=[(self.host, self.component)]) response, run_task_mock = self._do_change_mm_request(obj=self.component) @@ -128,10 +122,7 @@ def test_task_run_if_obj_is_host_without_hc(self): def test_task_run_if_obj_is_host_hc_exists(self): self.add_host_to_cluster(cluster=self.cluster, host=self.host) - self.add_hostcomponent_map( - cluster=self.cluster, - hc_map=[{"host_id": self.host.pk, "service_id": self.service.pk, "component_id": self.component.pk}], - ) + self.set_hostcomponent(cluster=self.cluster, entries=[(self.host, self.component)]) response, run_task_mock = self._do_change_mm_request(obj=self.host) @@ -144,10 +135,7 @@ def test_task_run_if_obj_is_host_hc_exists(self): def test_mm_not_changed_on_fail_service(self): self.add_host_to_cluster(cluster=self.cluster, host=self.host) - self.add_hostcomponent_map( - cluster=self.cluster, - hc_map=[{"host_id": self.host.pk, "service_id": self.service.pk, "component_id": self.component.pk}], - ) + self.set_hostcomponent(cluster=self.cluster, entries=[(self.host, self.component)]) initial_object_mm = self.service.maintenance_mode response, run_task_mock = self._do_change_mm_request( @@ -167,10 +155,7 @@ def test_mm_not_changed_on_fail_service(self): def test_mm_not_changed_on_fail_component(self): self.add_host_to_cluster(cluster=self.cluster, host=self.host) - self.add_hostcomponent_map( - cluster=self.cluster, - hc_map=[{"host_id": self.host.pk, "service_id": self.service.pk, "component_id": self.component.pk}], - ) + self.set_hostcomponent(cluster=self.cluster, entries=[(self.host, self.component)]) initial_object_mm = self.component.maintenance_mode response, run_task_mock = self._do_change_mm_request( @@ -190,10 +175,7 @@ def test_mm_not_changed_on_fail_component(self): def test_mm_not_changed_on_fail_host(self): self.add_host_to_cluster(cluster=self.cluster, host=self.host) - self.add_hostcomponent_map( - cluster=self.cluster, - hc_map=[{"host_id": self.host.pk, "service_id": self.service.pk, "component_id": self.component.pk}], - ) + self.set_hostcomponent(cluster=self.cluster, entries=[(self.host, self.component)]) initial_object_mm = self.host.maintenance_mode response, run_task_mock = self._do_change_mm_request( diff --git a/python/api_v2/tests/test_mapping.py b/python/api_v2/tests/test_mapping.py index 8e523f9f27..d3626efae7 100644 --- a/python/api_v2/tests/test_mapping.py +++ b/python/api_v2/tests/test_mapping.py @@ -53,10 +53,7 @@ def setUp(self) -> None: cluster=self.cluster_1, service=self.service_1, prototype__name="component_2" ) - self.add_hostcomponent_map( - cluster=self.cluster_1, - hc_map=[{"host_id": self.host_1.pk, "service_id": self.service_1.pk, "component_id": self.component_1.pk}], - ) + self.set_hostcomponent(cluster=self.cluster_1, entries=[(self.host_1, self.component_1)]) self.test_user_credentials = {"username": "test_user_username", "password": "test_user_password"} self.test_user = self.create_user(**self.test_user_credentials) diff --git a/python/api_v2/tests/test_service.py b/python/api_v2/tests/test_service.py index c3ee418ffb..413dfcfc9c 100644 --- a/python/api_v2/tests/test_service.py +++ b/python/api_v2/tests/test_service.py @@ -337,16 +337,7 @@ def setUp(self) -> None: bundle=self.provider_bundle, provider=self.provider, fqdn="doesntmatter_2", cluster=self.cluster_1 ) component = ServiceComponent.objects.filter(cluster_id=self.cluster_1.pk, service_id=self.service.pk).last() - self.add_hostcomponent_map( - cluster=self.cluster_1, - hc_map=[ - { - "host_id": self.host_with_component.pk, - "service_id": self.service.pk, - "component_id": component.pk, - } - ], - ) + self.set_hostcomponent(cluster=self.cluster_1, entries=[(self.host_with_component, component)]) def test_adcm_5278_cluster_hosts_restriction_by_service_administrator_ownership_success(self): response_list = self.client.v2[self.cluster_1, "hosts"].get() diff --git a/python/cm/tests/test_ansible_plugins/test_maintenance_mode.py b/python/cm/tests/test_ansible_plugins/test_maintenance_mode.py index 75a8c0a496..55a99e0e28 100644 --- a/python/cm/tests/test_ansible_plugins/test_maintenance_mode.py +++ b/python/cm/tests/test_ansible_plugins/test_maintenance_mode.py @@ -32,16 +32,7 @@ def setUp(self) -> None: provider_bundle = self.add_bundle(source_dir=bundles_dir / "provider") provider = self.add_provider(bundle=provider_bundle, name="test_provider") self.host = self.add_host(bundle=provider_bundle, provider=provider, fqdn="test_host", cluster=self.cluster) - self.add_hostcomponent_map( - cluster=self.cluster, - hc_map=[ - { - "host_id": self.host.pk, - "service_id": self.service.pk, - "component_id": self.component.pk, - } - ], - ) + self.set_hostcomponent(cluster=self.cluster, entries=[(self.host, self.component)]) def _set_objects_mm( self, diff --git a/python/cm/tests/test_inventory/base.py b/python/cm/tests/test_inventory/base.py index 2d48adfbb0..c079a25a2e 100644 --- a/python/cm/tests/test_inventory/base.py +++ b/python/cm/tests/test_inventory/base.py @@ -21,13 +21,11 @@ from jinja2 import Template from cm.adcm_config.ansible import ansible_decrypt -from cm.api import add_hc from cm.converters import model_name_to_core_type from cm.models import ( Action, ADCMEntity, ADCMModel, - Cluster, ClusterObject, GroupConfig, Host, @@ -83,16 +81,6 @@ def check_hosts_topology(self, data: Mapping[str, dict], expected: Mapping[str, for group_name, host_names in expected.items(): self.assertSetEqual(set(data[group_name]["hosts"].keys()), set(host_names)) - @staticmethod - def set_hostcomponent(cluster: Cluster, entries: Iterable[tuple[Host, ServiceComponent]]) -> list[HostComponent]: - return add_hc( - cluster=cluster, - hc_in=[ - {"host_id": host.pk, "component_id": component.pk, "service_id": component.service_id} - for host, component in entries - ], - ) - def check_data_by_template(self, data: Mapping[str, dict], templates_data: TemplatesData) -> None: for key_chain, template_data in templates_data.items(): template_path, kwargs = template_data @@ -130,9 +118,9 @@ def add_group_config(parent: ADCMModel, hosts: Iterable[Host]) -> GroupConfig: @staticmethod def get_mapping_delta_for_hc_acl(cluster, new_mapping: list[MappingEntry]) -> Delta: - existing_mapping_ids = { - (hc.host.pk, hc.component.pk, hc.service.pk) for hc in HostComponent.objects.filter(cluster=cluster) - } + existing_mapping_ids = set( + HostComponent.objects.values_list("host_id", "component_id", "service_id").filter(cluster=cluster) + ) new_mapping_ids = {(hc["host_id"], hc["component_id"], hc["service_id"]) for hc in new_mapping} added = {} diff --git a/python/cm/tests/test_inventory/test_hc_acl_actions.py b/python/cm/tests/test_inventory/test_hc_acl_actions.py index 55155a5aaf..20f56359f1 100644 --- a/python/cm/tests/test_inventory/test_hc_acl_actions.py +++ b/python/cm/tests/test_inventory/test_hc_acl_actions.py @@ -55,7 +55,8 @@ def setUp(self) -> None: "host_id": self.host_1.pk, } ] - self.add_hostcomponent_map(cluster=self.cluster_1, hc_map=self.initial_hc) + self.initial_hc_objects = ((self.host_1, self.component_1),) + self.set_hostcomponent(cluster=self.cluster_1, entries=self.initial_hc_objects) def test_expand(self): expected_topology = { @@ -131,7 +132,9 @@ def test_expand(self): ] delta = self.get_mapping_delta_for_hc_acl(cluster=self.cluster_1, new_mapping=hc_map_add) - self.add_hostcomponent_map(cluster=self.cluster_1, hc_map=hc_map_add) + self.set_hostcomponent( + cluster=self.cluster_1, entries=[*self.initial_hc_objects, (self.host_2, self.component_2)] + ) for obj, action in [ (self.cluster_1, self.hc_acl_action_cluster), @@ -151,11 +154,9 @@ def test_expand(self): ) def test_shrink(self): - initial_hc = [ - *self.initial_hc, - {"service_id": self.service.pk, "component_id": self.component_2.pk, "host_id": self.host_2.pk}, - ] - self.add_hostcomponent_map(cluster=self.cluster_1, hc_map=initial_hc) + self.set_hostcomponent( + cluster=self.cluster_1, entries=[*self.initial_hc_objects, (self.host_2, self.component_2)] + ) expected_topology = { "CLUSTER": [self.host_1.fqdn, self.host_2.fqdn], @@ -211,7 +212,7 @@ def test_shrink(self): ), } delta = self.get_mapping_delta_for_hc_acl(cluster=self.cluster_1, new_mapping=self.initial_hc) - self.add_hostcomponent_map(cluster=self.cluster_1, hc_map=self.initial_hc) + self.set_hostcomponent(cluster=self.cluster_1, entries=self.initial_hc_objects) for obj, action in ( (self.cluster_1, self.hc_acl_action_cluster), @@ -231,15 +232,9 @@ def test_shrink(self): ) def test_move(self): - initial_hc = [ - *self.initial_hc, - { - "service_id": self.service.pk, - "component_id": self.component_2.pk, - "host_id": self.host_2.pk, - }, - ] - self.add_hostcomponent_map(cluster=self.cluster_1, hc_map=initial_hc) + self.set_hostcomponent( + cluster=self.cluster_1, entries=[*self.initial_hc_objects, (self.host_2, self.component_2)] + ) expected_topology = { "CLUSTER": [self.host_1.fqdn, self.host_2.fqdn], @@ -343,7 +338,9 @@ def test_move(self): ] delta = self.get_mapping_delta_for_hc_acl(cluster=self.cluster_1, new_mapping=hc_map_move) - self.add_hostcomponent_map(cluster=self.cluster_1, hc_map=hc_map_move) + self.set_hostcomponent( + cluster=self.cluster_1, entries=[(self.host_2, self.component_1), (self.host_1, self.component_2)] + ) for obj, action in [ (self.cluster_1, self.hc_acl_action_cluster), diff --git a/python/cm/tests/test_inventory/test_hc_acl_maintenance_mode_group_config.py b/python/cm/tests/test_inventory/test_hc_acl_maintenance_mode_group_config.py index 68424e779e..b5a05179d6 100644 --- a/python/cm/tests/test_inventory/test_hc_acl_maintenance_mode_group_config.py +++ b/python/cm/tests/test_inventory/test_hc_acl_maintenance_mode_group_config.py @@ -36,20 +36,9 @@ def setUp(self) -> None: self.component_1 = ServiceComponent.objects.get(prototype__name="component_1", service=self.service) self.component_2 = ServiceComponent.objects.get(prototype__name="component_2", service=self.service) - self.initial_hc = [ - { - "service_id": self.service.pk, - "component_id": self.component_1.pk, - "host_id": self.host_1.pk, - }, - { - "service_id": self.service.pk, - "component_id": self.component_2.pk, - "host_id": self.host_2.pk, - }, - ] - - self.add_hostcomponent_map(cluster=self.cluster, hc_map=self.initial_hc) + self.set_hostcomponent( + cluster=self.cluster, entries=[(self.host_1, self.component_1), (self.host_2, self.component_2)] + ) self.cluster_group = self.add_group_config(parent=self.cluster, hosts=[self.host_1, self.host_2]) self.service_group = self.add_group_config(parent=self.service, hosts=[self.host_1, self.host_2]) @@ -90,7 +79,9 @@ def test_hc_acl_maintenance_mode_group_config(self): ] delta = self.get_mapping_delta_for_hc_acl(cluster=self.cluster, new_mapping=action_hc_map) - self.add_hostcomponent_map(cluster=self.cluster, hc_map=action_hc_map) + self.set_hostcomponent( + cluster=self.cluster, entries=[(self.host_3, self.component_1), (self.host_4, self.component_2)] + ) expected_topology = { "CLUSTER": [self.host_2.fqdn, self.host_3.fqdn, self.host_4.fqdn], diff --git a/python/cm/tests/test_management_commands.py b/python/cm/tests/test_management_commands.py index 9614bc5d13..83cf971689 100644 --- a/python/cm/tests/test_management_commands.py +++ b/python/cm/tests/test_management_commands.py @@ -55,25 +55,8 @@ def setUp(self) -> None: cluster=self.cluster_1, service=service, prototype__name="component_2" ) - self.add_hostcomponent_map( - cluster=self.cluster_1, - hc_map=[ - { - "host_id": host_1.pk, - "service_id": service.pk, - "component_id": component_1.pk, - }, - { - "host_id": host_2.pk, - "service_id": service.pk, - "component_id": component_1.pk, - }, - { - "host_id": host_3.pk, - "service_id": service.pk, - "component_id": component_2.pk, - }, - ], + self.set_hostcomponent( + cluster=self.cluster_1, entries=[(host_1, component_1), (host_2, component_1), (host_3, component_2)] ) @staticmethod From d3fd91d8c14a8ed619c8385fa0847c2649ea2e0f Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Wed, 5 Jun 2024 07:33:39 +0000 Subject: [PATCH 157/208] ADCM-5652 Removed `validate_object_type` Since `parametrized_by_type` is created internally, validation is removed and put on user to create it correctly --- python/rbac/migrations/0001_initial.py | 11 +---------- python/rbac/models.py | 12 ++---------- 2 files changed, 3 insertions(+), 20 deletions(-) diff --git a/python/rbac/migrations/0001_initial.py b/python/rbac/migrations/0001_initial.py index 193dd694bf..52ea3eb741 100644 --- a/python/rbac/migrations/0001_initial.py +++ b/python/rbac/migrations/0001_initial.py @@ -12,7 +12,6 @@ # Generated by Django 3.2.9 on 2022-01-28 13:12 -from django.core.exceptions import ValidationError from django.db import connection, migrations, models import django.db.models.deletion import django.contrib.auth.models @@ -20,14 +19,6 @@ types = {"cluster", "service", "component", "provider", "host"} -def validate(value): - if not isinstance(value, list): - raise ValidationError("Not a valid list.") - - if not all(v in types for v in value): - raise ValidationError("Not a valid object type.") - - def upgrade_users(apps, schema_editor): query = """ INSERT INTO "rbac_user" ("user_ptr_id", "profile", "built_in") @@ -143,7 +134,7 @@ class Migration(migrations.Migration): ("any_category", models.BooleanField(default=False)), ( "parametrized_by_type", - models.JSONField(default=list, validators=[validate]), + models.JSONField(default=list), ), ( "bundle", diff --git a/python/rbac/models.py b/python/rbac/models.py index 5b8c2c2381..35a482cf8d 100644 --- a/python/rbac/models.py +++ b/python/rbac/models.py @@ -41,7 +41,6 @@ from django.db.transaction import atomic from guardian.models import GroupObjectPermission from guardian.shortcuts import get_perms_for_model -from rest_framework.exceptions import ValidationError from rbac.utils import get_query_tuple_str @@ -65,14 +64,6 @@ class RoleTypes(TextChoices): HIDDEN = "hidden", "hidden" -def validate_object_type(value): - if not isinstance(value, list): - raise ValidationError("Not a valid list.") - - if not all(v in ObjectType.values for v in value): - raise ValidationError("Not a valid object type.") - - class User(AuthUser): profile = JSONField(default=str) built_in = BooleanField(default=False, null=False) @@ -118,7 +109,8 @@ class Role(Model): type = CharField(max_length=1000, choices=RoleTypes.choices, null=False, default=RoleTypes.ROLE) category = ManyToManyField(ProductCategory) any_category = BooleanField(default=False) - parametrized_by_type = JSONField(default=list, null=False, validators=[validate_object_type]) + # should be a list of `ObjectType` strings + parametrized_by_type = JSONField(default=list, null=False) __obj__ = None class Meta: From 7e91e495299c756ad6cfb7cb351558a9d98ef7bf Mon Sep 17 00:00:00 2001 From: astarovo Date: Tue, 4 Jun 2024 15:05:18 +0300 Subject: [PATCH 158/208] ADCM-5565: Rework other unittests --- python/adcm/tests/client.py | 15 ++++++++-- python/api_v2/tests/base.py | 29 ++++++++++++++++++ python/api_v2/tests/test_adcm.py | 13 ++++---- python/api_v2/tests/test_audit/test_adcm.py | 33 +++++++++++---------- python/api_v2/tests/test_bulk_operations.py | 26 +++++----------- python/api_v2/tests/test_known_bugs.py | 5 ++-- python/api_v2/tests/test_profile.py | 8 ++--- python/api_v2/tests/test_schema.py | 10 +++---- 8 files changed, 84 insertions(+), 55 deletions(-) diff --git a/python/adcm/tests/client.py b/python/adcm/tests/client.py index d387e12bb1..5458319522 100644 --- a/python/adcm/tests/client.py +++ b/python/adcm/tests/client.py @@ -107,6 +107,9 @@ class V2RootNode(RootNode): User: "rbac/users", Role: "rbac/roles", Group: "rbac/groups", + "profile": "profile", + "adcm": "adcm", + "schema": "schema", } def __getitem__(self, item: PathObject | tuple[PathObject, str | int | WithID, ...]) -> APINode: @@ -116,10 +119,16 @@ def __getitem__(self, item: PathObject | tuple[PathObject, str | int | WithID, . else: path_object, tail = item, () - root_endpoint = self._CLASS_ROOT_EP_MAP.get(path_object.__class__) + if isinstance(path_object, str): + root_endpoint = self._CLASS_ROOT_EP_MAP.get(path_object) + object_id_path = () + else: + root_endpoint = self._CLASS_ROOT_EP_MAP.get(path_object.__class__) + object_id_path = (str(path_object.id),) + if root_endpoint: return self._node_class( - *self._path, root_endpoint, str(path_object.id), *tail, client=self._client, node_class=self._node_class + *self._path, root_endpoint, *object_id_path, *tail, client=self._client, node_class=self._node_class ) if isinstance(path_object, ClusterObject): @@ -172,6 +181,7 @@ class ADCMTestClient(APIClient): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.versions = APINode("versions", client=self, node_class=APINode) self.v2 = V2RootNode("api", "v2", client=self, node_class=APINode) @@ -179,4 +189,5 @@ class ADCMAsyncTestClient(AsyncClient): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.versions = APINode("versions", client=self, node_class=APINode) self.v2 = V2RootNode("api", "v2", client=self, node_class=AsyncAPINode) diff --git a/python/api_v2/tests/base.py b/python/api_v2/tests/base.py index 74c8a5d7d7..cce2931820 100644 --- a/python/api_v2/tests/base.py +++ b/python/api_v2/tests/base.py @@ -10,6 +10,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from http.cookies import SimpleCookie +from importlib import import_module from pathlib import Path from shutil import rmtree from typing import Any, TypeAlias @@ -28,6 +30,7 @@ ServiceComponent, ) from django.conf import settings +from django.http import HttpRequest from init_db import init from rbac.models import Group, Policy, Role, User from rbac.upgrade.role import init_roles @@ -158,3 +161,29 @@ def prepare_audit_object_arguments( "audit_object__object_type": type_, "audit_object__is_deleted": is_deleted, } + + @property + def session(self): + """Return the current session variables.""" + engine = import_module(settings.SESSION_ENGINE) + cookie = self.cookies.get(settings.SESSION_COOKIE_NAME) + if cookie: + return engine.SessionStore(cookie.value) + session = engine.SessionStore() + session.save() + self.cookies[settings.SESSION_COOKIE_NAME] = session.session_key + return session + + def logout(self): + """Log out the user by removing the cookies and session object.""" + from django.contrib.auth import get_user, logout + + request = HttpRequest() + if self.session: + request.session = self.session + request.user = get_user(request) + else: + engine = import_module(settings.SESSION_ENGINE) + request.session = engine.SessionStore() + logout(request) + self.cookies = SimpleCookie() diff --git a/python/api_v2/tests/test_adcm.py b/python/api_v2/tests/test_adcm.py index 548f766184..559af893db 100644 --- a/python/api_v2/tests/test_adcm.py +++ b/python/api_v2/tests/test_adcm.py @@ -12,7 +12,6 @@ from cm.models import ADCM, Action from django.conf import settings -from rest_framework.reverse import reverse from rest_framework.status import HTTP_200_OK from api_v2.tests.base import BaseAPITestCase @@ -26,27 +25,25 @@ def setUp(self) -> None: self.test_user = self.create_user(**self.test_user_credentials) def test_retrieve_success(self): - response = self.client.get(path=reverse(viewname="v2:adcm-detail")) + response = self.client.v2["adcm"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["id"], ADCM.objects.first().pk) def test_list_actions_success(self): - response = self.client.get(path=reverse(viewname="v2:adcm-action-list")) + response = self.client.v2["adcm", "actions"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()), 2) def test_retrieve_actions_success(self): - response = self.client.get( - path=reverse(viewname="v2:adcm-action-detail", kwargs={"pk": Action.objects.last().pk}) - ) + response = self.client.v2["adcm", "actions", Action.objects.last().pk].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json()["id"], Action.objects.last().pk) def test_get_versions_success(self): - response = self.client.get(path=reverse(viewname="versions")) + response = self.client.versions.get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertDictEqual(response.json(), {"adcm": {"version": settings.ADCM_VERSION}}) @@ -54,7 +51,7 @@ def test_get_versions_success(self): def test_adcm_5461_adcm_basic_actions_success(self): self.client.login(**self.test_user_credentials) - response = self.client.get(path=reverse(viewname="v2:adcm-action-list")) + response = self.client.v2["adcm", "actions"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(len(response.json()), 0) diff --git a/python/api_v2/tests/test_audit/test_adcm.py b/python/api_v2/tests/test_audit/test_adcm.py index a5ffe994c5..d0cb209d21 100644 --- a/python/api_v2/tests/test_audit/test_adcm.py +++ b/python/api_v2/tests/test_audit/test_adcm.py @@ -12,7 +12,6 @@ from cm.models import ADCM, Action -from rest_framework.reverse import reverse from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, @@ -24,6 +23,8 @@ from api_v2.tests.base import BaseAPITestCase +CONFIGS = "configs" + class TestADCMAudit(BaseAPITestCase): def setUp(self) -> None: @@ -84,7 +85,7 @@ def setUp(self) -> None: Action.objects.filter(name="test_ldap_connection") def test_adcm_config_change_success(self): - response = self.client.post(path=reverse(viewname="v2:adcm-config-list"), data=self.data) + response = self.client.v2["adcm", CONFIGS].post(data=self.data) self.assertEqual(response.status_code, HTTP_201_CREATED) self.check_last_audit_record( operation_name="ADCM configuration updated", @@ -99,7 +100,7 @@ def test_adcm_config_change_success(self): ) def test_adcm_config_change_fail(self): - response = self.client.post(path=reverse(viewname="v2:adcm-config-list"), data={}) + response = self.client.v2["adcm", CONFIGS].post(data={}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) self.check_last_audit_record( operation_name="ADCM configuration updated", @@ -110,8 +111,7 @@ def test_adcm_config_change_fail(self): def test_adcm_config_change_access_denied(self): self.client.login(**self.test_user_credentials) - - response = self.client.post(path=reverse(viewname="v2:adcm-config-list"), data=self.data) + response = self.client.v2["adcm", CONFIGS].post(data=self.data) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) self.check_last_audit_record( @@ -122,9 +122,8 @@ def test_adcm_config_change_access_denied(self): ) def test_adcm_profile_password_change_success(self): - response = self.client.patch( - path=reverse(viewname="v2:profile"), data={"newPassword": "newtestpassword", "currentPassword": "admin"} - ) + response = self.client.v2["profile"].patch(data={"newPassword": "newtestpassword", "currentPassword": "admin"}) + self.assertEqual(response.status_code, HTTP_200_OK) self.check_last_audit_record( operation_name="Profile updated", @@ -137,8 +136,7 @@ def test_adcm_profile_password_change_success(self): def test_adcm_put_user_can_change_own_profile_success(self): self.client.login(**self.test_user_credentials) - response = self.client.patch( - path=reverse(viewname="v2:profile"), + response = self.client.v2["profile"].patch( data={"newPassword": "newtestpassword", "currentPassword": "test_user_password"}, ) self.assertEqual(response.status_code, HTTP_200_OK) @@ -153,8 +151,7 @@ def test_adcm_put_user_can_change_own_profile_success(self): def test_adcm_patch_user_can_change_own_profile_success(self): self.client.login(**self.test_user_credentials) - response = self.client.patch( - path=reverse(viewname="v2:profile"), + response = self.client.v2["profile"].patch( data={"newPassword": "newtestpassword", "currentPassword": "test_user_password"}, ) self.assertEqual(response.status_code, HTTP_200_OK) @@ -168,9 +165,11 @@ def test_adcm_patch_user_can_change_own_profile_success(self): ) def test_adcm_run_action_fail(self): - adcm_action_pk = Action.objects.filter(name="test_ldap_connection").first().pk + adcm_action = Action.objects.filter(name="test_ldap_connection").first() - response = self.client.post(path=reverse(viewname="v2:adcm-action-run", kwargs={"pk": adcm_action_pk})) + response = self.client.v2["adcm", "actions", adcm_action, "run"].post( + data={}, + ) self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.check_last_audit_record( @@ -182,10 +181,12 @@ def test_adcm_run_action_fail(self): ) def test_adcm_run_action_denied(self): - adcm_action_pk = Action.objects.filter(name="test_ldap_connection").first().pk + adcm_action = Action.objects.filter(name="test_ldap_connection").first() self.client.login(**self.test_user_credentials) - response = self.client.post(path=reverse(viewname="v2:adcm-action-run", kwargs={"pk": adcm_action_pk})) + response = self.client.v2["adcm", "actions", adcm_action, "run"].post( + data={}, + ) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.check_last_audit_record( diff --git a/python/api_v2/tests/test_bulk_operations.py b/python/api_v2/tests/test_bulk_operations.py index f93682a7fa..65ac907412 100644 --- a/python/api_v2/tests/test_bulk_operations.py +++ b/python/api_v2/tests/test_bulk_operations.py @@ -16,15 +16,15 @@ ClusterObject, ConfigLog, ObjectConfig, - ObjectType, ServiceComponent, ) from rest_framework.response import Response -from rest_framework.reverse import reverse from rest_framework.status import HTTP_200_OK from api_v2.tests.base import BaseAPITestCase +CONFIGS = "configs" + class TestBulkAddServices(BaseAPITestCase): def setUp(self) -> None: @@ -82,23 +82,13 @@ def test_permission_reappliance(self): for request_type, obj in product(["object", "config"], chain(services_qs, components_qs)): obj: ClusterObject | ServiceComponent - if obj.prototype.type == ObjectType.SERVICE: - if request_type == "object": - viewname = "v2:service-detail" - kwargs = {"cluster_pk": self.cluster_1.pk, "pk": obj.pk} - elif request_type == "config": - viewname = "v2:service-config-list" - kwargs = {"cluster_pk": self.cluster_1.pk, "service_pk": obj.pk} - elif obj.prototype.type == ObjectType.COMPONENT: - if request_type == "object": - viewname = "v2:component-detail" - kwargs = {"cluster_pk": self.cluster_1.pk, "service_pk": obj.service.pk, "pk": obj.pk} - elif request_type == "config": - viewname = "v2:component-config-list" - kwargs = {"cluster_pk": self.cluster_1.pk, "service_pk": obj.service.pk, "component_pk": obj.pk} + if request_type == "object": + viewname = self.client.v2[obj] + elif request_type == "config": + viewname = self.client.v2[obj, CONFIGS] else: raise AssertionError("Wrong object type") - with self.subTest(f"View: {viewname}, kwargs: {kwargs}"): - response: Response = self.client.get(path=reverse(viewname=viewname, kwargs=kwargs)) + with self.subTest(f"View: {viewname.path}"): + response: Response = viewname.get() self.assertEqual(response.status_code, HTTP_200_OK) diff --git a/python/api_v2/tests/test_known_bugs.py b/python/api_v2/tests/test_known_bugs.py index 78fd419224..d9ff5bb9e2 100644 --- a/python/api_v2/tests/test_known_bugs.py +++ b/python/api_v2/tests/test_known_bugs.py @@ -10,11 +10,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -from rest_framework.reverse import reverse from rest_framework.status import HTTP_200_OK from api_v2.tests.base import BaseAPITestCase +CONFIG_SCHEMA = "config-schema" + class TestConfigBugs(BaseAPITestCase): def test_cluster_variant_bug_adcm_4778(self): @@ -22,5 +23,5 @@ def test_cluster_variant_bug_adcm_4778(self): bundle = self.add_bundle(self.test_bundles_dir / "bugs" / "ADCM-4778") cluster = self.add_cluster(bundle, "cooler") - response = self.client.get(path=reverse(viewname="v2:cluster-config-schema", kwargs={"pk": cluster.pk})) + response = self.client.v2[cluster, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_200_OK) diff --git a/python/api_v2/tests/test_profile.py b/python/api_v2/tests/test_profile.py index f1d11381b9..40e859f7a5 100644 --- a/python/api_v2/tests/test_profile.py +++ b/python/api_v2/tests/test_profile.py @@ -10,16 +10,16 @@ # See the License for the specific language governing permissions and # limitations under the License. -from adcm.tests.base import BaseTestCase -from django.urls import reverse from rest_framework.status import HTTP_401_UNAUTHORIZED +from api_v2.tests.base import BaseAPITestCase -class TestProfile(BaseTestCase): + +class TestProfile(BaseAPITestCase): def test_unauthenticated_access_adcm_4946_fail(self): self.client.logout() - path = reverse(viewname="v2:profile") + path = self.client.v2["profile"].path for method in ("get", "put", "patch"): with self.subTest(f"[{method.upper()}]"): diff --git a/python/api_v2/tests/test_schema.py b/python/api_v2/tests/test_schema.py index e4c090ab84..fb87a70b33 100644 --- a/python/api_v2/tests/test_schema.py +++ b/python/api_v2/tests/test_schema.py @@ -10,18 +10,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -from adcm.tests.base import BaseTestCase -from rest_framework.reverse import reverse from rest_framework.status import HTTP_200_OK +from api_v2.tests.base import BaseAPITestCase -class TestSchema(BaseTestCase): + +class TestSchema(BaseAPITestCase): def test_swagger_available(self): - response = self.client.get(path=reverse(viewname="v2:swagger-ui")) + response = self.client.v2["schema", "swagger-ui"].get() self.assertEqual(response.status_code, HTTP_200_OK) - response = self.client.get(path=reverse(viewname="v2:schema")) + response = self.client.v2["schema"].get() self.assertEqual(response.status_code, HTTP_200_OK) self.assertIn("openapi", response.data) From 85bb77e3a9a5a06cc3dbcc30a136cd2a4f928e3b Mon Sep 17 00:00:00 2001 From: Igor Kuzmin Date: Wed, 5 Jun 2024 13:26:11 +0000 Subject: [PATCH 159/208] feature/ADCM-5516 + hosts mode Task: https://tracker.yandex.ru/ADCM-5516 --- .../DynamicActionHostMapping.tsx | 35 +- .../DynamicActionHostMapping.utils.ts | 38 ++ .../AddMappingButton/AddMappingButton.tsx | 11 +- .../ClusterMapping/ClusterMapping.module.scss | 4 + .../cluster/ClusterMapping/ClusterMapping.tsx | 80 +++- .../ClusterMapping/ClusterMapping.types.ts | 74 +++- .../ClusterMapping.utils.test.ts | 209 +++++---- .../ClusterMapping/ClusterMapping.utils.ts | 402 +++++++++++------- .../ClusterMappingToolbar.module.scss | 12 + .../ClusterMappingToolbar.tsx | 61 ++- .../ComponentContainer.module.scss | 43 +- .../ComponentContainer/ComponentContainer.tsx | 151 +++---- .../MappedHost/MappedHost.tsx | 32 ++ .../ComponentsMapping.module.scss | 25 +- .../ComponentsMapping/ComponentsMapping.tsx | 73 +--- .../Service/Service.module.scss | 17 + .../ComponentsMapping/Service/Service.tsx | 69 +++ .../HostContainer/HostContainer.module.scss | 4 + .../HostContainer/HostContainer.tsx | 104 ++++- .../MappedComponent.module.scss} | 14 +- .../MappedComponent/MappedComponent.tsx | 65 +++ .../HostsMapping/HostsMapping.tsx | 72 ++-- ...llapsibleComponentRestrictions.module.scss | 28 ++ .../CollapsibleComponentRestrictions.tsx | 43 ++ .../ComponentRestrictions.module.scss | 28 ++ .../ComponentRestrictions.tsx | 54 +++ .../RestrictionsList.module.scss | 34 ++ .../RestrictionsList/RestrictionsList.tsx | 43 ++ .../MappingError/MappingError.module.scss | 33 -- .../MappingError/MappingError.tsx | 50 --- .../MappingItemTag/MappingItemTag.tsx | 65 --- .../RequiredServicesDialog.tsx | 5 +- .../RequiredServicesDialog.types.ts | 0 .../ServicesLicensesStep.tsx | 4 +- .../ShowServices/ShowServices.module.scss | 0 .../ShowServices/ShowServices.tsx | 10 +- .../useRequiredServicesDialog.ts | 0 .../ClusterMapping/useClusterMapping.ts | 57 ++- .../src/components/uikit/Icon/icons/arrow.svg | 4 + .../app/src/components/uikit/Icon/sprite.ts | 1 + adcm-web/app/src/hooks/index.ts | 1 + adcm-web/app/src/hooks/usePrevious.ts | 11 + .../app/src/models/adcm/clusterMapping.ts | 17 +- adcm-web/app/src/models/adcm/service.ts | 23 +- adcm-web/app/src/scss/common.scss | 1 + adcm-web/app/src/scss/vars.scss | 1 + .../adcm/cluster/mapping/mappingSlice.ts | 8 +- .../clusters/clustersDynamicActionsSlice.ts | 9 +- 48 files changed, 1347 insertions(+), 778 deletions(-) create mode 100644 adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.utils.ts create mode 100644 adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/ComponentContainer/MappedHost/MappedHost.tsx create mode 100644 adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/Service/Service.module.scss create mode 100644 adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/Service/Service.tsx rename adcm-web/app/src/components/pages/cluster/ClusterMapping/{MappingItemTag/MappingItemTag.module.scss => HostsMapping/HostContainer/MappedComponent/MappedComponent.module.scss} (87%) create mode 100644 adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/HostContainer/MappedComponent/MappedComponent.tsx create mode 100644 adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/CollapsibleComponentRestrictions.module.scss create mode 100644 adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/CollapsibleComponentRestrictions.tsx create mode 100644 adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/ComponentRestrictions.module.scss create mode 100644 adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/ComponentRestrictions.tsx create mode 100644 adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/RestrictionsList.module.scss create mode 100644 adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/RestrictionsList.tsx delete mode 100644 adcm-web/app/src/components/pages/cluster/ClusterMapping/MappingError/MappingError.module.scss delete mode 100644 adcm-web/app/src/components/pages/cluster/ClusterMapping/MappingError/MappingError.tsx delete mode 100644 adcm-web/app/src/components/pages/cluster/ClusterMapping/MappingItemTag/MappingItemTag.tsx rename adcm-web/app/src/components/pages/cluster/ClusterMapping/{ComponentsMapping => }/RequiredServicesDialog/RequiredServicesDialog.tsx (84%) rename adcm-web/app/src/components/pages/cluster/ClusterMapping/{ComponentsMapping => }/RequiredServicesDialog/RequiredServicesDialog.types.ts (100%) rename adcm-web/app/src/components/pages/cluster/ClusterMapping/{ComponentsMapping => }/RequiredServicesDialog/ServicesLicensesStep/ServicesLicensesStep.tsx (91%) rename adcm-web/app/src/components/pages/cluster/ClusterMapping/{ComponentsMapping => }/RequiredServicesDialog/ShowServices/ShowServices.module.scss (100%) rename adcm-web/app/src/components/pages/cluster/ClusterMapping/{ComponentsMapping => }/RequiredServicesDialog/ShowServices/ShowServices.tsx (80%) rename adcm-web/app/src/components/pages/cluster/ClusterMapping/{ComponentsMapping => }/RequiredServicesDialog/useRequiredServicesDialog.ts (100%) create mode 100644 adcm-web/app/src/components/uikit/Icon/icons/arrow.svg create mode 100644 adcm-web/app/src/hooks/usePrevious.ts diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.tsx b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.tsx index 968fc9866b..691986e6c8 100644 --- a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.tsx +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.tsx @@ -1,12 +1,12 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { useDispatch, useStore } from '@hooks'; import { Button, ButtonGroup, SearchInput, SpinnerPanel, ToolbarPanel } from '@uikit'; import { DynamicActionCommonOptions } from '@commonComponents/DynamicActionDialog/DynamicAction.types'; import s from '@commonComponents/DynamicActionDialog/DynamicActionDialog.module.scss'; import { useClusterMapping } from '@pages/cluster/ClusterMapping/useClusterMapping'; import ComponentContainer from '@pages/cluster/ClusterMapping/ComponentsMapping/ComponentContainer/ComponentContainer'; -import { AdcmMappingComponent, AdcmMappingComponentService } from '@models/adcm'; import { getMappings } from '@store/adcm/clusters/clustersDynamicActionsSlice'; +import { getComponentMapActions, getDisabledMappings } from './DynamicActionHostMapping.utils'; import { Link } from 'react-router-dom'; import { LoadState } from '@models/loadState'; @@ -41,8 +41,8 @@ const DynamicActionHostMapping: React.FC = ({ servicesMapping, mappingFilter, handleMappingFilterChange, - mappingValidation, - handleMap, + mappingErrors, + handleMapHostsToComponent, handleUnmap, handleReset, } = useClusterMapping(mapping, hosts, components, notAddedServicesDictionary, true); @@ -53,16 +53,13 @@ const DynamicActionHostMapping: React.FC = ({ onSubmit({ hostComponentMap: localMapping }); }; - const getMapRules = (service: AdcmMappingComponentService, component: AdcmMappingComponent) => { - return actionDetails.hostComponentMapRules.filter( - (rule) => rule.service === service.name && rule.component === component.name, - ); - }; - const handleFilterChange = (event: React.ChangeEvent) => { handleMappingFilterChange({ hostName: event.target.value }); }; + const hasErrors = Object.keys(mappingErrors).length > 0; + const disabledMappings = useMemo(() => getDisabledMappings(mapping), [mapping]); + return (
@@ -72,11 +69,7 @@ const DynamicActionHostMapping: React.FC = ({ - @@ -96,8 +89,8 @@ const DynamicActionHostMapping: React.FC = ({ )} {servicesMapping.flatMap(({ service, componentsMapping }) => componentsMapping.map((componentMapping) => { - const actions = getMapRules(service, componentMapping.component).map((rule) => rule.action); - const allowActions = [...new Set(actions)]; + const allowActions = getComponentMapActions(actionDetails, service, componentMapping.component); + const componentMappingErrors = mappingErrors[componentMapping.component.id]; return ( = ({ componentMapping={componentMapping} filter={mappingFilter} allHosts={hosts} - notAddedServicesDictionary={notAddedServicesDictionary} - componentMappingValidation={mappingValidation.byComponents[componentMapping.component.id]} - onMap={handleMap} + disabledHosts={disabledMappings[componentMapping.component.id]} + mappingErrors={componentMappingErrors} + onMap={handleMapHostsToComponent} onUnmap={handleUnmap} allowActions={allowActions} - denyAddHostReason="Add host do not allow in config of action" - denyRemoveHostReason="Remove host do not allow in config of action" /> ); }), diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.utils.ts b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.utils.ts new file mode 100644 index 0000000000..2ff4b34046 --- /dev/null +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionHostMapping/DynamicActionHostMapping.utils.ts @@ -0,0 +1,38 @@ +import type { + AdcmDynamicActionDetails, + AdcmHostComponentMapRuleAction, + AdcmMapping, + AdcmMappingComponent, + AdcmMappingComponentService, +} from '@models/adcm'; +import type { DisabledComponentsMappings } from '@pages/cluster/ClusterMapping/ClusterMapping.types'; + +export const getComponentMapActions = ( + actionDetails: AdcmDynamicActionDetails, + service: AdcmMappingComponentService, + component: AdcmMappingComponent, +) => { + const result = new Set(); + + for (const rule of actionDetails.hostComponentMapRules) { + if (rule.service === service.name && rule.component === component.name) { + result.add(rule.action); + } + } + + return result; +}; + +export const getDisabledMappings = (mapping: AdcmMapping[]) => { + const result: DisabledComponentsMappings = {}; + + for (const m of mapping) { + if (result[m.componentId] === undefined) { + result[m.componentId] = new Set(); + } + + result[m.componentId].add(m.hostId); + } + + return result; +}; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/AddMappingButton/AddMappingButton.tsx b/adcm-web/app/src/components/pages/cluster/ClusterMapping/AddMappingButton/AddMappingButton.tsx index 167c8d287a..2309d38da1 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterMapping/AddMappingButton/AddMappingButton.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/AddMappingButton/AddMappingButton.tsx @@ -1,20 +1,19 @@ import { forwardRef } from 'react'; -import { Button, ConditionalWrapper } from '@uikit'; +import { Button, ConditionalWrapper, Tooltip } from '@uikit'; import s from './AddMappingButton.module.scss'; import cn from 'classnames'; -import Tooltip from '@uikit/Tooltip/Tooltip'; export interface AddMappingButtonProps { className?: string; label: string; - onAddClick: () => void; isDisabled?: boolean; - denyAddHostReason?: React.ReactNode; + tooltip?: React.ReactNode; + onClick: () => void; } const AddMappingButton = forwardRef( - ({ className = '', label, onAddClick, denyAddHostReason, isDisabled = false }: AddMappingButtonProps, ref) => ( - + ({ className = '', label, onClick: onAddClick, tooltip, isDisabled = false }: AddMappingButtonProps, ref) => ( +
( -
-
Service of the component must have "Created" state.
-
Maintenance mode on the component must be Off
-
-); diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/ComponentContainer/MappedHost/MappedHost.tsx b/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/ComponentContainer/MappedHost/MappedHost.tsx new file mode 100644 index 0000000000..0c8d2dc093 --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/ComponentContainer/MappedHost/MappedHost.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Tag, IconButton } from '@uikit'; + +export interface MappedHostProps { + id: number; + label: string; + isDisabled?: boolean; + deleteButtonTooltip?: React.ReactNode; + onDeleteClick: (e: React.MouseEvent) => void; +} + +const MappedHost = ({ id, label, isDisabled = false, deleteButtonTooltip, onDeleteClick }: MappedHostProps) => ( + + } + > + {label} + +); + +export default MappedHost; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/ComponentsMapping.module.scss b/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/ComponentsMapping.module.scss index 07bd860beb..209cf04526 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/ComponentsMapping.module.scss +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/ComponentsMapping.module.scss @@ -1,24 +1,5 @@ -.serviceMapping { - &__title { - margin-bottom: 24px; - display: flex; - align-items: center; - gap: 12px; - color: var(--component-container-valid-text-color); - - &_error { - color: var(--component-container-error-color); - } - } - - &:not(:last-child) { - margin-bottom: 24px; - } -} - - .componentsMapping { - display: grid; - grid-template-columns: 4fr 1fr; - gap: 20px; + display: grid; + grid-template-columns: 4fr 1fr; + gap: 20px; } \ No newline at end of file diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/ComponentsMapping.tsx b/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/ComponentsMapping.tsx index 0a3719010d..efed0dc9a7 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/ComponentsMapping.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/ComponentsMapping.tsx @@ -1,35 +1,24 @@ import { useMemo } from 'react'; -import { AnchorBar, AnchorBarItem, AnchorList, MarkerIcon, Text } from '@uikit'; -import ComponentContainer from './ComponentContainer/ComponentContainer'; import { Link, useParams } from 'react-router-dom'; -import RequiredServicesDialog from '@pages/cluster/ClusterMapping/ComponentsMapping/RequiredServicesDialog/RequiredServicesDialog'; -import { AdcmEntitySystemState, AdcmHostShortView, AdcmMaintenanceMode, AdcmMappingComponent } from '@models/adcm'; -import { MappingFilter, MappingValidation, ServiceMapping } from '../ClusterMapping.types'; +import { AnchorBar, AnchorBarItem, AnchorList } from '@uikit'; +import Service from './Service/Service'; +import { AdcmHostShortView, AdcmMappingComponent } from '@models/adcm'; +import type { MappingFilter, ComponentsMappingErrors, ServiceMapping } from '../ClusterMapping.types'; import s from './ComponentsMapping.module.scss'; -import cn from 'classnames'; -import { NotAddedServicesDictionary } from '@store/adcm/cluster/mapping/mappingSlice'; const buildServiceAnchorId = (id: number) => `anchor_${id}`; export interface ComponentsMappingProps { hosts: AdcmHostShortView[]; servicesMapping: ServiceMapping[]; - mappingValidation: MappingValidation; + mappingErrors: ComponentsMappingErrors; mappingFilter: MappingFilter; - notAddedServicesDictionary: NotAddedServicesDictionary; onMap: (hosts: AdcmHostShortView[], component: AdcmMappingComponent) => void; onUnmap: (hostId: number, componentId: number) => void; + onInstallServices: (component: AdcmMappingComponent) => void; } -const ComponentsMapping = ({ - hosts, - servicesMapping, - mappingValidation, - mappingFilter, - notAddedServicesDictionary, - onMap, - onUnmap, -}: ComponentsMappingProps) => { +const ComponentsMapping = ({ servicesMapping, ...restProps }: ComponentsMappingProps) => { const { clusterId: clusterIdFromUrl } = useParams(); const clusterId = Number(clusterIdFromUrl); @@ -45,44 +34,14 @@ const ComponentsMapping = ({ return (
- {servicesMapping.map(({ service, componentsMapping }) => { - const isServiceValid = componentsMapping.every( - (cm) => mappingValidation.byComponents[cm.component.id].isValid, - ); - const titleClassName = cn(s.serviceMapping__title, { - [s['serviceMapping__title_error']]: !isServiceValid, - }); - - const markerType = isServiceValid ? 'check' : 'alert'; - - return ( -
- - {service.displayName} - - - {componentsMapping.map((componentMapping) => { - const isEditableComponent = - componentMapping.component.service.state === AdcmEntitySystemState.Created && - componentMapping.component.maintenanceMode !== AdcmMaintenanceMode.On; - - return ( - - ); - })} -
- ); - })} + {servicesMapping.map(({ service, componentsMapping }) => ( + + ))} {servicesMapping.length === 0 && (
Add services on the{' '} @@ -95,8 +54,6 @@ const ComponentsMapping = ({ - -
); }; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/Service/Service.module.scss b/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/Service/Service.module.scss new file mode 100644 index 0000000000..93cb56b3ac --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/Service/Service.module.scss @@ -0,0 +1,17 @@ +.service { + &__title { + margin-bottom: 24px; + display: flex; + align-items: center; + gap: 12px; + color: var(--component-container-valid-text-color); + + &_error { + color: var(--component-container-error-color); + } + } + + &:not(:last-child) { + margin-bottom: 24px; + } +} diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/Service/Service.tsx b/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/Service/Service.tsx new file mode 100644 index 0000000000..1724373bc1 --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/Service/Service.tsx @@ -0,0 +1,69 @@ +import { useMemo } from 'react'; +import { Text, MarkerIcon } from '@uikit'; +import ComponentContainer from '../ComponentContainer/ComponentContainer'; +import type { ComponentsMappingErrors, MappingFilter, ComponentMapping } from '../../ClusterMapping.types'; +import type { AdcmHostShortView, AdcmMappingComponent, AdcmMappingComponentService } from '@models/adcm'; +import s from './Service.module.scss'; +import cn from 'classnames'; + +export interface ServiceProps { + service: AdcmMappingComponentService; + componentsMapping: ComponentMapping[]; + anchorId: string; + hosts: AdcmHostShortView[]; + mappingFilter: MappingFilter; + mappingErrors: ComponentsMappingErrors; + onMap: (hosts: AdcmHostShortView[], component: AdcmMappingComponent) => void; + onUnmap: (hostId: number, componentId: number) => void; + onInstallServices?: (component: AdcmMappingComponent) => void; +} + +const Service = ({ + service, + componentsMapping, + anchorId, + hosts, + mappingFilter, + mappingErrors, + onMap, + onUnmap, + onInstallServices, +}: ServiceProps) => { + const isServiceValid = componentsMapping.every((cm) => mappingErrors[cm.component.id] === undefined); + const titleClassName = cn(s.service__title, { + [s.service__title_error]: !isServiceValid, + }); + + const markerType = isServiceValid ? 'check' : 'alert'; + + const filteredComponentsMapping = useMemo(() => { + return componentsMapping.filter((componentMapping) => + componentMapping.component.displayName.toLowerCase().includes(mappingFilter.componentDisplayName.toLowerCase()), + ); + }, [mappingFilter.componentDisplayName, componentsMapping]); + + return ( +
+ + {service.displayName} + + + {filteredComponentsMapping.map((componentMapping) => { + return ( + + ); + })} +
+ ); +}; + +export default Service; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/HostContainer/HostContainer.module.scss b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/HostContainer/HostContainer.module.scss index 10625ab8c4..f204a93a9b 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/HostContainer/HostContainer.module.scss +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/HostContainer/HostContainer.module.scss @@ -25,6 +25,10 @@ &__components { margin-top: 8px; } + + &_disabled { + border-color: var(--component-container-disabled-color); + } } .hostContainerHeader { diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/HostContainer/HostContainer.tsx b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/HostContainer/HostContainer.tsx index fee78c3328..44061b2f33 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/HostContainer/HostContainer.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/HostContainer/HostContainer.tsx @@ -1,19 +1,52 @@ -import { useMemo } from 'react'; -import { Tags } from '@uikit'; -import MappingItemTag from '../../MappingItemTag/MappingItemTag'; -import { MappingValidation, HostMapping, MappingFilter } from '../../ClusterMapping.types'; +import { useMemo, useRef, useState } from 'react'; +import { SelectOption, Tags } from '@uikit'; +import MappedComponent from './MappedComponent/MappedComponent'; +import type { ComponentsMappingErrors, HostMapping, MappingFilter } from '../../ClusterMapping.types'; +import { type AdcmHostShortView, type AdcmMappingComponent } from '@models/adcm'; +import { checkHostMappingAvailability, checkComponentMappingAvailability } from '../../ClusterMapping.utils'; +import AddMappingButton from '../../AddMappingButton/AddMappingButton'; +import MappingItemSelect from '../../MappingItemSelect/MappingItemSelect'; import s from './HostContainer.module.scss'; import cn from 'classnames'; export interface HostContainerProps { hostMapping: HostMapping; - mappingValidation: MappingValidation; + mappingErrors: ComponentsMappingErrors; filter: MappingFilter; + allComponents: AdcmMappingComponent[]; className?: string; + onMap: (components: AdcmMappingComponent[], host: AdcmHostShortView) => void; + onUnmap: (hostId: number, componentId: number) => void; } -const HostContainer = ({ hostMapping, mappingValidation, filter, className }: HostContainerProps) => { +const HostContainer = ({ + hostMapping, + allComponents, + mappingErrors, + filter, + className, + onMap, + onUnmap, +}: HostContainerProps) => { const { host, components } = hostMapping; + const [isSelectOpen, setIsSelectOpen] = useState(false); + const addIconRef = useRef(null); + + const hostNotAvailableError = checkHostMappingAvailability(host); + + const componentsOptions = useMemo[]>( + () => + allComponents.map((component) => { + const { componentNotAvailableError } = checkComponentMappingAvailability(component); + return { + label: component.displayName, + value: component, + disabled: Boolean(componentNotAvailableError), + title: componentNotAvailableError, + }; + }), + [allComponents], + ); const visibleHostComponents = useMemo( () => @@ -27,26 +60,67 @@ const HostContainer = ({ hostMapping, mappingValidation, filter, className }: Ho return null; } + const hostClassName = cn(className, s.hostContainer, { + [s.hostContainer_disabled]: hostNotAvailableError, + }); + + const handleAddClick = () => { + setIsSelectOpen(true); + }; + + const handleDelete = (e: React.MouseEvent) => { + const componentId = Number(e.currentTarget.dataset.id); + onUnmap(host.id, componentId); + }; + + const handleMappingChange = (components: AdcmMappingComponent[]) => { + onMap(components, host); + }; + return ( <> -
+
{host.name} {components.length} +
{visibleHostComponents.length > 0 && ( - {visibleHostComponents.map((c) => ( - - ))} + {visibleHostComponents.map((component) => { + const { componentNotAvailableError } = checkComponentMappingAvailability(component); + return ( + + ); + })} )}
+ ); }; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/MappingItemTag/MappingItemTag.module.scss b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/HostContainer/MappedComponent/MappedComponent.module.scss similarity index 87% rename from adcm-web/app/src/components/pages/cluster/ClusterMapping/MappingItemTag/MappingItemTag.module.scss rename to adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/HostContainer/MappedComponent/MappedComponent.module.scss index e17d9513ec..7b1330c61a 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterMapping/MappingItemTag/MappingItemTag.module.scss +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/HostContainer/MappedComponent/MappedComponent.module.scss @@ -1,19 +1,17 @@ :global { body.theme-dark { - --mapping-tag-background-color: var(--color-new-light); + --mapping-tag-background-color: var(--color-xdark-new-20); --mapping-tag-valid-border-color: var(--color-green-dark); --mapping-tag-valid-border-color-hover: var(--color-xgreen-saturated); --mapping-tag-valid-color: var(--color-xgreen-saturated); --mapping-tag-valid-color-hover: var(--color-xwhite-off); - --mapping-tag-valid-background-color: var(--color-xdark-new-20); --mapping-tag-valid-background-color-hover: var(--color-xgreen-saturated15); --mapping-tag-error-border-color: var(--color-dark-red); --mapping-tag-error-border-color-hover: var(--color-xred); --mapping-tag-error-color: var(--color-xred); --mapping-tag-error-color-hover: var(--color-xwhite-off); - --mapping-tag-valid-background-color: var(--color-xdark-new-20); --mapping-tag-error-background-color-hover: var(--color-red-15); --mapping-tag-error-tooltip-color: var(--color-xwhite-off); @@ -23,7 +21,7 @@ } body.theme-light { - --mapping-tag-background-color: var(--color-popup-light); + --mapping-tag-background-color: var(--color-xgray-alt-30); --mapping-tag-valid-border-color: var(--color-xgreen); --mapping-tag-valid-border-color-hover: var(--color-xgreen-saturated); @@ -51,16 +49,19 @@ &__tooltip { border-radius: 8px; border: 1px solid var(--mapping-tag-error-tooltip-border-color); - color: var(--mapping-tag-error-tooltip-color); + background-color: var(--mapping-tag-error-tooltip-background-color); box-shadow: var(--mapping-tag-error-tooltip-shadow); + + * { + color: var(--mapping-tag-error-tooltip-color) !important; + } } &_valid { padding: 4px 6px; color: var(--mapping-tag-valid-color); border: 2px solid var(--mapping-tag-valid-border-color); - background-color: var(--mapping-tag-valid-background-color); &:hover { color: var(--mapping-tag-valid-color-hover); @@ -73,7 +74,6 @@ padding: 4px 6px; color: var(--mapping-tag-error-color); border: 2px solid var(--mapping-tag-error-border-color); - background-color: var(--mapping-tag-background-color); &:hover { color: var(--mapping-tag-error-color-hover); diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/HostContainer/MappedComponent/MappedComponent.tsx b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/HostContainer/MappedComponent/MappedComponent.tsx new file mode 100644 index 0000000000..a6d23833f0 --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/HostContainer/MappedComponent/MappedComponent.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Tag, IconButton, MarkerIcon, Tooltip, ConditionalWrapper } from '@uikit'; +import type { ComponentMappingErrors } from '../../../ClusterMapping.types'; +import ComponentRestrictions from '../../RestrictionsList/ComponentRestrictions'; +import cn from 'classnames'; +import s from './MappedComponent.module.scss'; + +export interface MappedComponentProps { + id: number; + label: string; + isDisabled?: boolean; + deleteButtonTooltip?: React.ReactNode; + mappingErrors?: ComponentMappingErrors; + onDeleteClick: (e: React.MouseEvent) => void; +} + +const MappedComponent = ({ + id, + label, + isDisabled = false, + deleteButtonTooltip, + mappingErrors, + onDeleteClick, +}: MappedComponentProps) => { + const isValid = mappingErrors === undefined; + const className = cn(s.mappingTag, { + [s['mappingTag_valid']]: isValid, + [s['mappingTag_error']]: !isValid, + }); + + return ( + + ) : null + } + > + } + placement="bottom-start" + className={s.mappingTag__tooltip} + offset={16} + > + + + {label} + + ); +}; + +export default MappedComponent; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/HostsMapping.tsx b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/HostsMapping.tsx index bec1ebf56e..333f4809bc 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/HostsMapping.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/HostsMapping.tsx @@ -1,44 +1,56 @@ +import { useMemo } from 'react'; import HostContainer from './HostContainer/HostContainer'; -import { useDispatch, useStore } from '@hooks'; -import { useEffect } from 'react'; -import { setBreadcrumbs } from '@store/adcm/breadcrumbs/breadcrumbsSlice'; -import { HostMapping, MappingFilter, MappingValidation } from '../ClusterMapping.types'; +import type { HostMapping, MappingFilter, ComponentsMappingErrors } from '../ClusterMapping.types'; +import type { AdcmHostShortView, AdcmMappingComponent } from '@models/adcm'; import s from './HostsMapping.module.scss'; +import RestrictionsList from './RestrictionsList/RestrictionsList'; export interface HostsMappingProps { + components: AdcmMappingComponent[]; hostsMapping: HostMapping[]; - mappingValidation: MappingValidation; + mappingErrors: ComponentsMappingErrors; mappingFilter: MappingFilter; + onMap: (components: AdcmMappingComponent[], host: AdcmHostShortView) => void; + onUnmap: (hostId: number, componentId: number) => void; + onInstallServices: (component: AdcmMappingComponent) => void; } -const HostsMapping = ({ hostsMapping, mappingValidation, mappingFilter }: HostsMappingProps) => { - const dispatch = useDispatch(); - - const cluster = useStore(({ adcm }) => adcm.cluster.cluster); - useEffect(() => { - if (cluster) { - dispatch( - setBreadcrumbs([ - { href: '/clusters', label: 'Clusters' }, - { href: `/clusters/${cluster.id}`, label: cluster.name }, - { label: 'Mapping' }, - { label: 'Hosts view' }, - ]), - ); - } - }, [cluster, dispatch]); +const HostsMapping = ({ + components, + hostsMapping, + mappingErrors, + mappingFilter, + onMap, + onUnmap, + onInstallServices, +}: HostsMappingProps) => { + const filteredHostsMapping = useMemo(() => { + return hostsMapping.filter((hostMapping) => + hostMapping.host.name.toLowerCase().includes(mappingFilter.hostName.toLowerCase()), + ); + }, [mappingFilter.hostName, hostsMapping]); return (
- {hostsMapping.map((hostMapping) => ( - - ))} +
+ {filteredHostsMapping.map((hostMapping) => ( + + ))} +
+
); }; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/CollapsibleComponentRestrictions.module.scss b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/CollapsibleComponentRestrictions.module.scss new file mode 100644 index 0000000000..22a9663b5e --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/CollapsibleComponentRestrictions.module.scss @@ -0,0 +1,28 @@ +.collapsibleComponentRestrictions { + &:not(:last-child) { + margin-bottom: 16px; + } + + &__header { + background-color: rgba(242, 97, 97, 0.15); + border-radius: 4px; + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 8px; + color: var(--color-light-red); + font-weight: 500; + font-size: 15px; + } + + &__icon { + cursor: pointer; + &:not(:global(.is-open)) { + transform: rotate(-90deg); + } + } + + &__list { + margin-top: 8px; + } +} diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/CollapsibleComponentRestrictions.tsx b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/CollapsibleComponentRestrictions.tsx new file mode 100644 index 0000000000..0a357c8155 --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/CollapsibleComponentRestrictions.tsx @@ -0,0 +1,43 @@ +import React, { useState } from 'react'; +import { AdcmMappingComponent } from '@models/adcm'; +import { Collapse, Icon } from '@uikit'; +import ComponentRestrictions from './ComponentRestrictions'; +import type { ComponentMappingErrors } from '../../ClusterMapping.types'; +import s from './CollapsibleComponentRestrictions.module.scss'; +import cn from 'classnames'; + +export interface CollapsibleComponentRestrictionsProps { + component: AdcmMappingComponent; + errors: ComponentMappingErrors; + onInstallServices: () => void; +} + +const CollapsibleComponentRestrictions = (props: CollapsibleComponentRestrictionsProps) => { + const [isExpanded, setIsExpanded] = useState(false); + + const handleToggle = () => { + setIsExpanded((prev) => !prev); + }; + + const iconClassName = cn(s.collapsibleComponentRestrictions__icon, { + ['is-open']: isExpanded, + }); + + return ( +
+
+ {props.component.displayName} + +
+ + + +
+ ); +}; + +export default CollapsibleComponentRestrictions; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/ComponentRestrictions.module.scss b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/ComponentRestrictions.module.scss new file mode 100644 index 0000000000..5a44bf0091 --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/ComponentRestrictions.module.scss @@ -0,0 +1,28 @@ +:global { + body.theme-dark { + --component-restrictions-item-color: #989AA8; + --component-restrictions-constraint-item-color: var(--color-xwhite-off); + } + + body.theme-light { + --component-restrictions-item-color: var(--color-adcmx); + --component-restrictions-constraint-item-color: var(--color-xdark) + } +} + +.componentRestrictions { + font-size: 15px; + font-weight: 500px; + + &__listItem { + color: var(--component-restrictions-item-color); + + &:not(:last-child) { + margin-bottom: 8px; + } + + &_constraint { + color: var(--component-restrictions-constraint-item-color); + } + } +} diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/ComponentRestrictions.tsx b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/ComponentRestrictions.tsx new file mode 100644 index 0000000000..cd1a162cf1 --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/ComponentRestrictions.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import type { ComponentMappingErrors } from '../../ClusterMapping.types'; +import s from './ComponentRestrictions.module.scss'; +import cn from 'classnames'; + +export interface ComponentRestrictionsProps { + errors: ComponentMappingErrors; + className?: string; + onInstallServices?: () => void; +} + +const ComponentRestrictions = (props: ComponentRestrictionsProps) => { + return ( +
    + {props.errors.constraintsError && ( +
  • + {props.errors.constraintsError.message} +
  • + )} + {props.errors.dependenciesErrors?.notAddedErrors && ( + <> +
  • + Requires{' '} + + {' '} + adding + {' '} + of services: +
  • + {props.errors.dependenciesErrors.notAddedErrors.map((error) => ( +
  • {`- ${error.params.service.displayName}`}
  • + ))} + + )} + {props.errors.dependenciesErrors?.requiredErrors && ( + <> +
  • Requires mapping of components:
  • + {props.errors.dependenciesErrors.requiredErrors.map((error) => ( + + {error.params.components.map((name) => ( +
  • {`- ${name}`}
  • + ))} +
    + ))} + + )} +
+ ); +}; + +export default ComponentRestrictions; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/RestrictionsList.module.scss b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/RestrictionsList.module.scss new file mode 100644 index 0000000000..7ffee0e739 --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/RestrictionsList.module.scss @@ -0,0 +1,34 @@ +:global { + body.theme-dark { + --restrictions-list-background: rgba(39, 38, 43, 0.30); + --restrictions-list-header-color: var(--color-xwhite-off); + } + + body.theme-light { + --restrictions-list-background: rgba(227, 234, 238, 0.30); + --restrictions-list-header-color: var(--color-xdark); + } +} + +.restrictionsList { + max-height: calc(100vh - 353px); + overflow-y: hidden; + background-color: var(--restrictions-list-background); + padding: 16px 12px 12px 12px; + border-radius: 10px; + display: flex; + gap: 16px; + flex-direction: column; + + &__header { + color: var(--restrictions-list-header-color); + font-size: 15px; + font-weight: 500; + } + + &__content { + max-height: 100%; + overflow-y: scroll; + padding-right: 8px; + } +} diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/RestrictionsList.tsx b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/RestrictionsList.tsx new file mode 100644 index 0000000000..f322809eb6 --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/HostsMapping/RestrictionsList/RestrictionsList.tsx @@ -0,0 +1,43 @@ +import type { ComponentsMappingErrors } from '../../ClusterMapping.types'; +import type { AdcmMappingComponent } from '@models/adcm'; +import CollapsibleComponentRestrictions from './CollapsibleComponentRestrictions'; +import s from './RestrictionsList.module.scss'; +import cn from 'classnames'; + +export interface RestrictionsListProps { + allComponents: AdcmMappingComponent[]; + mappingErrors: ComponentsMappingErrors; + onInstallServices: (component: AdcmMappingComponent) => void; +} + +const RestrictionsList = (props: RestrictionsListProps) => { + const { allComponents, mappingErrors } = props; + + const sortedAllComponents = [...allComponents]; + sortedAllComponents.sort((a, b) => a.displayName.localeCompare(b.displayName)); + + return ( +
+
List of Restrictions
+
+ {sortedAllComponents.map((component) => { + const componentErrors = mappingErrors[component.id]; + if (componentErrors === undefined) { + return null; + } + + return ( + props.onInstallServices(component)} + /> + ); + })} +
+
+ ); +}; + +export default RestrictionsList; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/MappingError/MappingError.module.scss b/adcm-web/app/src/components/pages/cluster/ClusterMapping/MappingError/MappingError.module.scss deleted file mode 100644 index 95241e59fb..0000000000 --- a/adcm-web/app/src/components/pages/cluster/ClusterMapping/MappingError/MappingError.module.scss +++ /dev/null @@ -1,33 +0,0 @@ -:global { - body.theme-dark { - --mapping-error-tooltip-color: var(--color-xwhite-off); - --mapping-error-tooltip-border: var(--color-xdark); - --mapping-error-tooltip-error-background: var(--color-xred-dark); - --mapping-error-tooltip-error-shadow: rgba(2, 1, 17, 0.20); - } - - body.theme-light { - --mapping-error-tooltip-color: var(--color-xdark); - --mapping-error-tooltip-border: var(--color-stroke-light); - --mapping-error-tooltip-error-background: var(--color-red-30); - --mapping-error-tooltip-warning-background: rgba(199, 196, 226, 0.20); - } -} - -.mappingError { - &__message { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - color: var(--component-container-error-color); - } - - &__tooltip { - padding: 8px 16px; - border-radius: 8px; - border: 1px solid var(--mapping-error-tooltip-border); - color: var(--mapping-error-tooltip-color); - background: var(--mapping-error-tooltip-error-background); - box-shadow: 0px 20px 20px 0px var(--mapping-error-tooltip-error-shadow); - } -} \ No newline at end of file diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/MappingError/MappingError.tsx b/adcm-web/app/src/components/pages/cluster/ClusterMapping/MappingError/MappingError.tsx deleted file mode 100644 index 6e5476379a..0000000000 --- a/adcm-web/app/src/components/pages/cluster/ClusterMapping/MappingError/MappingError.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useState, useRef } from 'react'; -import { Popover } from '@uikit'; -import s from './MappingError.module.scss'; - -export interface MappingErrorProps { - message: string; -} - -const MappingError = ({ message }: MappingErrorProps) => { - const [hasOverflowingChildren, setHasOverflowingChildren] = useState(false); - const triggerRef = useRef(null); - - const handleHover = (e: React.MouseEvent) => { - const el = e.target as Element; - - if (!hasOverflowingChildren && el.scrollWidth > el.clientWidth) { - setHasOverflowingChildren(true); - } else { - setHasOverflowingChildren(false); - } - }; - - const handleMouseLeave = () => { - setHasOverflowingChildren(false); - }; - - return ( - <> - - {message} - - -
{message}
-
- - ); -}; - -export default MappingError; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/MappingItemTag/MappingItemTag.tsx b/adcm-web/app/src/components/pages/cluster/ClusterMapping/MappingItemTag/MappingItemTag.tsx deleted file mode 100644 index 471ecbadbf..0000000000 --- a/adcm-web/app/src/components/pages/cluster/ClusterMapping/MappingItemTag/MappingItemTag.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react'; -import { Tag, IconButton, MarkerIcon, Tooltip, ConditionalWrapper } from '@uikit'; -import s from './MappingItemTag.module.scss'; -import cn from 'classnames'; -import { ValidationResult, ValidationError } from '../ClusterMapping.types'; - -export interface MappingItemTagProps { - id: number; - label: string; - isDisabled?: boolean; - validationResult?: ValidationResult; - onDeleteClick?: (e: React.MouseEvent) => void; - denyRemoveHostReason?: React.ReactNode; -} - -const MappingItemTag = ({ - id, - label, - isDisabled = false, - validationResult, - onDeleteClick, - denyRemoveHostReason, -}: MappingItemTagProps) => { - const className = cn(s.mappingTag, { - [s['mappingTag_valid']]: validationResult?.isValid === true, - [s['mappingTag_error']]: validationResult?.isValid === false, - }); - - return ( - - ) : null - } - > - {validationResult !== undefined && ( - - - - )} - {label} - - ); -}; - -export default MappingItemTag; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/RequiredServicesDialog/RequiredServicesDialog.tsx b/adcm-web/app/src/components/pages/cluster/ClusterMapping/RequiredServicesDialog/RequiredServicesDialog.tsx similarity index 84% rename from adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/RequiredServicesDialog/RequiredServicesDialog.tsx rename to adcm-web/app/src/components/pages/cluster/ClusterMapping/RequiredServicesDialog/RequiredServicesDialog.tsx index 5b5b0e6380..fb9f95f975 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/RequiredServicesDialog/RequiredServicesDialog.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/RequiredServicesDialog/RequiredServicesDialog.tsx @@ -1,13 +1,12 @@ import React from 'react'; import { Dialog } from '@uikit'; import { useRequiredServicesDialog } from './useRequiredServicesDialog'; -import ShowServices from '@pages/cluster/ClusterMapping/ComponentsMapping/RequiredServicesDialog/ShowServices/ShowServices'; +import ShowServices from './ShowServices/ShowServices'; +import ServicesLicensesStep from './ServicesLicensesStep/ServicesLicensesStep'; import { RequiredServicesStepKey } from './RequiredServicesDialog.types'; -import ServicesLicensesStep from '@pages/cluster/ClusterMapping/ComponentsMapping/RequiredServicesDialog/ServicesLicensesStep/ServicesLicensesStep'; const RequiredServicesDialog: React.FC = () => { const { - // isOpen, onClose, onSubmit, diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/RequiredServicesDialog/RequiredServicesDialog.types.ts b/adcm-web/app/src/components/pages/cluster/ClusterMapping/RequiredServicesDialog/RequiredServicesDialog.types.ts similarity index 100% rename from adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/RequiredServicesDialog/RequiredServicesDialog.types.ts rename to adcm-web/app/src/components/pages/cluster/ClusterMapping/RequiredServicesDialog/RequiredServicesDialog.types.ts diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/RequiredServicesDialog/ServicesLicensesStep/ServicesLicensesStep.tsx b/adcm-web/app/src/components/pages/cluster/ClusterMapping/RequiredServicesDialog/ServicesLicensesStep/ServicesLicensesStep.tsx similarity index 91% rename from adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/RequiredServicesDialog/ServicesLicensesStep/ServicesLicensesStep.tsx rename to adcm-web/app/src/components/pages/cluster/ClusterMapping/RequiredServicesDialog/ServicesLicensesStep/ServicesLicensesStep.tsx index e648bcc9e3..8d6a51c3cc 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/RequiredServicesDialog/ServicesLicensesStep/ServicesLicensesStep.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/RequiredServicesDialog/ServicesLicensesStep/ServicesLicensesStep.tsx @@ -1,14 +1,14 @@ import React, { useMemo } from 'react'; import LicenseAcceptanceList from '@commonComponents/license/LicenseAcceptanceList/LicenseAcceptanceList'; import { useDispatch } from '@hooks'; -import { AdcmDependOnService, AdcmLicenseStatus } from '@models/adcm'; +import { AdcmComponentDependency, AdcmLicenseStatus } from '@models/adcm'; import { acceptServiceLicense } from '@store/adcm/cluster/services/servicesSlice'; import { RequiredServicesFormData } from '../RequiredServicesDialog.types'; interface ServicesLicensesStepProps { formData: RequiredServicesFormData; onChange: (changes: Partial) => void; - unacceptedSelectedServices: AdcmDependOnService['servicePrototype'][]; + unacceptedSelectedServices: AdcmComponentDependency[]; } const ServicesLicensesStep: React.FC = ({ diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/RequiredServicesDialog/ShowServices/ShowServices.module.scss b/adcm-web/app/src/components/pages/cluster/ClusterMapping/RequiredServicesDialog/ShowServices/ShowServices.module.scss similarity index 100% rename from adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/RequiredServicesDialog/ShowServices/ShowServices.module.scss rename to adcm-web/app/src/components/pages/cluster/ClusterMapping/RequiredServicesDialog/ShowServices/ShowServices.module.scss diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/RequiredServicesDialog/ShowServices/ShowServices.tsx b/adcm-web/app/src/components/pages/cluster/ClusterMapping/RequiredServicesDialog/ShowServices/ShowServices.tsx similarity index 80% rename from adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/RequiredServicesDialog/ShowServices/ShowServices.tsx rename to adcm-web/app/src/components/pages/cluster/ClusterMapping/RequiredServicesDialog/ShowServices/ShowServices.tsx index d0b249fd14..b77ffab4e1 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/RequiredServicesDialog/ShowServices/ShowServices.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/RequiredServicesDialog/ShowServices/ShowServices.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { AdcmDependOnService, AdcmPrototypeShortView } from '@models/adcm'; +import { AdcmComponentDependency, AdcmPrototypeShortView } from '@models/adcm'; import { useStore } from '@hooks'; import MarkedList from '@uikit/MarkedList/MarkedList'; import s from './ShowServices.module.scss'; @@ -7,8 +7,8 @@ import WarningMessage from '@uikit/WarningMessage/WarningMessage'; const getComponentKey = (item: AdcmPrototypeShortView) => item.id; const renderComponentItem = (item: AdcmPrototypeShortView) =>
{item.displayName}
; -const getServiceKey = (item: AdcmDependOnService['servicePrototype']) => item.id; -const renderServiceItem = (item: AdcmDependOnService['servicePrototype']) => ( +const getServiceKey = (item: AdcmComponentDependency) => item.id; +const renderServiceItem = (item: AdcmComponentDependency) => ( <>
{item.displayName}
@@ -16,8 +16,8 @@ const renderServiceItem = (item: AdcmDependOnService['servicePrototype']) => ( ); interface ShowServicesProps { - dependsServices: AdcmDependOnService['servicePrototype'][]; - unacceptedSelectedServices: AdcmDependOnService['servicePrototype'][]; + dependsServices: AdcmComponentDependency[]; + unacceptedSelectedServices: AdcmComponentDependency[]; } const ShowServices: React.FC = ({ dependsServices, unacceptedSelectedServices }) => { const srcComponent = useStore(({ adcm }) => adcm.clusterMapping.requiredServicesDialog.component); diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/RequiredServicesDialog/useRequiredServicesDialog.ts b/adcm-web/app/src/components/pages/cluster/ClusterMapping/RequiredServicesDialog/useRequiredServicesDialog.ts similarity index 100% rename from adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/RequiredServicesDialog/useRequiredServicesDialog.ts rename to adcm-web/app/src/components/pages/cluster/ClusterMapping/RequiredServicesDialog/useRequiredServicesDialog.ts diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/useClusterMapping.ts b/adcm-web/app/src/components/pages/cluster/ClusterMapping/useClusterMapping.ts index afd2114d1d..8b8f3cb555 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterMapping/useClusterMapping.ts +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/useClusterMapping.ts @@ -1,14 +1,15 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { AdcmMappingComponent, AdcmHostShortView, AdcmMapping } from '@models/adcm'; +import { AdcmMappingComponent, AdcmHostShortView, AdcmMapping, NotAddedServicesDictionary } from '@models/adcm'; import { arrayToHash } from '@utils/arrayUtils'; import { getComponentsMapping, getHostsMapping, getServicesMapping, mapHostsToComponent, + mapComponentsToHost, validate, } from './ClusterMapping.utils'; -import { +import type { MappingFilter, HostMapping, ComponentMapping, @@ -16,7 +17,7 @@ import { HostsDictionary, ComponentsDictionary, } from './ClusterMapping.types'; -import { NotAddedServicesDictionary } from '@store/adcm/cluster/mapping/mappingSlice'; +import { SortDirection } from '@models/table'; export const useClusterMapping = ( mapping: AdcmMapping[], @@ -37,6 +38,8 @@ export const useClusterMapping = ( isHideEmpty: false, }); + const [mappingSortDirection, setMappingSortDirection] = useState('asc'); + useEffect(() => { if (isLoaded) { setLocalMapping(mapping); @@ -48,22 +51,30 @@ export const useClusterMapping = ( [components, hostsDictionary, isLoaded, localMapping], ); - const hostsMapping: HostMapping[] = useMemo( - () => (isLoaded ? getHostsMapping(localMapping, hosts, componentsDictionary) : []), - [hosts, componentsDictionary, isLoaded, localMapping], - ); + const hostsMapping: HostMapping[] = useMemo(() => { + const result = isLoaded ? getHostsMapping(localMapping, hosts, componentsDictionary) : []; + result.sort((a, b) => a.host.name.localeCompare(b.host.name)); + if (mappingSortDirection === 'desc') { + result.reverse(); + } + return result; + }, [hosts, componentsDictionary, isLoaded, localMapping, mappingSortDirection]); - const servicesMapping: ServiceMapping[] = useMemo( - () => (isLoaded ? getServicesMapping(componentsMapping) : []), - [isLoaded, componentsMapping], - ); + const servicesMapping: ServiceMapping[] = useMemo(() => { + const result = isLoaded ? getServicesMapping(componentsMapping) : []; + result.sort((a, b) => a.service.name.localeCompare(b.service.name)); + if (mappingSortDirection === 'desc') { + result.reverse(); + } + return result; + }, [isLoaded, componentsMapping, mappingSortDirection]); const servicesMappingDictionary = useMemo( () => arrayToHash(servicesMapping, (sm) => sm.service.prototype.id), [servicesMapping], ); - const mappingValidation = useMemo(() => { + const mappingErrors = useMemo(() => { return validate(componentsMapping, { servicesMappingDictionary, notAddedServicesDictionary, @@ -71,13 +82,22 @@ export const useClusterMapping = ( }); }, [componentsMapping, servicesMappingDictionary, notAddedServicesDictionary, hosts.length]); - const handleMap = useCallback( + const handleMapHostsToComponent = useCallback( (hosts: AdcmHostShortView[], component: AdcmMappingComponent) => { - const newLocalMapping = mapHostsToComponent(servicesMapping, hosts, component); + const newLocalMapping = mapHostsToComponent(localMapping, hosts, component); setLocalMapping(newLocalMapping); setIsMappingChanged(true); }, - [servicesMapping], + [localMapping], + ); + + const handleMapComponentsToHost = useCallback( + (components: AdcmMappingComponent[], host: AdcmHostShortView) => { + const newLocalMapping = mapComponentsToHost(localMapping, components, host); + setLocalMapping(newLocalMapping); + setIsMappingChanged(true); + }, + [localMapping], ); const handleUnmap = useCallback( @@ -108,10 +128,13 @@ export const useClusterMapping = ( isMappingChanged, mappingFilter, handleMappingFilterChange, + mappingSortDirection, + handleMappingSortDirectionChange: setMappingSortDirection, components, servicesMapping, - mappingValidation, - handleMap, + mappingErrors, + handleMapHostsToComponent, + handleMapComponentsToHost, handleUnmap, handleReset, }; diff --git a/adcm-web/app/src/components/uikit/Icon/icons/arrow.svg b/adcm-web/app/src/components/uikit/Icon/icons/arrow.svg new file mode 100644 index 0000000000..4fde24ddd7 --- /dev/null +++ b/adcm-web/app/src/components/uikit/Icon/icons/arrow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/adcm-web/app/src/components/uikit/Icon/sprite.ts b/adcm-web/app/src/components/uikit/Icon/sprite.ts index 5f92a7b7c6..ad81a325fb 100644 --- a/adcm-web/app/src/components/uikit/Icon/sprite.ts +++ b/adcm-web/app/src/components/uikit/Icon/sprite.ts @@ -1,6 +1,7 @@ export const allowIconsNames = [ // 'alert-circle', + 'arrow', 'arrow-sorting', 'check', 'check-hollow', diff --git a/adcm-web/app/src/hooks/index.ts b/adcm-web/app/src/hooks/index.ts index 3dc5962a61..70f6436f26 100644 --- a/adcm-web/app/src/hooks/index.ts +++ b/adcm-web/app/src/hooks/index.ts @@ -18,3 +18,4 @@ export { useForwardRef } from './useForwardRef'; export { useLocalPagination } from './useLocalPagination'; export { useMediaQuery } from './useMediaQuery'; export { useSelectedItems } from './useSelectedItems'; +export { usePrevious } from './usePrevious'; diff --git a/adcm-web/app/src/hooks/usePrevious.ts b/adcm-web/app/src/hooks/usePrevious.ts new file mode 100644 index 0000000000..38a60643f5 --- /dev/null +++ b/adcm-web/app/src/hooks/usePrevious.ts @@ -0,0 +1,11 @@ +import { useEffect, useRef } from 'react'; + +export const usePrevious = (value: T): T | undefined => { + const ref = useRef(); + + useEffect(() => { + ref.current = value; + }); + + return ref.current; +}; diff --git a/adcm-web/app/src/models/adcm/clusterMapping.ts b/adcm-web/app/src/models/adcm/clusterMapping.ts index f170cae889..16b2525572 100644 --- a/adcm-web/app/src/models/adcm/clusterMapping.ts +++ b/adcm-web/app/src/models/adcm/clusterMapping.ts @@ -1,5 +1,5 @@ import { AdcmMaintenanceMode } from './maintenanceMode'; -import { AdcmDependOnService } from './service'; +import { AdcmDependOnService, AdcmServicePrototype } from './service'; import { AdcmPrototypeShortView } from './prototype'; import { AdcmEntityState } from './common'; @@ -18,19 +18,6 @@ export interface AdcmHostShortView { maintenanceMode: AdcmMaintenanceMode; } -interface AdcmComponentDependencyLicense { - status: string; - text: string; -} - -export interface AdcmComponentDependency { - id: number; - name: string; - displayName: string; - componentPrototypes?: AdcmComponentDependency[]; - license: AdcmComponentDependencyLicense; -} - export type AdcmComponentConstraint = number | string; export type ServiceId = AdcmMappingComponentService['id']; @@ -62,3 +49,5 @@ export interface CreateMappingPayload { hostId: number; componentId: number; } + +export type NotAddedServicesDictionary = Record; diff --git a/adcm-web/app/src/models/adcm/service.ts b/adcm-web/app/src/models/adcm/service.ts index 8ef753241b..c77ceb71fd 100644 --- a/adcm-web/app/src/models/adcm/service.ts +++ b/adcm-web/app/src/models/adcm/service.ts @@ -1,5 +1,5 @@ import { AdcmConcerns } from './concern'; -import { AdcmLicense, AdcmLicenseStatus } from './license'; +import { AdcmLicense } from './license'; import { AdcmPrototypeShortView, AdcmPrototypeType } from './prototype'; import { AdcmMaintenanceMode } from './maintenanceMode'; @@ -8,11 +8,13 @@ export enum AdcmServiceStatus { Down = 'down', } +export interface AdcmComponentDependency extends AdcmPrototypeShortView { + license: AdcmLicense; + componentPrototypes: AdcmPrototypeShortView[]; +} + export interface AdcmDependOnService { - servicePrototype: AdcmPrototypeShortView & { - license: AdcmLicense; - componentPrototypes: AdcmPrototypeShortView[]; - }; + servicePrototype: AdcmComponentDependency; } export interface AdcmService { @@ -35,16 +37,9 @@ export interface AdcmService { export type ServicePrototypeId = AdcmServicePrototype['id']; -export interface AdcmServicePrototype { - id: number; - name: string; - displayName: string; +export interface AdcmServicePrototype extends AdcmPrototypeShortView { type: AdcmPrototypeType.Service; - version: string; - license: { - status: AdcmLicenseStatus; - text: string | null; - }; + license: AdcmLicense; isRequired: boolean; dependOn: AdcmDependOnService[] | null; } diff --git a/adcm-web/app/src/scss/common.scss b/adcm-web/app/src/scss/common.scss index bed1e31220..1e93b4fd53 100644 --- a/adcm-web/app/src/scss/common.scss +++ b/adcm-web/app/src/scss/common.scss @@ -31,6 +31,7 @@ body { &:hover { color: var(--text-green-color) !important; } + cursor: pointer; } ul.marked-list { diff --git a/adcm-web/app/src/scss/vars.scss b/adcm-web/app/src/scss/vars.scss index 069cc8bfff..c2a5453d5a 100644 --- a/adcm-web/app/src/scss/vars.scss +++ b/adcm-web/app/src/scss/vars.scss @@ -69,6 +69,7 @@ --color-xwhite: #FFF; --color-xwhite-50: rgba(255, 255, 255, 0.5); + --color-xwhite-70: rgba(255, 255, 255, 0.7); --color-xwhite-off: #FCFCFD; --color-white-background-50: #EEF2F5; diff --git a/adcm-web/app/src/store/adcm/cluster/mapping/mappingSlice.ts b/adcm-web/app/src/store/adcm/cluster/mapping/mappingSlice.ts index 1750aed30c..5e8e39a3bb 100644 --- a/adcm-web/app/src/store/adcm/cluster/mapping/mappingSlice.ts +++ b/adcm-web/app/src/store/adcm/cluster/mapping/mappingSlice.ts @@ -6,17 +6,14 @@ import { AdcmError, AdcmHostShortView, AdcmMapping, + NotAddedServicesDictionary, AdcmMappingComponent, - AdcmServicePrototype, - ServiceId, } from '@models/adcm'; import { AdcmClusterServicesApi } from '@api/adcm/clusterServices'; import { arrayToHash } from '@utils/arrayUtils'; import { ActionState, RequestState } from '@models/loadState'; import { processErrorResponse } from '@utils/responseUtils'; -export type NotAddedServicesDictionary = Record; - type GetClusterMappingArg = { clusterId: number; }; @@ -35,7 +32,6 @@ type AdcmClusterMappingsState = { }; saving: { state: ActionState; - hasError: boolean; }; relatedData: { notAddedServicesDictionary: NotAddedServicesDictionary; @@ -127,7 +123,6 @@ const createInitialState = (): AdcmClusterMappingsState => ({ }, saving: { state: 'not-started', - hasError: false, }, relatedData: { notAddedServicesDictionary: {}, @@ -186,7 +181,6 @@ const mappingSlice = createSlice({ }); builder.addCase(saveMapping.rejected, (state) => { state.saving.state = 'completed'; - state.saving.hasError = true; }); builder.addCase(getNotAddedServices.fulfilled, (state, action) => { state.relatedData.notAddedServicesDictionary = arrayToHash(action.payload, (s) => s.id); diff --git a/adcm-web/app/src/store/adcm/clusters/clustersDynamicActionsSlice.ts b/adcm-web/app/src/store/adcm/clusters/clustersDynamicActionsSlice.ts index 8cfe8de88b..55439bffce 100644 --- a/adcm-web/app/src/store/adcm/clusters/clustersDynamicActionsSlice.ts +++ b/adcm-web/app/src/store/adcm/clusters/clustersDynamicActionsSlice.ts @@ -1,5 +1,11 @@ import { createSlice } from '@reduxjs/toolkit'; -import { AdcmCluster, AdcmHostShortView, AdcmMapping, AdcmMappingComponent } from '@models/adcm'; +import { + AdcmCluster, + AdcmHostShortView, + AdcmMapping, + AdcmMappingComponent, + NotAddedServicesDictionary, +} from '@models/adcm'; import { createAsyncThunk } from '@store/redux'; import { AdcmClusterMappingApi, AdcmClustersApi, RequestError } from '@api'; import { fulfilledFilter } from '@utils/promiseUtils'; @@ -10,7 +16,6 @@ import { ActionStatuses } from '@constants'; import { LoadState } from '@models/loadState'; import { AdcmClusterServicesApi } from '@api/adcm/clusterServices'; import { arrayToHash } from '@utils/arrayUtils'; -import { NotAddedServicesDictionary } from '../cluster/mapping/mappingSlice'; type GetClusterMappingArg = { clusterId: number; From 91fe02102e4fbb9fec4b3cf8201e292a10a86e88 Mon Sep 17 00:00:00 2001 From: Skrynnik Daniil Date: Wed, 5 Jun 2024 17:10:55 +0300 Subject: [PATCH 160/208] ADCM-5580: refactor test_rbac_superuser.py --- python/api_v2/tests/test_rbac_superuser.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/python/api_v2/tests/test_rbac_superuser.py b/python/api_v2/tests/test_rbac_superuser.py index ce877a6620..6140dd7483 100644 --- a/python/api_v2/tests/test_rbac_superuser.py +++ b/python/api_v2/tests/test_rbac_superuser.py @@ -19,9 +19,8 @@ from rbac.services.user import perform_user_creation from rest_framework.response import Response from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_403_FORBIDDEN, HTTP_409_CONFLICT -from rest_framework.test import APIClient -from api_v2.tests.base import BaseAPITestCase +from api_v2.tests.base import ADCMTestClient, BaseAPITestCase class TestUserCreateEdit(BaseAPITestCase): @@ -55,9 +54,9 @@ def setUp(self) -> None: ) ) - self.creator_client = APIClient() + self.creator_client = self.client_class() self.creator_client.login(username="icancreate", password=self.password) - self.editor_client = APIClient() + self.editor_client = self.client_class() self.editor_client.login(username="icanedit", password=self.password) policy_create(name="Creators policy", role=create_user_role, group=[creators_group]) @@ -72,12 +71,12 @@ def setUp(self) -> None: } @staticmethod - def request_create_user(client: APIClient, data: dict) -> Response: - return client.post(path="/api/v2/rbac/users/", data=data) + def request_create_user(client: ADCMTestClient, data: dict) -> Response: + return (client.v2 / "rbac" / "users").post(data=data) @staticmethod - def request_edit_user(client: APIClient, user_id: int, data: dict) -> Response: - return client.patch(path=f"/api/v2/rbac/users/{user_id}/", data=data) + def request_edit_user(client: ADCMTestClient, user_id: int, data: dict) -> Response: + return (client.v2 / "rbac" / "users" / str(user_id)).patch(data=data) # Create Restrictions From 21b5426b189732665374a5bcf45bc35b0e37793c Mon Sep 17 00:00:00 2001 From: Aleksandr Alferov Date: Wed, 5 Jun 2024 18:01:19 +0300 Subject: [PATCH 161/208] ADCM-5588 Fix run send statistics, send every day --- os/etc/crontabs/root | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/os/etc/crontabs/root b/os/etc/crontabs/root index 3ec8162372..4c5a86dd69 100755 --- a/os/etc/crontabs/root +++ b/os/etc/crontabs/root @@ -4,4 +4,4 @@ 0 8 */1 * * python /adcm/python/manage.py logrotate --target all 0 10 */1 * * python /adcm/python/manage.py clearaudit */1 * * * * python /adcm/python/manage.py run_ldap_sync -0 0 * * 1 python /adcm/python/manage.py collect_statistics --mode send +0 0 * * * python /adcm/python/manage.py collect_statistics --mode send From a97199eaed5a259292ac397652fe0d07265bb1d6 Mon Sep 17 00:00:00 2001 From: Aleksandr Alferov Date: Thu, 6 Jun 2024 10:30:17 +0300 Subject: [PATCH 162/208] ADCM-5588 Fix typehints, fix __slots__ and fix base class for StorageError class --- python/cm/collect_statistics/collectors.py | 2 ++ python/cm/collect_statistics/encoders.py | 2 +- python/cm/collect_statistics/errors.py | 2 +- python/cm/collect_statistics/storages.py | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/python/cm/collect_statistics/collectors.py b/python/cm/collect_statistics/collectors.py index 3b45350037..c3883be269 100644 --- a/python/cm/collect_statistics/collectors.py +++ b/python/cm/collect_statistics/collectors.py @@ -89,6 +89,8 @@ def __call__(self) -> RBACEntities: class BundleCollector: + __slots__ = ("_date_format", "_filters") + def __init__(self, date_format: str, filters: Collection[Q]): self._date_format = date_format self._filters = filters diff --git a/python/cm/collect_statistics/encoders.py b/python/cm/collect_statistics/encoders.py index bd90fa1601..7298c4d766 100644 --- a/python/cm/collect_statistics/encoders.py +++ b/python/cm/collect_statistics/encoders.py @@ -18,7 +18,7 @@ class TarFileEncoder(Encoder[Path]): """Encode and decode a file in place""" - __slots__ = ["suffix"] + __slots__ = ("suffix",) def __init__(self, suffix: str) -> None: if suffix and not suffix.startswith(".") or suffix == ".": diff --git a/python/cm/collect_statistics/errors.py b/python/cm/collect_statistics/errors.py index 1218c34842..ccb2df85f5 100644 --- a/python/cm/collect_statistics/errors.py +++ b/python/cm/collect_statistics/errors.py @@ -27,5 +27,5 @@ class RetriesExceededError(SenderError): pass -class StorageError(Exception): +class StorageError(BaseStatisticsError): pass diff --git a/python/cm/collect_statistics/storages.py b/python/cm/collect_statistics/storages.py index f5512da806..b12981128b 100644 --- a/python/cm/collect_statistics/storages.py +++ b/python/cm/collect_statistics/storages.py @@ -31,7 +31,7 @@ class JSONFile(BaseModel): class TarFileWithJSONFileStorage(Storage[JSONFile]): __slots__ = ("json_files", "tmp_dir", "compresslevel", "date_format") - def __init__(self, compresslevel=9, date_format="%Y-%m-%d"): + def __init__(self, compresslevel: int = 9, date_format: str = "%Y-%m-%d") -> None: self.json_files = [] self.tmp_dir = Path(mkdtemp()).absolute() self.compresslevel = compresslevel From 6924a16334a2b66bedb4e47d8911bea244d6af72 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Fri, 7 Jun 2024 04:18:14 +0000 Subject: [PATCH 163/208] ADCM-5640 Reworked `adcm_change_maintenance_mode` plugin --- .../action/adcm_change_maintenance_mode.py | 36 +---- python/ansible_plugin/base.py | 12 +- .../executors/change_maintenance_mode.py | 107 +++++++++++++++ python/ansible_plugin/maintenance_mode.py | 74 ---------- .../test_adcm_change_maintenance_mode.py | 129 ++++++++++++++++++ python/ansible_plugin/utils.py | 46 ------- python/cm/services/maintenance_mode.py | 8 -- .../cm/tests/test_ansible_plugins/__init__.py | 12 -- .../test_maintenance_mode.py | 109 --------------- 9 files changed, 247 insertions(+), 286 deletions(-) create mode 100644 python/ansible_plugin/executors/change_maintenance_mode.py delete mode 100644 python/ansible_plugin/maintenance_mode.py create mode 100644 python/ansible_plugin/tests/test_adcm_change_maintenance_mode.py delete mode 100644 python/cm/tests/test_ansible_plugins/__init__.py delete mode 100644 python/cm/tests/test_ansible_plugins/test_maintenance_mode.py diff --git a/python/ansible/plugins/action/adcm_change_maintenance_mode.py b/python/ansible/plugins/action/adcm_change_maintenance_mode.py index e9368413e0..d9b5584432 100644 --- a/python/ansible/plugins/action/adcm_change_maintenance_mode.py +++ b/python/ansible/plugins/action/adcm_change_maintenance_mode.py @@ -45,41 +45,13 @@ import sys -from ansible.errors import AnsibleActionFail -from ansible.plugins.action import ActionBase - sys.path.append("/adcm/python") import adcm.init_django # noqa: F401, isort:skip -from ansible_plugin.maintenance_mode import get_object, validate_args, validate_obj -from cm.models import MaintenanceMode -from cm.services.maintenance_mode import set_maintenance_mode - - -class ActionModule(ActionBase): - TRANSFERS_FILES = False - _VALID_ARGS = frozenset(["type", "value"]) - - def run(self, tmp=None, task_vars=None): - super().run(tmp, task_vars) - - error = validate_args(task_args=self._task.args) - if error is not None: - raise error - - obj, error = get_object(task_vars=task_vars, obj_type=self._task.args["type"]) - if error is not None: - raise error - - error = validate_obj(obj=obj) - if error is not None: - raise error +from ansible_plugin.base import ADCMAnsiblePlugin +from ansible_plugin.executors.change_maintenance_mode import ADCMChangeMMExecutor - value = MaintenanceMode.ON if self._task.args["value"] else MaintenanceMode.OFF - try: - set_maintenance_mode(obj=obj, value=value) - except Exception as e: # noqa: BLE001 - raise AnsibleActionFail("Unexpected error occurred while changing object's maintenance mode") from e - return {"failed": False, "changed": True} +class ActionModule(ADCMAnsiblePlugin): + executor_class = ADCMChangeMMExecutor diff --git a/python/ansible_plugin/base.py b/python/ansible_plugin/base.py index 171b47fdcd..2017691961 100644 --- a/python/ansible_plugin/base.py +++ b/python/ansible_plugin/base.py @@ -95,19 +95,21 @@ class RuntimeEnvironment(BaseModel): # Target -class CoreObjectTargetDescription(BaseModel): +class ObjectWithType(BaseModel): type: TargetTypeLiteral - service_name: str | None = None - component_name: str | None = None - host_id: int | str | None = None - @field_validator("type", mode="before") @classmethod def convert_type_to_string(cls, v: Any) -> str: # requited to pre-process Ansible Strings return str(v) + +class CoreObjectTargetDescription(ObjectWithType): + service_name: str | None = None + component_name: str | None = None + host_id: int | str | None = None + @model_validator(mode="after") def validate_args_allowed_for_type(self) -> Self: match self.type: diff --git a/python/ansible_plugin/executors/change_maintenance_mode.py b/python/ansible_plugin/executors/change_maintenance_mode.py new file mode 100644 index 0000000000..09b8b985dd --- /dev/null +++ b/python/ansible_plugin/executors/change_maintenance_mode.py @@ -0,0 +1,107 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from contextlib import suppress +from typing import Any, Collection + +from cm.issue import update_hierarchy_issues +from cm.models import Host, MaintenanceMode +from cm.services.status.notify import reset_objects_in_mm +from cm.status_api import send_object_update_event +from core.types import ADCMCoreType, CoreObjectDescriptor +from django.db.transaction import atomic +from pydantic import field_validator + +from ansible_plugin.base import ( + ADCMAnsiblePluginExecutor, + ArgumentsConfig, + CallResult, + ObjectWithType, + PluginExecutorConfig, + RuntimeEnvironment, + TargetConfig, + VarsContextSection, + retrieve_orm_object, +) +from ansible_plugin.errors import PluginIncorrectCallError + + +class ChangeMaintenanceModeArguments(ObjectWithType): + value: bool + + @field_validator("type") + @classmethod + def check_type_is_allowed(cls, v: str) -> str: + if v in ("service", "component", "host"): + return v + + message = f"`adcm_change_maintenance_mode` plugin can't be called to change {v}'s MM" + raise ValueError(message) + + +def from_context_based_on_type( + context_owner: CoreObjectDescriptor, # noqa: ARG001 + context: VarsContextSection, + parsed_arguments: Any, +): + if not isinstance(parsed_arguments, ChangeMaintenanceModeArguments): + return () + + return ( + CoreObjectDescriptor( + id=getattr(context, f"{parsed_arguments.type}_id"), type=ADCMCoreType(parsed_arguments.type) + ), + ) + + +class ADCMChangeMMExecutor(ADCMAnsiblePluginExecutor[ChangeMaintenanceModeArguments, None]): + _config = PluginExecutorConfig( + arguments=ArgumentsConfig(represent_as=ChangeMaintenanceModeArguments), + target=TargetConfig(detectors=(from_context_based_on_type,)), + ) + + def __call__( + self, + targets: Collection[CoreObjectDescriptor], + arguments: ChangeMaintenanceModeArguments, + runtime: RuntimeEnvironment, + ) -> CallResult[None]: + _ = runtime + + target, *_ = targets + target_object = retrieve_orm_object(object_=target) + value = MaintenanceMode.ON if arguments.value else MaintenanceMode.OFF + + if target_object.maintenance_mode != MaintenanceMode.CHANGING: + return CallResult( + value=None, + changed=False, + error=PluginIncorrectCallError( + f'Only "{MaintenanceMode.CHANGING}" state of object maintenance mode can be changed' + ), + ) + + with atomic(): + target_object.maintenance_mode = value + target_object.save( + update_fields=["maintenance_mode"] if isinstance(target_object, Host) else ["_maintenance_mode"] + ) + + update_hierarchy_issues(target_object.cluster) + + with suppress(Exception): + send_object_update_event(object_=target_object, changes={"maintenanceMode": target_object.maintenance_mode}) + + with suppress(Exception): + reset_objects_in_mm() + + return CallResult(value=None, changed=True, error=None) diff --git a/python/ansible_plugin/maintenance_mode.py b/python/ansible_plugin/maintenance_mode.py deleted file mode 100644 index 2efd887cc7..0000000000 --- a/python/ansible_plugin/maintenance_mode.py +++ /dev/null @@ -1,74 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from typing import Any, Literal - -from ansible.errors import AnsibleActionFail, AnsibleError -from cm.models import ClusterObject, Host, MaintenanceMode, ServiceComponent -from pydantic import BaseModel, ConfigDict, ValidationError, field_validator - -from ansible_plugin.utils import get_object_id_from_context - -TYPE_CLASS_MAP = { - "host": Host, - "service": ClusterObject, - "component": ServiceComponent, -} - - -class TaskArgs(BaseModel): - type: Literal["host", "service", "component"] - value: bool - model_config = ConfigDict(frozen=True) - - @field_validator("type", mode="before") - @classmethod - def convert_type_to_string(cls, v: Any) -> str: - # requited to pre-process Ansible Strings - return str(v) - - -def validate_args(task_args: dict) -> AnsibleActionFail | None: - try: - TaskArgs(**task_args) - except ValidationError as e: - return AnsibleActionFail(str(e)) - - -def validate_obj(obj: Host | ClusterObject | ServiceComponent) -> AnsibleActionFail | None: - if obj.maintenance_mode != MaintenanceMode.CHANGING: - return AnsibleActionFail(f'Only "{MaintenanceMode.CHANGING}" state of object maintenance mode can be changed') - - -def get_object( - task_vars: dict, obj_type: Literal["host", "service", "component"] -) -> tuple[Host | ClusterObject | ServiceComponent | None, None | AnsibleError]: - context_type = obj_type - if obj_type == "host": - context_type = "cluster" - - obj_pk, error = get_object_id_from_context( - task_vars=task_vars, - id_type=f"{obj_type}_id", - context_types=(context_type,), - err_msg=f'You can change "{obj_type}" maintenance mode only in {context_type} context', - raise_=False, - ) - if error: - return None, error - - obj_qs = TYPE_CLASS_MAP[obj_type].objects.filter(pk=obj_pk) - - if obj_qs.exists(): - return obj_qs.get(), None - - return None, AnsibleActionFail(f'Object of type "{obj_type}" with PK "{obj_pk}" does not exist') diff --git a/python/ansible_plugin/tests/test_adcm_change_maintenance_mode.py b/python/ansible_plugin/tests/test_adcm_change_maintenance_mode.py new file mode 100644 index 0000000000..a2986e78eb --- /dev/null +++ b/python/ansible_plugin/tests/test_adcm_change_maintenance_mode.py @@ -0,0 +1,129 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from cm.models import MaintenanceMode, ServiceComponent +from cm.services.job.run.repo import JobRepoImpl + +from ansible_plugin.errors import ( + PluginValidationError, +) +from ansible_plugin.executors.change_maintenance_mode import ADCMChangeMMExecutor +from ansible_plugin.tests.base import BaseTestEffectsOfADCMAnsiblePlugins + + +class TestEffectsOfADCMAnsiblePlugins(BaseTestEffectsOfADCMAnsiblePlugins): + def setUp(self) -> None: + super().setUp() + + self.service_1 = self.add_services_to_cluster(["service_1"], cluster=self.cluster).first() + self.component_1 = ServiceComponent.objects.filter(service=self.service_1).first() + + self.add_host_to_cluster(cluster=self.cluster, host=self.host_1) + self.add_host_to_cluster(cluster=self.cluster, host=self.host_2) + + def test_simple_call_success(self) -> None: + for object_, arguments, expected_mm in ( + (self.service_1, {"type": "service", "value": False}, MaintenanceMode.OFF), + (self.component_1, {"type": "component", "value": True}, MaintenanceMode.ON), + (self.host_1, {"type": "host", "value": True}, MaintenanceMode.ON), + ): + object_.maintenance_mode = MaintenanceMode.CHANGING + object_.save() + + with self.subTest(arguments["type"]): + task = self.prepare_task(owner=object_, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMChangeMMExecutor, + call_arguments=arguments, + call_context=job, + ) + + result = executor.execute() + self.assertIsNone(result.error) + + object_.refresh_from_db() + self.assertEqual(object_.maintenance_mode, expected_mm) + + def test_call_from_another_context_success(self) -> None: + self.service_1.maintenance_mode = MaintenanceMode.CHANGING + self.service_1.save() + self.component_1.maintenance_mode = MaintenanceMode.CHANGING + self.component_1.save() + self.host_1.maintenance_mode = MaintenanceMode.CHANGING + self.host_1.save() + + with self.subTest("component-from-host"): + task = self.prepare_task(owner=self.component_1, name="on_host", host=self.host_1) + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMChangeMMExecutor, + call_arguments=""" + type: component + value: yes + """, + call_context=job, + ) + + result = executor.execute() + self.assertIsNone(result.error) + + self.component_1.refresh_from_db() + self.assertEqual(self.component_1.maintenance_mode, MaintenanceMode.ON) + self.service_1.refresh_from_db() + self.assertEqual(self.service_1.maintenance_mode, MaintenanceMode.CHANGING) + self.host_1.refresh_from_db() + self.assertEqual(self.host_1.maintenance_mode, MaintenanceMode.CHANGING) + + with self.subTest("service-from-component"): + task = self.prepare_task(owner=self.component_1, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMChangeMMExecutor, + call_arguments=""" + type: service + value: no + """, + call_context=job, + ) + + result = executor.execute() + self.assertIsNone(result.error) + + self.service_1.refresh_from_db() + self.assertEqual(self.service_1.maintenance_mode, MaintenanceMode.OFF) + self.component_1.refresh_from_db() + self.assertEqual(self.component_1.maintenance_mode, MaintenanceMode.ON) + self.host_1.refresh_from_db() + self.assertEqual(self.host_1.maintenance_mode, MaintenanceMode.CHANGING) + + def test_incorrect_type_fail(self) -> None: + for type_ in ("cluster", "provider"): + with self.subTest(type_): + task = self.prepare_task(owner=self.component_1, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + executor = self.prepare_executor( + executor_type=ADCMChangeMMExecutor, + call_arguments=f""" + type: {type_} + value: true + """, + call_context=job, + ) + + result = executor.execute() + + self.assertIsInstance(result.error, PluginValidationError) + self.assertIn(f"plugin can't be called to change {type_}'s MM", result.error.message) diff --git a/python/ansible_plugin/utils.py b/python/ansible_plugin/utils.py index 58d2c4db31..648c08ae72 100644 --- a/python/ansible_plugin/utils.py +++ b/python/ansible_plugin/utils.py @@ -19,12 +19,6 @@ from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType -from ansible_plugin.messages import ( - MSG_NO_CONFIG, - MSG_NO_CONTEXT, - MSG_WRONG_CONTEXT, - MSG_WRONG_CONTEXT_ID, -) from cm.adcm_config.config import get_option_value from cm.models import ( CheckLog, @@ -40,46 +34,6 @@ # isort: on -def check_context_type(task_vars: dict, context_types: tuple, err_msg: str | None = None) -> None: - """ - Check context type. Check if inventory.json and config.json were passed - and check if `context` exists in task variables, сheck if a context is of a given type. - """ - if not task_vars: - raise AnsibleError(MSG_NO_CONFIG) - - if "context" not in task_vars: - raise AnsibleError(MSG_NO_CONTEXT) - - if not isinstance(task_vars["context"], dict): - raise AnsibleError(MSG_NO_CONTEXT) - - context = task_vars["context"] - if context["type"] not in context_types: - if err_msg is None: - err_msg = MSG_WRONG_CONTEXT.format(", ".join(context_types), context["type"]) - raise AnsibleError(err_msg) - - -def get_object_id_from_context( - task_vars: dict, id_type: str, context_types: tuple, err_msg: str | None = None, raise_: bool = True -) -> tuple[int | None, None | AnsibleError]: - """ - Get object id from context. - """ - check_context_type(task_vars=task_vars, context_types=context_types, err_msg=err_msg) - context = task_vars["context"] - - if id_type not in context: - error = AnsibleError(MSG_WRONG_CONTEXT_ID.format(id_type)) - if raise_: - raise error - - return None, error - - return context[id_type], None - - # Helper functions for ansible plugins diff --git a/python/cm/services/maintenance_mode.py b/python/cm/services/maintenance_mode.py index 0ae38ee20e..9f044da54f 100644 --- a/python/cm/services/maintenance_mode.py +++ b/python/cm/services/maintenance_mode.py @@ -178,11 +178,3 @@ def get_maintenance_mode_response( data={"error": f'Unknown {obj_name} maintenance mode "{obj.maintenance_mode}"'}, status=HTTP_400_BAD_REQUEST, ) - - -def set_maintenance_mode(obj: ClusterObject | ServiceComponent | Host, value: MaintenanceMode) -> None: - obj.maintenance_mode = value - obj.save(update_fields=["maintenance_mode"] if isinstance(obj, Host) else ["_maintenance_mode"]) - send_object_update_event(object_=obj, changes={"maintenanceMode": obj.maintenance_mode}) - update_hierarchy_issues(obj.cluster) - reset_objects_in_mm() diff --git a/python/cm/tests/test_ansible_plugins/__init__.py b/python/cm/tests/test_ansible_plugins/__init__.py deleted file mode 100644 index f3e59d81d8..0000000000 --- a/python/cm/tests/test_ansible_plugins/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - diff --git a/python/cm/tests/test_ansible_plugins/test_maintenance_mode.py b/python/cm/tests/test_ansible_plugins/test_maintenance_mode.py deleted file mode 100644 index 55a99e0e28..0000000000 --- a/python/cm/tests/test_ansible_plugins/test_maintenance_mode.py +++ /dev/null @@ -1,109 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from itertools import chain, product -from pathlib import Path - -from adcm.tests.base import BaseTestCase, BusinessLogicMixin -from ansible_plugin.maintenance_mode import TYPE_CLASS_MAP, get_object, validate_args, validate_obj - -from cm.models import MaintenanceMode - - -class TestMaintenanceModePlugin(BusinessLogicMixin, BaseTestCase): - def setUp(self) -> None: - self.client.login(username="admin", password="admin") - bundles_dir = Path(__file__).parent.parent / "bundles" - - cluster_bundle = self.add_bundle(source_dir=bundles_dir / "cluster_1") - self.cluster = self.add_cluster(bundle=cluster_bundle, name="test_cluster") - self.service = self.add_services_to_cluster(service_names=["service_one_component"], cluster=self.cluster).get() - self.component = self.service.servicecomponent_set.get(prototype__name="component_1") - - provider_bundle = self.add_bundle(source_dir=bundles_dir / "provider") - provider = self.add_provider(bundle=provider_bundle, name="test_provider") - self.host = self.add_host(bundle=provider_bundle, provider=provider, fqdn="test_host", cluster=self.cluster) - self.set_hostcomponent(cluster=self.cluster, entries=[(self.host, self.component)]) - - def _set_objects_mm( - self, - host: MaintenanceMode = MaintenanceMode.OFF, - service: MaintenanceMode = MaintenanceMode.OFF, - component: MaintenanceMode = MaintenanceMode.OFF, - ) -> None: - for obj, mm_value in zip((self.host, self.service, self.component), (host, service, component)): - obj.maintenance_mode = mm_value - obj.save() - - def test_task_args_validation(self): - correct_types = tuple(TYPE_CLASS_MAP.keys()) - correct_values = (True, False) - wrong_types = ("cluster", "provider", "some_string", 1.3, None) - wrong_values = (8, None, []) - - for assert_func, type_value_pairs in ( - (self.assertIsNone, product(correct_types, correct_values)), - ( - self.assertIsNotNone, - chain( - product(wrong_types, wrong_values), - product(wrong_types, correct_values), - product(correct_types, wrong_values), - ), - ), - ): - for type_, value_ in type_value_pairs: - args = {"type": type_, "value": value_} - with self.subTest(args): - error = validate_args(task_args=args) - assert_func(error) - - def test_object_validation(self): - correct_values = (MaintenanceMode.CHANGING,) - wrong_values = (MaintenanceMode.ON, MaintenanceMode.OFF) - object_type_pairs = ((self.host, "host"), (self.service, "service"), (self.component, "component")) - - for assert_func, mm_states in ((self.assertIsNone, correct_values), (self.assertIsNotNone, wrong_values)): - for mm_state, object_type_pair in product(mm_states, object_type_pairs): - object_, type_ = object_type_pair - self._set_objects_mm(**{type_: mm_state}) - with self.subTest(f"{object_.__class__.__name__} with mm `{object_.maintenance_mode}`"): - error = validate_obj(obj=object_) - assert_func(error) - - def test_object_getting(self): - test_data = { - "host": { - "context": { - "type": "cluster", - "host_id": self.host.pk, - } - }, - "service": { - "context": { - "type": "service", - "service_id": self.service.pk, - } - }, - "component": { - "context": { - "type": "component", - "component_id": self.component.pk, - } - }, - } - for type_, task_vars in test_data.items(): - with self.subTest(type_): - object_, error = get_object(task_vars=task_vars, obj_type=type_) - self.assertIsNotNone(object_) - self.assertIsInstance(object_, TYPE_CLASS_MAP[type_]) - self.assertIsNone(error) From 4869e0310054b0022431feb30e458aca28853c1a Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Mon, 10 Jun 2024 07:19:49 +0000 Subject: [PATCH 164/208] ADCM-5643: ansible config API --- python/api_v2/cluster/serializers.py | 44 ++++++++++- python/api_v2/cluster/views.py | 113 +++++++++++++++++++++++++++ python/api_v2/tests/test_cluster.py | 48 ++++++++++++ 3 files changed, 204 insertions(+), 1 deletion(-) diff --git a/python/api_v2/cluster/serializers.py b/python/api_v2/cluster/serializers.py index f9b445acc1..22ea3da735 100644 --- a/python/api_v2/cluster/serializers.py +++ b/python/api_v2/cluster/serializers.py @@ -13,6 +13,7 @@ from adcm.serializers import EmptySerializer from cm.adcm_config.config import get_main_info from cm.models import ( + AnsibleConfig, Cluster, ClusterObject, Host, @@ -24,11 +25,12 @@ from cm.validators import ClusterUniqueValidator, StartMidEndValidator from django.conf import settings from drf_spectacular.utils import extend_schema_field -from rest_framework.fields import CharField, IntegerField +from rest_framework.fields import CharField, DictField, IntegerField from rest_framework.serializers import ( BooleanField, ModelSerializer, SerializerMethodField, + ValidationError, ) from api_v2.cluster.utils import get_depend_on @@ -195,3 +197,43 @@ class ClusterStatusSerializer(WithStatusSerializer): class Meta: model = Cluster fields = ["id", "name", "state", "status"] + + +class AnsibleConfigUpdateSerializer(EmptySerializer): + config = DictField(write_only=True) + + @staticmethod + def validate_config(value: dict) -> dict: + if set(value) != {"defaults"}: + raise ValidationError("Only `defaults` section can be modified") + + defaults = value["defaults"] + + if set(defaults) != {"forks"}: + raise ValidationError("Only `defaults.forks` parameter can be modified") + + if not isinstance(defaults["forks"], int) or defaults["forks"] < 1: + raise ValidationError("`defaults.forks` parameter must be an integer greater than 0") + + defaults["forks"] = str(defaults["forks"]) + value["defaults"] = defaults + + return value + + +class AnsibleConfigRetrieveSerializer(ModelSerializer): + config = DictField(source="value", read_only=True) + adcm_meta = SerializerMethodField(read_only=True) + + class Meta: + model = AnsibleConfig + fields = ["config", "adcm_meta"] + + def get_adcm_meta(self, instance: AnsibleConfig) -> dict: # noqa: ARG002 + return {} + + def to_representation(self, instance: AnsibleConfig) -> dict: + data = super().to_representation(instance=instance) + data["config"]["defaults"]["forks"] = int(data["config"]["defaults"]["forks"]) + + return data diff --git a/python/api_v2/cluster/views.py b/python/api_v2/cluster/views.py index 12a9de1960..e530294003 100644 --- a/python/api_v2/cluster/views.py +++ b/python/api_v2/cluster/views.py @@ -23,6 +23,7 @@ from cm.errors import AdcmEx from cm.issue import update_hierarchy_issues from cm.models import ( + AnsibleConfig, Cluster, ClusterObject, ConcernType, @@ -32,6 +33,7 @@ Prototype, ServiceComponent, ) +from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from guardian.mixins import PermissionListMixin from guardian.shortcuts import get_objects_for_user @@ -43,7 +45,9 @@ HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT, + HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, HTTP_409_CONFLICT, ) @@ -55,6 +59,8 @@ ) from api_v2.cluster.permissions import ClusterPermissions from api_v2.cluster.serializers import ( + AnsibleConfigRetrieveSerializer, + AnsibleConfigUpdateSerializer, ClusterCreateSerializer, ClusterSerializer, ClusterUpdateSerializer, @@ -416,3 +422,110 @@ def mapping_components(self, request: Request, *args, **kwargs): # noqa: ARG002 ) return Response(status=HTTP_200_OK, data=serializer.data) + + @extend_schema( + methods=["get"], + operation_id="getClusterAnsibleConfigs", + summary="GET cluster ansible configuration", + description="Get information about cluster ansible config.", + responses={ + HTTP_200_OK: AnsibleConfigRetrieveSerializer, + HTTP_403_FORBIDDEN: ErrorSerializer, + HTTP_404_NOT_FOUND: ErrorSerializer, + }, + ) + @extend_schema( + methods=["post"], + operation_id="postClusterAnsibleConfigs", + summary="POST cluster ansible config", + description="Create ansible configuration.", + request=AnsibleConfigUpdateSerializer, + responses={ + HTTP_201_CREATED: AnsibleConfigRetrieveSerializer, + HTTP_400_BAD_REQUEST: ErrorSerializer, + HTTP_403_FORBIDDEN: ErrorSerializer, + HTTP_404_NOT_FOUND: ErrorSerializer, + HTTP_409_CONFLICT: ErrorSerializer, + }, + ) + @action(methods=["get", "post"], detail=True, pagination_class=None, filter_backends=[], url_path="ansible-config") + def ansible_config(self, request: Request, *args, **kwargs): # noqa: ARG002 + cluster = self.get_object() + ansible_config = AnsibleConfig.objects.get( + object_id=cluster.pk, object_type=ContentType.objects.get_for_model(model=cluster) + ) + + if request.method.lower() == "get": + # TODO: uncomment/refactor after ADCM-5642 + # check_custom_perm(user=request.user, action_type="view_ansible_config_of", model="cluster", obj=cluster) + + return Response(status=HTTP_200_OK, data=AnsibleConfigRetrieveSerializer(instance=ansible_config).data) + + # TODO: uncomment/refactor after ADCM-5642 + # check_custom_perm(user=request.user, action_type="change_ansible_config_of", model="cluster", obj=cluster) + serializer = AnsibleConfigUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + ansible_config.value = serializer.validated_data["config"] + ansible_config.save(update_fields=["value"]) + + return Response(status=HTTP_201_CREATED, data=AnsibleConfigRetrieveSerializer(instance=ansible_config).data) + + @extend_schema( + methods=["get"], + operation_id="getClusterAnsibleConfigs", + summary="GET cluster ansible configuration", + description="Get information about cluster ansible config.", + responses={ + HTTP_200_OK: dict, + HTTP_404_NOT_FOUND: ErrorSerializer, + }, + ) + @action(methods=["get"], detail=True, pagination_class=None, filter_backends=[], url_path="ansible-config-schema") + def ansible_config_schema(self, request: Request, *args, **kwargs): # noqa: ARG002 + adcm_meta_part = { + "isAdvanced": False, + "isInvisible": False, + "activation": None, + "synchronization": None, + "NoneValue": None, + "isSecret": False, + "stringExtra": None, + "enumExtra": None, + } + schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Ansible configuration", + "description": "", + "readOnly": False, + "adcmMeta": adcm_meta_part, + "type": "object", + "properties": { + "defaults": { + "title": "defaults", + "type": "object", + "description": "", + "default": {}, + "readOnly": False, + "adcmMeta": adcm_meta_part, + "additionalProperties": False, + "properties": { + "forks": { + "title": "forks", + "type": "integer", + "description": "", + "default": 5, + "readOnly": False, + "adcmMeta": adcm_meta_part, + "minimum": 1, + }, + }, + }, + }, + "additionalProperties": False, + "required": [ + "defaults.forks", + ], + } + + return Response(status=HTTP_200_OK, data=schema) diff --git a/python/api_v2/tests/test_cluster.py b/python/api_v2/tests/test_cluster.py index 64c80f30d6..049b3af572 100644 --- a/python/api_v2/tests/test_cluster.py +++ b/python/api_v2/tests/test_cluster.py @@ -17,6 +17,7 @@ from cm.models import ( Action, ADCMEntityStatus, + AnsibleConfig, Cluster, ClusterObject, Host, @@ -27,6 +28,7 @@ from cm.services.status.client import FullStatusMap from cm.tests.mocks.task_runner import RunTaskMock from cm.tests.utils import gen_component, gen_host, gen_service, generate_hierarchy +from django.contrib.contenttypes.models import ContentType from guardian.models import GroupObjectPermission from rbac.models import User from rest_framework.status import ( @@ -342,6 +344,52 @@ def test_service_create_success(self): self.assertEqual(response.json()[0]["name"], service_prototype.name) self.assertEqual(ClusterObject.objects.get(cluster_id=self.cluster_1.pk).name, "service_1") + def test_retrieve_ansible_config_success(self): + expected_response = {"adcmMeta": {}, "config": {"defaults": {"forks": 5}}} + + response = self.client.v2[self.cluster_1, "ansible-config"].get() + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json(), expected_response) + + def test_retrieve_ansible_config_fail(self): + response = (self.client.v2 / "clusters" / str(self.get_non_existent_pk(model=Cluster)) / "ansible-config").get() + + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + + def test_update_ansible_config_success(self): + response = self.client.v2[self.cluster_1, "ansible-config"].post(data={"config": {"defaults": {"forks": 13}}}) + + self.assertEqual(response.status_code, HTTP_201_CREATED) + + ansible_config = AnsibleConfig.objects.get( + object_id=self.cluster_1.pk, + object_type=ContentType.objects.get_for_model(model=self.cluster_1), + ) + self.assertDictEqual(ansible_config.value, {"defaults": {"forks": "13"}}) + + def test_update_ansible_config_fail(self): + ansible_config = AnsibleConfig.objects.get( + object_id=self.cluster_1.pk, + object_type=ContentType.objects.get_for_model(model=self.cluster_1), + ) + + for value in ( + {"defaults": {"forks": 0}}, + {"defaults": {"forks": "13"}}, + {"defaults": {"forks": "13.0"}}, + {"defaults": {"forks": 13, "stdout_callback": "not_yaml"}}, + {"defaults": {"not_forks": "not_13"}}, + {"defaults": {}}, + {"not_defaults": {}}, + ): + with self.subTest(value=value): + response = self.client.v2[self.cluster_1, "ansible-config"].post(data={"config": value}) + + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + ansible_config.refresh_from_db() + self.assertDictEqual(ansible_config.value, {"defaults": {"forks": "5"}}) + class TestClusterActions(BaseAPITestCase): def setUp(self) -> None: From 6482c58e979cbdb192bf4af1c7ec9fbe798c344a Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Mon, 10 Jun 2024 07:22:53 +0000 Subject: [PATCH 165/208] ADCM-5651 Fix false "non-changed" config plugin call --- python/ansible/plugins/lookup/adcm_config.py | 90 ++++++-------------- python/ansible_plugin/executors/config.py | 57 +++++-------- 2 files changed, 47 insertions(+), 100 deletions(-) diff --git a/python/ansible/plugins/lookup/adcm_config.py b/python/ansible/plugins/lookup/adcm_config.py index c9861fb6ff..33b826e2c4 100644 --- a/python/ansible/plugins/lookup/adcm_config.py +++ b/python/ansible/plugins/lookup/adcm_config.py @@ -22,7 +22,6 @@ import adcm.init_django # noqa: F401, isort:skip from ansible_plugin.utils import cast_to_type, get_service_by_name -from cm.adcm_config.ansible import ansible_decrypt from cm.api import set_object_config_with_plugin from cm.logger import logger from cm.models import ( @@ -79,7 +78,6 @@ def run(self, terms, variables=None, **kwargs): raise AnsibleError(msg.format(len(terms))) conf = {terms[1]: terms[2]} - attr = {} if terms[0] == "service": if "cluster" not in variables: @@ -87,11 +85,11 @@ def run(self, terms, variables=None, **kwargs): cluster = variables["cluster"] if "service_name" in kwargs: res = set_service_config_by_name( - cluster_id=cluster["id"], service_name=kwargs["service_name"], config=conf, attr=attr + cluster_id=cluster["id"], service_name=kwargs["service_name"], config=conf ) elif "job" in variables and "service_id" in variables["job"]: res = set_service_config( - cluster_id=cluster["id"], service_id=variables["job"]["service_id"], config=conf, attr=attr + cluster_id=cluster["id"], service_id=variables["job"]["service_id"], config=conf ) else: msg = "no service_id in job or service_name and service_version in params" @@ -100,16 +98,16 @@ def run(self, terms, variables=None, **kwargs): if "cluster" not in variables: raise AnsibleError("there is no cluster in hostvars") cluster = variables["cluster"] - res = set_cluster_config(cluster_id=cluster["id"], config=conf, attr=attr) + res = set_cluster_config(cluster_id=cluster["id"], config=conf) elif terms[0] == "provider": if "provider" not in variables: raise AnsibleError("there is no host provider in hostvars") provider = variables["provider"] - res = set_provider_config(provider_id=provider["id"], config=conf, attr=attr) + res = set_provider_config(provider_id=provider["id"], config=conf) elif terms[0] == "host": if "adcm_hostid" not in variables: raise AnsibleError("there is no adcm_hostid in hostvars") - res = set_host_config(host_id=variables["adcm_hostid"], config=conf, attr=attr) + res = set_host_config(host_id=variables["adcm_hostid"], config=conf) else: raise AnsibleError(f"unknown object type: {terms[0]}") @@ -122,12 +120,14 @@ class PluginResult(NamedTuple): changed: bool -def update_config(obj: ADCMEntity, conf: dict, attr: dict) -> PluginResult: +def update_config(obj: ADCMEntity, conf: dict) -> PluginResult: config_log = ConfigLog.objects.get(id=obj.config.current) new_config = deepcopy(config_log.config) new_attr = deepcopy(config_log.attr) if config_log.attr is not None else {} + changed = False + for keys, value in conf.items(): keys_list = keys.split("/") key = keys_list[0] @@ -142,34 +142,23 @@ def update_config(obj: ADCMEntity, conf: dict, attr: dict) -> PluginResult: ) except PrototypeConfig.DoesNotExist as error: raise AnsibleError(f"Config parameter '{key}/{subkey}' does not exist") from error - new_config[key][subkey] = cast_to_type( - field_type=prototype_conf.type, value=value, limits=prototype_conf.limits - ) + + cast_value = cast_to_type(field_type=prototype_conf.type, value=value, limits=prototype_conf.limits) + if new_config[key][subkey] != cast_value: + new_config[key][subkey] = cast_value + changed = True else: try: prototype_conf = PrototypeConfig.objects.get(name=key, subname="", prototype=obj.prototype, action=None) except PrototypeConfig.DoesNotExist as error: raise AnsibleError(f"Config parameter '{key}' does not exist") from error - new_config[key] = cast_to_type(field_type=prototype_conf.type, value=value, limits=prototype_conf.limits) - - if key in attr: - prototype_conf = PrototypeConfig.objects.filter( - name=key, prototype=obj.prototype, type="group", action=None - ) - if not prototype_conf or "activatable" not in prototype_conf.first().limits: - raise AnsibleError("'active' key should be used only with activatable group") + cast_value = cast_to_type(field_type=prototype_conf.type, value=value, limits=prototype_conf.limits) + if new_config[key] != cast_value: + new_config[key] = cast_value + changed = True - new_attr.update(attr) - - for key in attr: - for subkey, value in config_log.config[key].items(): - if not new_config[key] or subkey not in new_config[key]: - new_config[key][subkey] = value - - if _does_contain(base_dict=config_log.config, part=new_config) and _does_contain( - base_dict=config_log.attr, part=new_attr - ): + if not changed: return PluginResult(conf, False) set_object_config_with_plugin(obj=obj, config=new_config, attr=new_attr) @@ -181,54 +170,31 @@ def update_config(obj: ADCMEntity, conf: dict, attr: dict) -> PluginResult: return PluginResult(conf, True) -def set_cluster_config(cluster_id: int, config: dict, attr: dict) -> PluginResult: +def set_cluster_config(cluster_id: int, config: dict) -> PluginResult: obj = Cluster.obj.get(id=cluster_id) - return update_config(obj=obj, conf=config, attr=attr) + return update_config(obj=obj, conf=config) -def set_host_config(host_id: int, config: dict, attr: dict) -> PluginResult: +def set_host_config(host_id: int, config: dict) -> PluginResult: obj = Host.obj.get(id=host_id) - return update_config(obj=obj, conf=config, attr=attr) + return update_config(obj=obj, conf=config) -def set_provider_config(provider_id: int, config: dict, attr: dict) -> PluginResult: +def set_provider_config(provider_id: int, config: dict) -> PluginResult: obj = HostProvider.obj.get(id=provider_id) - return update_config(obj=obj, conf=config, attr=attr) + return update_config(obj=obj, conf=config) -def set_service_config_by_name(cluster_id: int, service_name: str, config: dict, attr: dict) -> PluginResult: +def set_service_config_by_name(cluster_id: int, service_name: str, config: dict) -> PluginResult: obj = get_service_by_name(cluster_id, service_name) - return update_config(obj=obj, conf=config, attr=attr) + return update_config(obj=obj, conf=config) -def set_service_config(cluster_id: int, service_id: int, config: dict, attr: dict) -> PluginResult: +def set_service_config(cluster_id: int, service_id: int, config: dict) -> PluginResult: obj = ClusterObject.obj.get(id=service_id, cluster__id=cluster_id, prototype__type="service") - return update_config(obj=obj, conf=config, attr=attr) - - -def _does_contain(base_dict: dict, part: dict) -> bool: - """ - Check fields in `part` have the same value in `base_dict` - """ - - for key, val2 in part.items(): - if key not in base_dict: - return False - - val1 = base_dict[key] - - if isinstance(val1, dict) and isinstance(val2, dict): - if not _does_contain(val1, val2): - return False - else: - val1 = ansible_decrypt(val1) - val2 = ansible_decrypt(val2) - if val1 != val2: - return False - - return True + return update_config(obj=obj, conf=config) diff --git a/python/ansible_plugin/executors/config.py b/python/ansible_plugin/executors/config.py index 308e1d27e2..11fbef2538 100644 --- a/python/ansible_plugin/executors/config.py +++ b/python/ansible_plugin/executors/config.py @@ -10,11 +10,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -from collections import defaultdict -from copy import deepcopy from typing import Any, Collection, TypeAlias, TypedDict -from cm.adcm_config.ansible import ansible_decrypt from cm.api import set_object_config_with_plugin from cm.converters import core_type_to_model from cm.models import ConfigLog @@ -136,11 +133,9 @@ def __call__( configuration = ConfigAttrPair(**ConfigLog.objects.values("config", "attr").get(id=db_object.config.current)) spec = next(iter(retrieve_flat_spec_for_objects(prototypes=(db_object.prototype_id,)).values())) - original_values = _fill_config_and_attr(target=configuration, changes=changes, spec=spec) + changed = _fill_config_and_attr(target=configuration, changes=changes, spec=spec) - if _does_contain(base_dict=configuration.config, part=original_values.config) and _does_contain( - base_dict=configuration.attr, part=original_values.attr - ): + if not changed: return CallResult(value=return_value, changed=False, error=None) set_object_config_with_plugin(obj=db_object, config=configuration.config, attr=configuration.attr) @@ -164,14 +159,14 @@ def _prepare_return_value(config: dict) -> ChangeConfigReturn: return ChangeConfigReturn(value=config_params) -def _fill_config_and_attr(target: ConfigAttrPair, changes: ConfigAttrPair, spec: FlatSpec) -> OriginalValues: +def _fill_config_and_attr(target: ConfigAttrPair, changes: ConfigAttrPair, spec: FlatSpec) -> bool: """ Fill `target` with values from `changes` in-place :param target: Values for complex structures are nested (e.g. ["groupkey"]["valingorupkey"]) :param changes: Keys must have the same format as flatspec (e.g. ["groupkey/subgroupkey"]) :param spec: Flat specification for the changing config - :returns: Original values (from `target`) of keys that was changed for further checks + :returns: Changed flag that's true if either param in config/attr has changed """ keys_to_change = set(changes.config.keys()) | set(changes.attr.keys()) @@ -179,8 +174,7 @@ def _fill_config_and_attr(target: ConfigAttrPair, changes: ConfigAttrPair, spec: message = f"Some keys aren't presented in specification: {', '.join(sorted(missing_keys))}" raise PluginIncorrectCallError(message=message) - original_fields = defaultdict(dict) - original_attrs = {} + changed = False for key, value in changes.config.items(): param_spec = spec[key] @@ -190,11 +184,17 @@ def _fill_config_and_attr(target: ConfigAttrPair, changes: ConfigAttrPair, spec: subkey = subs[0] if subs else None if subkey: - original_fields[key][subkey] = target.config[key][subkey] + if target.config[key][subkey] == cast_value: + continue + target.config[key][subkey] = cast_value + changed = True else: - original_fields[key] = target.config[key] + if target.config[key] == cast_value: + continue + target.config[key] = cast_value + changed = True # here we consider key full key for key, value in changes.attr.items(): @@ -206,30 +206,11 @@ def _fill_config_and_attr(target: ConfigAttrPair, changes: ConfigAttrPair, spec: raise PluginIncorrectCallError(message=message) attr_key = key.rstrip("/") - original_attrs[attr_key] = deepcopy(target.attr[attr_key]) - target.attr[attr_key] |= value - - return OriginalValues(config=original_fields, attr=original_attrs) - - -def _does_contain(base_dict: dict, part: dict) -> bool: - """ - Check fields in `part` have the same value in `base_dict` - """ - - for key, val2 in part.items(): - if key not in base_dict: - return False + # we want to directly compare "active"'s since it's the only thing we may change via plugin + if target.attr[attr_key].get("active") == value["active"]: + continue - val1 = base_dict[key] - - if isinstance(val1, dict) and isinstance(val2, dict): - if not _does_contain(val1, val2): - return False - else: - val1 = ansible_decrypt(val1) - val2 = ansible_decrypt(val2) - if val1 != val2: - return False + target.attr[attr_key] |= value + changed = True - return True + return changed From 928a656c766272fe08fd5a9771a6324fe9c7a556 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Mon, 10 Jun 2024 08:27:16 +0000 Subject: [PATCH 166/208] ADCM-5648 Action Host Groups Changed: 1. `run_action` restructured for achieving more clarity 2. Added validation for targeting host action and action host group at the start of `run_action` Added: 1. `ActionHostGroup` model 2. `allow_for_action_host_group` parameter for `Action` 3. Basic operations on `ActionHostGroup` via same named service --- .../migrations/0007_action_host_group.py | 46 +++ python/audit/models.py | 1 + python/audit/utils.py | 14 +- python/cm/adcm_schema.yaml | 1 + python/cm/bundle.py | 3 + python/cm/converters.py | 27 +- python/cm/issue.py | 4 +- .../cm/migrations/0126_action_host_group.py | 53 ++++ python/cm/models.py | 14 +- python/cm/services/action_host_group.py | 85 ++++++ python/cm/services/job/action.py | 284 +++++++++++------- python/cm/services/job/inventory/_base.py | 44 ++- python/cm/services/job/jinja_scripts.py | 21 +- python/cm/services/job/prepare.py | 4 +- .../cm/services/job/run/_target_factories.py | 6 +- python/cm/services/job/run/_task.py | 9 +- .../cm/services/job/run/_task_finalizers.py | 10 +- python/cm/services/job/run/repo.py | 41 ++- python/cm/services/job/run/runners.py | 11 +- python/cm/stack.py | 12 +- .../action_host_group/correct/config.yaml | 35 +++ .../negative/in_upgrade/config.yaml | 22 ++ .../negative/with_host_action/config.yaml | 12 + .../bundles/cluster_full_config/config.yaml | 3 + python/cm/tests/test_action_host_group.py | 211 +++++++++++++ python/core/job/repo.py | 4 +- python/core/job/task.py | 4 +- python/core/job/types.py | 4 +- python/core/types.py | 24 +- python/rbac/roles.py | 8 +- 30 files changed, 838 insertions(+), 179 deletions(-) create mode 100644 python/audit/migrations/0007_action_host_group.py create mode 100644 python/cm/migrations/0126_action_host_group.py create mode 100644 python/cm/services/action_host_group.py create mode 100644 python/cm/tests/bundles/action_host_group/correct/config.yaml create mode 100644 python/cm/tests/bundles/action_host_group/negative/in_upgrade/config.yaml create mode 100644 python/cm/tests/bundles/action_host_group/negative/with_host_action/config.yaml create mode 100644 python/cm/tests/test_action_host_group.py diff --git a/python/audit/migrations/0007_action_host_group.py b/python/audit/migrations/0007_action_host_group.py new file mode 100644 index 0000000000..64b870efc7 --- /dev/null +++ b/python/audit/migrations/0007_action_host_group.py @@ -0,0 +1,46 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated by Django 3.2.23 on 2024-06-06 10:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("audit", "0006_add_address"), + ] + + operations = [ + migrations.AlterField( + model_name="auditobject", + name="object_type", + field=models.CharField( + choices=[ + ("prototype", "prototype"), + ("cluster", "cluster"), + ("service", "service"), + ("component", "component"), + ("host", "host"), + ("provider", "provider"), + ("bundle", "bundle"), + ("adcm", "adcm"), + ("user", "user"), + ("group", "group"), + ("role", "role"), + ("policy", "policy"), + ("actionhostgroup", "actionhostgroup"), + ], + max_length=2000, + ), + ), + ] diff --git a/python/audit/models.py b/python/audit/models.py index 4e6f0d4fe5..9426d2982d 100644 --- a/python/audit/models.py +++ b/python/audit/models.py @@ -51,6 +51,7 @@ class AuditObjectType(TextChoices): GROUP = "group", "group" ROLE = "role", "role" POLICY = "policy", "policy" + ACTION_HOST_GROUP = "actionhostgroup", "actionhostgroup" class AuditLogOperationType(TextChoices): diff --git a/python/audit/utils.py b/python/audit/utils.py index 6ca5da2a34..60a349cada 100644 --- a/python/audit/utils.py +++ b/python/audit/utils.py @@ -51,7 +51,7 @@ get_model_by_type, ) from core.job.types import ExecutionStatus -from core.types import ADCMCoreType, NamedCoreObject +from core.types import ADCMCoreType, NamedActionObject from django.contrib.auth.models import User as DjangoUser from django.core.handlers.wsgi import WSGIRequest from django.db.models import Model, ObjectDoesNotExist @@ -623,17 +623,19 @@ def get_client_ip(request: WSGIRequest) -> str | None: return host -def audit_job_finish(owner: NamedCoreObject, display_name: str, is_upgrade: bool, job_result: ExecutionStatus) -> None: +def audit_job_finish( + target: NamedActionObject, display_name: str, is_upgrade: bool, job_result: ExecutionStatus +) -> None: operation_name = f"{display_name} {'upgrade' if is_upgrade else 'action'} completed" - if owner.type == ADCMCoreType.HOSTPROVIDER: + if target.type == ADCMCoreType.HOSTPROVIDER: obj_type = AuditObjectType.PROVIDER else: - obj_type = AuditObjectType(owner.type.value) + obj_type = AuditObjectType(target.type.value) audit_object = get_or_create_audit_obj( - object_id=str(owner.id), - object_name=owner.name, + object_id=str(target.id), + object_name=target.name, object_type=obj_type, ) operation_result = ( diff --git a/python/cm/adcm_schema.yaml b/python/cm/adcm_schema.yaml index e76f4d6f73..1a812b0a05 100644 --- a/python/cm/adcm_schema.yaml +++ b/python/cm/adcm_schema.yaml @@ -753,6 +753,7 @@ common_action: allow_to_terminate: boolean partial_execution: boolean host_action: boolean + allow_for_action_host_group: boolean log_files: list_of_string states: action_states_dict masking: action_masking_or_none diff --git a/python/cm/bundle.py b/python/cm/bundle.py index 343ee6ccc5..1ccec4c668 100644 --- a/python/cm/bundle.py +++ b/python/cm/bundle.py @@ -841,6 +841,7 @@ def copy_stage_actions(stage_actions, prototype): "allow_to_terminate", "partial_execution", "host_action", + "allow_for_action_host_group", "venv", "allow_in_maintenance_mode", "config_jinja", @@ -1110,6 +1111,7 @@ def update_bundle_from_stage(bundle): "allow_to_terminate", "partial_execution", "host_action", + "allow_for_action_host_group", "venv", "allow_in_maintenance_mode", ), @@ -1140,6 +1142,7 @@ def update_bundle_from_stage(bundle): "allow_to_terminate", "partial_execution", "host_action", + "allow_for_action_host_group", "venv", "allow_in_maintenance_mode", ), diff --git a/python/cm/converters.py b/python/cm/converters.py index 3a6c905fe9..900d2686b3 100644 --- a/python/cm/converters.py +++ b/python/cm/converters.py @@ -10,15 +10,17 @@ # See the License for the specific language governing permissions and # limitations under the License. -from core.types import ADCMCoreType +from typing import TypeAlias + +from core.types import ADCMCoreType, ExtraActionTargetType from django.db.models import Model -from cm.models import ADCM, Cluster, ClusterObject, Host, HostProvider, ServiceComponent +from cm.models import ADCM, ActionHostGroup, Cluster, ClusterObject, Host, HostProvider, ServiceComponent + +CoreObject: TypeAlias = Cluster | ClusterObject | ServiceComponent | HostProvider | Host -def core_type_to_model( - core_type: ADCMCoreType, -) -> type[Cluster | ClusterObject | ServiceComponent | HostProvider | Host | ADCM]: +def core_type_to_model(core_type: ADCMCoreType) -> type[CoreObject | ADCM]: match core_type: case ADCMCoreType.CLUSTER: return Cluster @@ -79,8 +81,19 @@ def model_name_to_core_type(model_name: str) -> ADCMCoreType: def model_to_core_type(model: type[Model]) -> ADCMCoreType: - return model_name_to_core_type(model_name=model.__name__.lower()) + return model_name_to_core_type(model_name=model.__name__) -def orm_object_to_core_type(object_: Cluster | ClusterObject | ServiceComponent | HostProvider | Host) -> ADCMCoreType: +def orm_object_to_core_type(object_: CoreObject) -> ADCMCoreType: return model_to_core_type(model=object_.__class__) + + +def model_to_action_target_type(model: type[Model]) -> ADCMCoreType | ExtraActionTargetType: + if model == ActionHostGroup: + return ExtraActionTargetType.ACTION_HOST_GROUP + + return model_to_core_type(model=model) + + +def orm_object_to_action_target_type(object_: CoreObject | ActionHostGroup) -> ADCMCoreType | ExtraActionTargetType: + return model_to_action_target_type(model=object_.__class__) diff --git a/python/cm/issue.py b/python/cm/issue.py index ea940f4890..65e516fffc 100755 --- a/python/cm/issue.py +++ b/python/cm/issue.py @@ -535,11 +535,11 @@ def remove_concern_from_object(object_: ADCMEntity, concern: ConcernItem | None) ) -def lock_affected_objects(task: TaskLog, objects: Iterable[ADCMEntity]) -> None: +def lock_affected_objects(task: TaskLog, objects: Iterable[ADCMEntity], lock_target: ADCMEntity | None = None) -> None: if task.lock: return - owner: ADCMEntity = task.task_object + owner: ADCMEntity = lock_target or task.task_object first_job = JobLog.obj.filter(task=task).order_by("id").first() delete_service_action = settings.ADCM_DELETE_SERVICE_ACTION_NAME custom_name = delete_service_action if task.action.name == delete_service_action else "" diff --git a/python/cm/migrations/0126_action_host_group.py b/python/cm/migrations/0126_action_host_group.py new file mode 100644 index 0000000000..fdb2a6b894 --- /dev/null +++ b/python/cm/migrations/0126_action_host_group.py @@ -0,0 +1,53 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated by Django 3.2.23 on 2024-06-06 05:56 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("cm", "0125_simplify_defaults"), + ] + + operations = [ + migrations.AddField( + model_name="action", + name="allow_for_action_host_group", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="stageaction", + name="allow_for_action_host_group", + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name="ActionHostGroup", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("object_id", models.PositiveIntegerField()), + ("name", models.CharField(max_length=150)), + ("description", models.CharField(max_length=255)), + ("hosts", models.ManyToManyField(to="cm.Host")), + ( + "object_type", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype"), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/python/cm/models.py b/python/cm/models.py index 48d604c107..ec4df9bc80 100644 --- a/python/cm/models.py +++ b/python/cm/models.py @@ -752,6 +752,15 @@ def auto_delete_config_with_servicecomponent(sender, instance, **kwargs): # noq instance.config.delete() +class ActionHostGroup(ADCMModel): + object_id = models.PositiveIntegerField(null=False) + object_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=False) + object = GenericForeignKey("object_type", "object_id") + name = models.CharField(max_length=150) + description = models.CharField(max_length=255) + hosts = models.ManyToManyField(Host) + + class GroupConfig(ADCMModel): object_id = models.PositiveIntegerField() object_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) @@ -1020,6 +1029,7 @@ class AbstractAction(ADCMModel): allow_to_terminate = models.BooleanField(default=False) partial_execution = models.BooleanField(default=False) host_action = models.BooleanField(default=False) + allow_for_action_host_group = models.BooleanField(default=False) allow_in_maintenance_mode = models.BooleanField(default=False) config_jinja = models.CharField(max_length=1000, blank=True, null=True) @@ -1101,9 +1111,7 @@ def allowed(self, obj: ADCMEntity) -> bool: if ( self.multi_state_available == "any" or isinstance(self.multi_state_available, list) - and obj.has_multi_state_intersection( - self.multi_state_available, - ) + and obj.has_multi_state_intersection(self.multi_state_available) ): multi_state_allowed = True diff --git a/python/cm/services/action_host_group.py b/python/cm/services/action_host_group.py new file mode 100644 index 0000000000..8aa540dded --- /dev/null +++ b/python/cm/services/action_host_group.py @@ -0,0 +1,85 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import dataclass +from typing import Iterable, NamedTuple, TypeAlias + +from core.types import ADCMCoreType, CoreObjectDescriptor, HostID, ShortObjectInfo +from django.contrib.contenttypes.models import ContentType + +from cm.converters import core_type_to_model, model_name_to_core_type +from cm.models import ActionHostGroup, Host + +ActionHostGroupID: TypeAlias = int + + +@dataclass(slots=True) +class ActionTargetHostGroup: + owner: CoreObjectDescriptor + id: ActionHostGroupID + name: str + hosts: tuple[ShortObjectInfo, ...] + + +class CreateDTO(NamedTuple): + owner: CoreObjectDescriptor + name: str + description: str + + +class ActionHostGroupRepo: + @staticmethod + def create(dto: CreateDTO) -> ActionHostGroupID: + return ActionHostGroup.objects.create( + name=dto.name, + description=dto.description, + object_id=dto.owner.id, + object_type=ContentType.objects.get_for_model(core_type_to_model(dto.owner.type)), + ).id + + @staticmethod + def retrieve(id: ActionHostGroupID) -> ActionTargetHostGroup: # noqa: A002 + group = ActionHostGroup.objects.get(id=id) + owner = CoreObjectDescriptor(id=group.object_id, type=model_name_to_core_type(group.object_type.model)) + + hosts_qs = group.hosts_set.values_list("id", flat=True) + hosts = tuple(map(ShortObjectInfo, Host.objects.values_list("id", "fqdn").filter(id__in=hosts_qs))) + + return ActionTargetHostGroup(id=group.id, name=group.name, owner=owner, hosts=hosts) + + @staticmethod + def reset_hosts(id: ActionHostGroupID, hosts: Iterable[HostID]) -> None: # noqa: A002 + # todo optimize requests + m2m_model = ActionHostGroup.hosts.through + m2m_model.objects.filter(actionhostgroup_id=id).delete() + m2m_model.objects.bulk_create(objs=(m2m_model(actionhostgroup_id=id, host_id=host_id) for host_id in hosts)) + + +class ActionHostGroupService: + __slots__ = ("_repo",) + + def __init__(self, repository: ActionHostGroupRepo): + self._repo = repository + + def create(self, dto: CreateDTO) -> ActionHostGroupID: + if dto.owner.type == ADCMCoreType.HOST: + message = "Action groups for host owner aren't supported" + raise TypeError(message) + + return self._repo.create(dto=dto) + + def retrieve(self, group_id: ActionHostGroupID) -> ActionTargetHostGroup: + return self._repo.retrieve(id=group_id) + + def set_hosts(self, group_id: ActionHostGroupID, hosts: tuple[HostID, ...]) -> None: + # todo add check that hosts belong to group owner + self._repo.reset_hosts(id=group_id, hosts=hosts) diff --git a/python/cm/services/job/action.py b/python/cm/services/job/action.py index ff6985bf6e..269d61a197 100644 --- a/python/cm/services/job/action.py +++ b/python/cm/services/job/action.py @@ -15,7 +15,7 @@ from typing import TypeAlias from core.job.dto import TaskPayloadDTO -from core.types import ADCMCoreType, CoreObjectDescriptor +from core.types import ActionTargetDescriptor, CoreObjectDescriptor from django.conf import settings from django.db.transaction import atomic, on_commit from rbac.roles import re_apply_policy_for_jobs @@ -24,12 +24,12 @@ from cm.adcm_config.checks import check_attr from cm.adcm_config.config import check_config_spec, get_prototype_config, process_config_spec, process_file_type from cm.api import get_hc, save_hc -from cm.converters import model_name_to_core_type +from cm.converters import model_name_to_core_type, orm_object_to_action_target_type, orm_object_to_core_type from cm.errors import AdcmEx from cm.models import ( ADCM, Action, - ADCMEntity, + ActionHostGroup, Cluster, ClusterObject, ConcernType, @@ -40,7 +40,6 @@ JobStatus, ServiceComponent, TaskLog, - get_object_cluster, ) from cm.services.config.spec import convert_to_flat_spec_from_proto_flat_spec from cm.services.job.checks import check_constraints_for_upgrade, check_hostcomponentmap @@ -51,6 +50,7 @@ from cm.variant import process_variant ObjectWithAction: TypeAlias = ADCM | Cluster | ClusterObject | ServiceComponent | HostProvider | Host +ActionTarget: TypeAlias = ObjectWithAction | ActionHostGroup @dataclass @@ -61,128 +61,141 @@ class ActionRunPayload: verbose: bool = False -def run_action( - action: Action, - obj: ObjectWithAction, - payload: ActionRunPayload, -) -> TaskLog: - cluster: Cluster | None = get_object_cluster(obj=obj) - - action_target = _get_host_object(action=action, cluster=cluster) if action.host_action else obj - - object_locks = action_target.concerns.filter(type=ConcernType.LOCK) - - if action.name == settings.ADCM_DELETE_SERVICE_ACTION_NAME: - object_locks = object_locks.exclude(owner_id=obj.id, owner_type=obj.content_type) - - if object_locks.exists(): - raise AdcmEx(code="LOCK_ERROR", msg=f"object {action_target} is locked") +def run_action(action: Action, obj: ActionTarget, payload: ActionRunPayload) -> TaskLog: + action_objects = _ActionLaunchObjects(target=obj, action=action) - if ( - action.name not in settings.ADCM_SERVICE_ACTION_NAMES_SET - and action_target.concerns.filter(type=ConcernType.ISSUE).exists() - ): - raise AdcmEx(code="ISSUE_INTEGRITY_ERROR", msg=f"object {action_target} has issues") - - if not action.allowed(obj=action_target): - raise AdcmEx(code="TASK_ERROR", msg="action is disabled") + _check_no_target_conflict(target=action_objects.target, action=action) + _check_no_blocking_concerns(lock_owner=action_objects.object_to_lock, action_name=action.name) + _check_action_is_available_for_object(owner=action_objects.owner, action=action) - spec, flat_spec = _check_action_config(action=action, obj=obj, conf=payload.conf, attr=payload.attr) - - is_upgrade_action = hasattr(action, "upgrade") - - if is_upgrade_action and not action.hostcomponentmap: - check_constraints_for_upgrade( - cluster=cluster, upgrade=action.upgrade, host_comp_list=_get_actual_hc(cluster=cluster) - ) - - host_map, post_upgrade_hc, delta = check_hostcomponentmap( - cluster=cluster, action=action, new_hc=payload.hostcomponent + spec, flat_spec = _process_run_config( + action=action, owner=action_objects.owner, conf=payload.conf, attr=payload.attr + ) + host_map, post_upgrade_hc, delta, is_upgrade_action = _process_hostcomponent( + cluster=action_objects.cluster, action=action, new_hostcomponent=payload.hostcomponent ) - if action.hostcomponentmap and not (delta.get("add") or delta.get("remove")): - # means empty delta, shouldn't be like that - raise AdcmEx( - code="WRONG_ACTION_HC", - msg="Host-component is expected to be changed for this action", - http_code=HTTP_409_CONFLICT, - ) with atomic(): - target = CoreObjectDescriptor(id=obj.pk, type=model_name_to_core_type(obj.__class__.__name__.lower())) - owner = target - if target.type == ADCMCoreType.HOST and action.host_action: - match action.prototype_type: - case "cluster": - owner = CoreObjectDescriptor(id=cluster.pk, type=ADCMCoreType.CLUSTER) - case "service": - owner = CoreObjectDescriptor( - id=ClusterObject.objects.values_list("id", flat=True) - .filter(cluster=cluster, prototype_id=action.prototype_id) - .get(), - type=ADCMCoreType.SERVICE, - ) - case "component": - owner = CoreObjectDescriptor( - id=ServiceComponent.objects.values_list("id", flat=True) - .filter(cluster=cluster, prototype_id=action.prototype_id) - .get(), - type=ADCMCoreType.COMPONENT, - ) - task = prepare_task_for_action( - target=target, - owner=owner, + target=ActionTargetDescriptor( + id=action_objects.target.id, type=orm_object_to_action_target_type(action_objects.target) + ), + owner=CoreObjectDescriptor(id=action_objects.owner.id, type=orm_object_to_core_type(action_objects.owner)), action=action.pk, payload=TaskPayloadDTO( conf=payload.conf, attr=payload.attr, verbose=payload.verbose, - hostcomponent=get_hc(cluster=cluster), + hostcomponent=get_hc(cluster=action_objects.cluster), post_upgrade_hostcomponent=post_upgrade_hc, ), delta=delta, ) + + on_commit(func=partial(send_task_status_update_event, task_id=task.id, status=JobStatus.CREATED.value)) + task_ = TaskLog.objects.get(id=task.id) - if host_map or (is_upgrade_action and host_map is not None): - save_hc(cluster=cluster, host_comp_list=host_map) - - if payload.conf: - new_conf = update_configuration_for_inventory_inplace( - configuration=payload.conf, - attributes=payload.attr, - specification=convert_to_flat_spec_from_proto_flat_spec(prototypes_flat_spec=flat_spec), - config_owner=CoreObjectDescriptor( - id=obj.pk, type=model_name_to_core_type(model_name=obj._meta.model_name) - ), - ) - process_file_type(obj=task_, spec=spec, conf=payload.conf) - task_.config = new_conf - task_.save() - - on_commit(func=partial(send_task_status_update_event, task_id=task_.pk, status=JobStatus.CREATED.value)) - - re_apply_policy_for_jobs(action_object=obj, task=task_) + _finish_task_preparation( + task=task_, + owner=action_objects.owner, + cluster=action_objects.cluster, + host_map=host_map, + is_upgrade_action=is_upgrade_action, + payload=payload, + spec=spec, + flat_spec=flat_spec, + ) + + re_apply_policy_for_jobs(action_object=action_objects.owner, task=task_) run_task(task_) return task_ -def _get_host_object(action: Action, cluster: Cluster | None) -> ADCMEntity | None: - obj = None - if action.prototype.type == "service": - obj = ClusterObject.obj.get(cluster=cluster, prototype=action.prototype) - elif action.prototype.type == "component": - obj = ServiceComponent.obj.get(cluster=cluster, prototype=action.prototype) - elif action.prototype.type == "cluster": - obj = cluster +class _ActionLaunchObjects: + """ + Utility container to process differences in action's target/owner in one place + """ + + __slots__ = ("target", "owner", "cluster", "object_to_lock") + + target: ActionTarget + """Object on which action will be launched: may be owner OR host with this object OR action host group""" + + owner: ObjectWithAction + """Object that "owns" action: action is described in """ + + object_to_lock: ObjectWithAction + """Object owner of locks/issues and which will be locked on action launch""" + + cluster: Cluster | None + """Related cluster, is None for own HostProvider/Host actions""" - return obj + def __init__(self, target: ActionTarget, action: Action) -> None: + self.target = target + self.object_to_lock = self.target + if isinstance(target, (Cluster, ClusterObject, ServiceComponent)): + self.owner = target + self.cluster = target if isinstance(target, Cluster) else target.cluster + elif action.host_action and isinstance(target, Host): + self.cluster = target.cluster + match action.prototype.type: + case "component": + self.owner = ServiceComponent.objects.get(cluster=self.cluster, prototype=action.prototype) + case "service": + self.owner = ClusterObject.objects.get(cluster=self.cluster, prototype=action.prototype) + case "cluster": + self.owner = self.cluster + case _: + message = f"Can't handle {action.prototype.type} type for owner of host action detection" + raise NotImplementedError(message) + elif isinstance(target, ActionHostGroup): + # action group support only objects in Cluster hierarchy, + # so we can safely assume that there is related cluster + self.owner = target.object + self.cluster = self.owner if isinstance(self.owner, Cluster) else self.owner.cluster + self.object_to_lock = self.owner + else: + self.owner = target + self.cluster = None # it's safe to assume cluster is None for host own action + + +def _check_no_target_conflict(target: ActionTarget, action: Action) -> None: + if action.host_action and not isinstance(target, Host): + message = "Running host action without targeting host is prohibited" + raise AdcmEx(code="TASK_ERROR", msg=message) -def _check_action_config(action: Action, obj: ADCMEntity, conf: dict, attr: dict) -> tuple[dict, dict]: + if isinstance(target, ActionHostGroup) and not action.allow_for_action_host_group: + message = f"Action {action.display_name} isn't allowed to be launched on action host group" + raise AdcmEx(code="TASK_ERROR", msg=message) + + +def _check_no_blocking_concerns(lock_owner: ObjectWithAction, action_name: str) -> None: + object_locks = lock_owner.concerns.filter(type=ConcernType.LOCK) + + if action_name == settings.ADCM_DELETE_SERVICE_ACTION_NAME: + object_locks = object_locks.exclude(owner_id=lock_owner.id, owner_type=lock_owner.content_type) + + if object_locks.exists(): + raise AdcmEx(code="LOCK_ERROR", msg=f"object {lock_owner} is locked") + + if ( + action_name not in settings.ADCM_SERVICE_ACTION_NAMES_SET + and lock_owner.concerns.filter(type=ConcernType.ISSUE).exists() + ): + raise AdcmEx(code="ISSUE_INTEGRITY_ERROR", msg=f"object {lock_owner} has issues") + + +def _check_action_is_available_for_object(owner: ObjectWithAction, action: Action) -> None: + if not action.allowed(obj=owner): + raise AdcmEx(code="TASK_ERROR", msg="action is disabled") + + +def _process_run_config(action: Action, owner: ObjectWithAction, conf: dict, attr: dict) -> tuple[dict, dict]: proto = action.prototype - spec, flat_spec, _, _ = get_prototype_config(prototype=proto, action=action, obj=obj) + spec, flat_spec, _, _ = get_prototype_config(prototype=proto, action=action, obj=owner) if not spec: if conf: raise AdcmEx(code="CONFIG_VALUE_ERROR", msg="Absent config in action prototype") @@ -195,19 +208,72 @@ def _check_action_config(action: Action, obj: ADCMEntity, conf: dict, attr: dict check_attr(proto, action, attr, flat_spec) object_config = {} - if obj.config is not None: - object_config = ConfigLog.objects.get(id=obj.config.current).config + if owner.config is not None: + object_config = ConfigLog.objects.get(id=owner.config.current).config - process_variant(obj=obj, spec=spec, conf=object_config) + process_variant(obj=owner, spec=spec, conf=object_config) check_config_spec(proto=proto, obj=action, spec=spec, flat_spec=flat_spec, conf=conf, attr=attr) - process_config_spec(obj=obj, spec=spec, new_config=conf) + process_config_spec(obj=owner, spec=spec, new_config=conf) return spec, flat_spec -def _get_actual_hc(cluster: Cluster): - new_hc = [] - for hostcomponent in HostComponent.objects.filter(cluster=cluster): - new_hc.append((hostcomponent.service, hostcomponent.host, hostcomponent.component)) - return new_hc +def _process_hostcomponent( + cluster: Cluster | None, action: Action, new_hostcomponent: list[dict] +) -> tuple[list[tuple[ClusterObject, Host, ServiceComponent]] | None, list, dict[str, dict], bool]: + is_upgrade_action = hasattr(action, "upgrade") + + if not cluster: + if not new_hostcomponent: + return None, [], {}, is_upgrade_action + + # Don't think it's even required check on action preparation, + # should be handled one level above + raise AdcmEx(code="TASK_ERROR", msg="Only cluster objects can have action with hostcomponentmap") + + if is_upgrade_action and not action.hostcomponentmap: + new_hc = [ + (entry.service, entry.host, entry.component) + for entry in HostComponent.objects.select_related("service", "component", "host").filter(cluster=cluster) + ] + + check_constraints_for_upgrade(cluster=cluster, upgrade=action.upgrade, host_comp_list=new_hc) + + host_map, post_upgrade_hc, delta = check_hostcomponentmap(cluster=cluster, action=action, new_hc=new_hostcomponent) + if action.hostcomponentmap and not (delta.get("add") or delta.get("remove")): + # means empty delta, shouldn't be like that + raise AdcmEx( + code="WRONG_ACTION_HC", + msg="Host-component is expected to be changed for this action", + http_code=HTTP_409_CONFLICT, + ) + + return host_map, post_upgrade_hc, delta, is_upgrade_action + + +def _finish_task_preparation( + task: TaskLog, + owner: ObjectWithAction, + cluster: Cluster | None, + host_map: list | None, + is_upgrade_action: bool, + payload: ActionRunPayload, + spec: dict, + flat_spec: dict, +): + if host_map or (is_upgrade_action and host_map is not None): + save_hc(cluster=cluster, host_comp_list=host_map) + + if payload.conf: + new_conf = update_configuration_for_inventory_inplace( + configuration=payload.conf, + attributes=payload.attr, + specification=convert_to_flat_spec_from_proto_flat_spec(prototypes_flat_spec=flat_spec), + config_owner=CoreObjectDescriptor( + id=owner.pk, type=model_name_to_core_type(model_name=owner._meta.model_name) + ), + ) + process_file_type(obj=task, spec=spec, conf=payload.conf) + task.config = new_conf + task.save(update_fields=["config"]) diff --git a/python/cm/services/job/inventory/_base.py b/python/cm/services/job/inventory/_base.py index 695ef5e0db..1e66450457 100644 --- a/python/cm/services/job/inventory/_base.py +++ b/python/cm/services/job/inventory/_base.py @@ -12,14 +12,24 @@ from itertools import chain from operator import itemgetter +from typing import Iterable from core.cluster.operations import calculate_maintenance_mode_for_cluster_objects from core.cluster.types import ClusterTopology, MaintenanceModeOfObjects, ObjectMaintenanceModeState -from core.types import ADCMCoreType, CoreObjectDescriptor, HostID, HostName, ObjectID +from core.types import ( + ActionTargetDescriptor, + ADCMCoreType, + CoreObjectDescriptor, + ExtraActionTargetType, + HostID, + HostName, + ObjectID, +) from django.db.models import F from cm.converters import core_type_to_model from cm.models import ( + ActionHostGroup, Cluster, ClusterObject, Host, @@ -51,13 +61,29 @@ ) -def get_inventory_data(target: CoreObjectDescriptor, is_host_action: bool, delta: dict | None = None) -> dict: +def get_inventory_data(target: ActionTargetDescriptor, is_host_action: bool, delta: dict | None = None) -> dict: + if target.type == ExtraActionTargetType.ACTION_HOST_GROUP: + group = ActionHostGroup.objects.prefetch_related("hosts", "object").get(id=target.id) + return _get_inventory_for_action_from_cluster_bundle( + object_=group.object, + delta=delta or {}, + target_hosts=tuple((host.pk, host.fqdn) for host in group.hosts.all()), + ) + target_object = core_type_to_model(target.type).objects.get(id=target.id) if isinstance(target_object, HostProvider) or (isinstance(target_object, Host) and not is_host_action): return _get_inventory_for_action_from_hostprovider_bundle(object_=target_object) + target_hosts = () + if isinstance(target_object, Host): + if not is_host_action: + message = "Only actions with `host_action: true` can be launched on host" + raise RuntimeError(message) + + target_hosts = ((target_object.pk, target_object.fqdn),) + return _get_inventory_for_action_from_cluster_bundle( - object_=target_object, is_host_action=is_host_action, delta=delta or {} + object_=target_object, delta=delta or {}, target_hosts=target_hosts ) @@ -84,16 +110,14 @@ def get_cluster_vars(topology: ClusterTopology) -> ClusterVars: def _get_inventory_for_action_from_cluster_bundle( - object_: Cluster | ClusterObject | ServiceComponent | Host, is_host_action: bool, delta: dict + object_: Cluster | ClusterObject | ServiceComponent | Host | ActionHostGroup, + delta: dict, + target_hosts: Iterable[tuple[HostID, HostName]], ) -> dict: host_groups: dict[HostGroupName, set[tuple[HostID, HostName]]] = {} - if isinstance(object_, Host): - if not is_host_action: - message = "Only actions with `host_action: true` can be launched on host" - raise RuntimeError(message) - - host_groups["target"] = {(object_.pk, object_.fqdn)} + if target_hosts: + host_groups["target"] = set(target_hosts) if isinstance(object_, Cluster): cluster_topology = next(retrieve_clusters_topology([object_.pk])) diff --git a/python/cm/services/job/jinja_scripts.py b/python/cm/services/job/jinja_scripts.py index 9bc8975766..e4c06f3fab 100755 --- a/python/cm/services/job/jinja_scripts.py +++ b/python/cm/services/job/jinja_scripts.py @@ -18,6 +18,7 @@ from cm.errors import AdcmEx from cm.models import ( Action, + ActionHostGroup, Cluster, ClusterObject, Host, @@ -59,7 +60,11 @@ class JinjaScriptsEnvironment(TypedDict): def get_env(task: TaskLog, delta: dict | None = None) -> JinjaScriptsEnvironment: + action_group = None target_object = task.task_object + if isinstance(target_object, ActionHostGroup): + action_group = target_object + target_object = target_object.object if isinstance(target_object, Cluster): cluster_topology = next(retrieve_clusters_topology([target_object.pk])) @@ -76,9 +81,19 @@ def get_env(task: TaskLog, delta: dict | None = None) -> JinjaScriptsEnvironment "id", flat=True ) ) - host_groups = detect_host_groups_for_cluster_bundle_action( - cluster_topology=cluster_topology, hosts_in_maintenance_mode=hosts_in_maintenance_mode, hc_delta=delta + host_groups = _get_host_group_names_only( + host_groups=detect_host_groups_for_cluster_bundle_action( + cluster_topology=cluster_topology, hosts_in_maintenance_mode=hosts_in_maintenance_mode, hc_delta=delta + ) ) + if action_group: + host_groups |= { + "target": Host.objects.values_list("fqdn", flat=True).filter( + id__in=ActionHostGroup.hosts.through.objects.filter(actionhostgroup_id=action_group.id).values_list( + "host_id", flat=True + ) + ) + } return JinjaScriptsEnvironment( cluster=cluster_vars.cluster.dict(by_alias=True), @@ -86,7 +101,7 @@ def get_env(task: TaskLog, delta: dict | None = None) -> JinjaScriptsEnvironment service_name: service_data.dict(by_alias=True) for service_name, service_data in cluster_vars.services.items() }, - groups=_get_host_group_names_only(host_groups=host_groups), + groups=host_groups, task=TaskContext(config=task.config, verbose=task.verbose), action=get_action_info(action=task.action), ) diff --git a/python/cm/services/job/prepare.py b/python/cm/services/job/prepare.py index 673ac70870..58f1f173aa 100644 --- a/python/cm/services/job/prepare.py +++ b/python/cm/services/job/prepare.py @@ -13,13 +13,13 @@ from core.job.dto import TaskPayloadDTO from core.job.task import compose_task from core.job.types import Task -from core.types import ActionID, CoreObjectDescriptor +from core.types import ActionID, ActionTargetDescriptor, CoreObjectDescriptor from cm.services.job.run.repo import ActionRepoImpl, JobRepoImpl def prepare_task_for_action( - target: CoreObjectDescriptor, + target: ActionTargetDescriptor, owner: CoreObjectDescriptor, action: ActionID, payload: TaskPayloadDTO, diff --git a/python/cm/services/job/run/_target_factories.py b/python/cm/services/job/run/_target_factories.py index 880edd461e..b3c27dff0b 100644 --- a/python/cm/services/job/run/_target_factories.py +++ b/python/cm/services/job/run/_target_factories.py @@ -235,7 +235,9 @@ def prepare_ansible_inventory(task: Task) -> dict[str, Any]: raise RuntimeError(message) new_hc = [] - for hostcomponent in HostComponent.objects.filter(cluster_id=cluster_id): + for hostcomponent in HostComponent.objects.select_related("service", "host", "component").filter( + cluster_id=cluster_id + ): new_hc.append((hostcomponent.service, hostcomponent.host, hostcomponent.component)) delta = cook_delta( @@ -250,7 +252,7 @@ def prepare_ansible_inventory(task: Task) -> dict[str, Any]: def prepare_ansible_job_config(task: Task, job: Job, configuration: ExternalSettings) -> dict[str, Any]: # prepare context - context = {f"{k}_id": v["id"] for k, v in task.selector.items()} + context = {f"{k}_id": v["id"] for k, v in task.selector.items() if k != "action_host_group"} context["type"] = task.owner.type.value.replace("hostp", "p") job_data = JobData( diff --git a/python/cm/services/job/run/_task.py b/python/cm/services/job/run/_task.py index aa00e6058e..639fcf1b8e 100644 --- a/python/cm/services/job/run/_task.py +++ b/python/cm/services/job/run/_task.py @@ -19,7 +19,7 @@ from cm.hierarchy import Tree from cm.issue import lock_affected_objects -from cm.models import TaskLog +from cm.models import ActionHostGroup, TaskLog from cm.utils import get_env_with_venv_path logger = logging.getLogger("adcm") @@ -34,9 +34,12 @@ def restart_task(task: TaskLog) -> None: def _run_task(task: TaskLog, command: Literal["start", "restart"]): - tree = Tree(obj=task.task_object) + owner = task.task_object + if isinstance(owner, ActionHostGroup): + owner = owner.object + tree = Tree(obj=owner) affected_objs = (node.value for node in tree.get_all_affected(node=tree.built_from)) - lock_affected_objects(task=task, objects=affected_objs) + lock_affected_objects(task=task, objects=affected_objs, lock_target=owner) err_file = open( # noqa: SIM115 Path(settings.LOG_DIR, "task_runner.err"), diff --git a/python/cm/services/job/run/_task_finalizers.py b/python/cm/services/job/run/_task_finalizers.py index 1dd2ba7840..2b79d09052 100644 --- a/python/cm/services/job/run/_task_finalizers.py +++ b/python/cm/services/job/run/_task_finalizers.py @@ -12,9 +12,10 @@ from logging import Logger from operator import itemgetter +from typing import Protocol from core.job.types import Task -from core.types import CoreObjectDescriptor +from core.types import ADCMCoreType, CoreObjectDescriptor from django.conf import settings from cm.api import save_hc @@ -29,6 +30,11 @@ # which is in no way correct approach +class WithIDAndCoreType(Protocol): + id: int + type: ADCMCoreType + + def set_job_lock(job_id: int) -> None: job = JobLog.objects.select_related("task").get(pk=job_id) if job.task.lock and job.task.task_object: @@ -79,7 +85,7 @@ def update_issues(object_: CoreObjectDescriptor): update_hierarchy_issues(obj=core_type_to_model(core_type=object_.type).objects.get(id=object_.id)) -def update_object_maintenance_mode(action_name: str, object_: CoreObjectDescriptor): +def update_object_maintenance_mode(action_name: str, object_: WithIDAndCoreType): """ If maintenance mode wasn't changed during action execution, set "opposite" (to action's name) MM """ diff --git a/python/cm/services/job/run/repo.py b/python/cm/services/job/run/repo.py index a9573f56e1..fac4cef973 100644 --- a/python/cm/services/job/run/repo.py +++ b/python/cm/services/job/run/repo.py @@ -36,22 +36,29 @@ ) from core.types import ( ActionID, + ActionTargetDescriptor, ADCMCoreType, - ADCMDescriptor, CoreObjectDescriptor, HostID, - NamedCoreObject, + NamedActionObject, NamedCoreObjectWithPrototype, PrototypeDescriptor, ) from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.db.models import F, QuerySet, Value -from cm.converters import core_type_to_model, db_record_type_to_core_type +from cm.converters import ( + core_type_to_model, + db_record_type_to_core_type, + model_name_to_core_type, + orm_object_to_action_target_type, +) from cm.models import ( ADCM, Action, + ActionHostGroup, Cluster, ClusterObject, Host, @@ -89,9 +96,7 @@ class JobRepoImpl: def get_task(cls, id: int) -> Task: # noqa: A002 try: task_record: TaskLog = ( - TaskLog.objects.select_related("action__prototype") - .prefetch_related("task_object__prototype__bundle") - .get(id=id) + TaskLog.objects.select_related("action__prototype").prefetch_related("task_object").get(id=id) ) except ObjectDoesNotExist: message = f"Can't find task identified by {id}" @@ -104,8 +109,8 @@ def get_task(cls, id: int) -> Task: # noqa: A002 action_prototype = task_record.action.prototype target_ = bundle = None if target := task_record.task_object: - target_ = NamedCoreObject( - id=target.pk, type=db_record_type_to_core_type(db_record_type=target.prototype.type), name=target.name + target_ = NamedActionObject( + id=target.pk, type=orm_object_to_action_target_type(object_=target), name=target.name ) if action_prototype.type == "adcm": bundle = BundleInfo(root=settings.BASE_DIR / "conf" / "adcm", config_dir=Path()) @@ -160,7 +165,7 @@ def get_task_mutable_fields(id: int) -> TaskMutableFieldsDTO: # noqa: A002 @classmethod def create_task( - cls, target: CoreObjectDescriptor, owner: CoreObjectDescriptor, action: ActionInfo, payload: TaskPayloadDTO + cls, target: ActionTargetDescriptor, owner: CoreObjectDescriptor, action: ActionInfo, payload: TaskPayloadDTO ) -> Task: if action.owner_prototype.type == ADCMCoreType.ADCM: if target.type != ADCMCoreType.ADCM: @@ -169,12 +174,24 @@ def create_task( selector = {"adcm": {"id": target.id, "name": "adcm"}} object_type = ADCM.class_content_type - elif target.type == ADCMDescriptor: + elif target.type == ADCMCoreType.ADCM: message = f"ADCM actions can be launched only on ADCM: {target=} ; {action.owner_prototype=}" raise TypeError(message) - else: - selector = cls._get_selector_for_core_object(target=target, owner=action.owner_prototype) + + elif isinstance(target.type, ADCMCoreType): + selector = cls._get_selector_for_core_object( + target=CoreObjectDescriptor(id=target.id, type=target.type), owner=action.owner_prototype + ) object_type = core_type_to_model(core_type=target.type).class_content_type + else: + group = ActionHostGroup.objects.select_related("object_type").get(id=target.id) + group_owner = CoreObjectDescriptor( + id=group.object_id, type=model_name_to_core_type(group.object_type.model) + ) + selector = {"action_host_group": {"id": group.id, "name": group.name}} | cls._get_selector_for_core_object( + target=group_owner, owner=action.owner_prototype + ) + object_type = ContentType.objects.get_for_model(ActionHostGroup) task = TaskLog.objects.create( action_id=action.id, diff --git a/python/cm/services/job/run/runners.py b/python/cm/services/job/run/runners.py index 9ce50cfd84..a09e1c27f1 100644 --- a/python/cm/services/job/run/runners.py +++ b/python/cm/services/job/run/runners.py @@ -18,7 +18,7 @@ from core.job.dto import JobUpdateDTO, TaskUpdateDTO from core.job.runners import ExecutionTarget, RunnerRuntime, TaskRunner from core.job.types import ExecutionStatus, Job, Task -from core.types import CoreObjectDescriptor +from core.types import ADCMCoreType, CoreObjectDescriptor from cm.services.job.run._task_finalizers import ( remove_task_lock, @@ -238,7 +238,7 @@ def _finish(self, task: Task, last_job: Job | None): remove_task_lock(task_id=task.id) audit_job_finish( - owner=task.target, + target=task.target, display_name=task.action.display_name, is_upgrade=task.action.is_upgrade, job_result=task_result, @@ -253,7 +253,12 @@ def _finish(self, task: Task, last_job: Job | None): ) if finished_task.target: - update_object_maintenance_mode(action_name=finished_task.action.name, object_=finished_task.target) + update_object_maintenance_mode( + action_name=finished_task.action.name, + object_=finished_task.target + if isinstance(finished_task.target.type, ADCMCoreType) + else finished_task.owner, + ) self._repo.update_task(id=task.id, data=TaskUpdateDTO(finish_date=self._environment.now(), status=task_result)) self._notifier.send_task_status_update_event(task_id=self._runtime.task_id, status=task_result) diff --git a/python/cm/stack.py b/python/cm/stack.py index 9ea7cc1937..179b1e4f16 100644 --- a/python/cm/stack.py +++ b/python/cm/stack.py @@ -758,10 +758,18 @@ def save_action(proto: StagePrototype, config: dict, path_resolver: PathResolver action = StageAction(prototype=proto, name=action_name) action.type = config["type"] + if config.get("host_action", False) and config.get("allow_for_action_host_group", False): + message = ( + "The allow_for_action_host_group and host_action attributes are mutually exclusive. " + f"Check {action_name} action definition" + ) + raise AdcmEx(code="INVALID_ACTION_DEFINITION", msg=message) + dict_to_obj(dictionary=config, key="description", obj=action) dict_to_obj(dictionary=config, key="allow_to_terminate", obj=action) dict_to_obj(dictionary=config, key="partial_execution", obj=action) dict_to_obj(dictionary=config, key="host_action", obj=action) + dict_to_obj(dictionary=config, key="allow_for_action_host_group", obj=action) dict_to_obj(dictionary=config, key="ui_options", obj=action) dict_to_obj(dictionary=config, key="venv", obj=action) dict_to_obj(dictionary=config, key="allow_in_maintenance_mode", obj=action) @@ -1124,8 +1132,8 @@ def in_dict(dictionary: dict, key: str) -> bool: if dictionary[key] is None: return False return True - else: # noqa: RET505 - return False + + return False def dict_to_obj(dictionary, key, obj, obj_key=None): diff --git a/python/cm/tests/bundles/action_host_group/correct/config.yaml b/python/cm/tests/bundles/action_host_group/correct/config.yaml new file mode 100644 index 0000000000..25d54e0eda --- /dev/null +++ b/python/cm/tests/bundles/action_host_group/correct/config.yaml @@ -0,0 +1,35 @@ +- type: cluster + version: 1 + name: "cl" + + actions: + allow_true_ha_false: + masking: + allow_for_action_host_group: true + host_action: false + type: job + script: ./some.yaml + script_type: ansible + +- type: service + version: 4 + name: "se" + + actions: + allow_false_ha_true: + masking: + allow_for_action_host_group: false + host_action: true + type: job + script: ./some.yaml + script_type: ansible + + components: + co: + actions: + allow_true_ha_absent: + masking: + allow_for_action_host_group: true + type: job + script: ./some.yaml + script_type: ansible diff --git a/python/cm/tests/bundles/action_host_group/negative/in_upgrade/config.yaml b/python/cm/tests/bundles/action_host_group/negative/in_upgrade/config.yaml new file mode 100644 index 0000000000..6a164add77 --- /dev/null +++ b/python/cm/tests/bundles/action_host_group/negative/in_upgrade/config.yaml @@ -0,0 +1,22 @@ +- type: cluster + version: 2 + name: "cl" + + upgrade: + - name: cool + versions: + min: '1.0' + max: '2.0' + states: + available: any + allow_for_action_host_group: false + scripts: + - name: pre + script: ./playbook.yaml + script_type: ansible + - name: switch + script: bundle_switch + script_type: internal + - name: post + script: ./playbook.yaml + script_type: ansible diff --git a/python/cm/tests/bundles/action_host_group/negative/with_host_action/config.yaml b/python/cm/tests/bundles/action_host_group/negative/with_host_action/config.yaml new file mode 100644 index 0000000000..cf096420c9 --- /dev/null +++ b/python/cm/tests/bundles/action_host_group/negative/with_host_action/config.yaml @@ -0,0 +1,12 @@ +- type: cluster + version: 3 + name: "cl" + + actions: + host_action_with_host_group: + masking: + allow_for_action_host_group: true + host_action: true + type: job + script: ./some.yaml + script_type: ansible diff --git a/python/cm/tests/bundles/cluster_full_config/config.yaml b/python/cm/tests/bundles/cluster_full_config/config.yaml index 4b8fdb5baa..51a3d3a1ae 100644 --- a/python/cm/tests/bundles/cluster_full_config/config.yaml +++ b/python/cm/tests/bundles/cluster_full_config/config.yaml @@ -104,6 +104,7 @@ type: job script: ./playbook.yaml script_type: ansible + allow_for_action_host_group: true states: available: any @@ -114,6 +115,7 @@ with_config_on_host: <<: *job config: *config + allow_for_action_host_group: false host_action: true name_and_pass: @@ -135,6 +137,7 @@ with_jinja: script: ./playbool.yaml script_type: ansible + allow_for_action_host_group: true config_jinja: action.j2 states: available: any diff --git a/python/cm/tests/test_action_host_group.py b/python/cm/tests/test_action_host_group.py new file mode 100644 index 0000000000..3fb101eeca --- /dev/null +++ b/python/cm/tests/test_action_host_group.py @@ -0,0 +1,211 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path + +from adcm.tests.base import BaseTestCase, BusinessLogicMixin +from core.job.runners import ADCMSettings, AnsibleSettings, ExternalSettings, IntegrationsSettings +from core.types import ActionTargetDescriptor, ADCMCoreType, CoreObjectDescriptor, ExtraActionTargetType +from django.conf import settings + +from cm.errors import AdcmEx +from cm.models import Action, ActionHostGroup, ServiceComponent +from cm.services.action_host_group import ActionHostGroupRepo, ActionHostGroupService, CreateDTO +from cm.services.job.action import ActionRunPayload, run_action +from cm.services.job.inventory import get_inventory_data +from cm.services.job.jinja_scripts import get_env +from cm.services.job.run._target_factories import prepare_ansible_job_config +from cm.services.job.run.repo import JobRepoImpl +from cm.tests.mocks.task_runner import RunTaskMock + + +class TestActionHostGroup(BusinessLogicMixin, BaseTestCase): + def setUp(self) -> None: + super().setUp() + + self.bundles_dir = Path(__file__).parent / "bundles" + + self.hostprovider = self.add_provider( + bundle=self.add_bundle(self.bundles_dir / "provider_full_config"), name="Host Provider" + ) + self.host_1 = self.add_host( + bundle=self.hostprovider.prototype.bundle, provider=self.hostprovider, fqdn="host-1" + ) + self.host_2 = self.add_host( + bundle=self.hostprovider.prototype.bundle, provider=self.hostprovider, fqdn="host-2" + ) + + self.cluster = self.add_cluster( + bundle=self.add_bundle(self.bundles_dir / "cluster_full_config"), name="Main Cluster" + ) + self.service = self.add_services_to_cluster(service_names=["all_params"], cluster=self.cluster).first() + self.component = ServiceComponent.objects.get(service=self.service) + + self.add_host_to_cluster(cluster=self.cluster, host=self.host_1) + self.add_host_to_cluster(cluster=self.cluster, host=self.host_2) + self.set_hostcomponent( + cluster=self.cluster, entries=((self.host_1, self.component), (self.host_2, self.component)) + ) + + self.context = { + "hostprovider_bundle": self.hostprovider.prototype.bundle, + "cluster_bundle": self.cluster.prototype.bundle, + "datadir": self.directories["DATA_DIR"], + "stackdir": self.directories["STACK_DIR"], + "token": settings.STATUS_SECRET_KEY, + "component_type_id": self.component.prototype_id, + } + + self.configuration = ExternalSettings( + adcm=ADCMSettings(code_root_dir=settings.CODE_DIR, run_dir=settings.RUN_DIR, log_dir=settings.LOG_DIR), + ansible=AnsibleSettings(ansible_secret_script=settings.CODE_DIR / "ansible_secret.py"), + integrations=IntegrationsSettings(status_server_token=settings.STATUS_SECRET_KEY), + ) + + self.action_group_service = ActionHostGroupService(repository=ActionHostGroupRepo()) + + group_id = self.action_group_service.create( + CreateDTO( + name="simple", description="", owner=CoreObjectDescriptor(id=self.cluster.id, type=ADCMCoreType.CLUSTER) + ) + ) + self.action_group_service.set_hosts(group_id=group_id, hosts=(self.host_1.id, self.host_2.id)) + self.action_group = ActionHostGroup.objects.get(id=group_id) + + def test_run_action_success(self) -> None: + action = Action.objects.get(prototype=self.cluster.prototype, name="dummy") + + with RunTaskMock() as run_task: + run_action(action=action, obj=self.action_group, payload=ActionRunPayload()) + + task = run_task.target_task + self.assertEqual(task.task_object, self.action_group) + self.assertEqual(task.owner_id, self.cluster.id) + self.assertEqual(task.owner_type, ADCMCoreType.CLUSTER.value) + + def test_generate_inventory_success(self) -> None: + group_inventory = get_inventory_data( + target=ActionTargetDescriptor(id=self.action_group.id, type=ExtraActionTargetType.ACTION_HOST_GROUP), + is_host_action=False, + delta={}, + ) + + self.assertIn("target", group_inventory["all"]["children"]) + self.assertSetEqual( + set(group_inventory["all"]["children"]["target"]["hosts"]), {self.host_1.fqdn, self.host_2.fqdn} + ) + + owner_inventory = get_inventory_data( + target=ActionTargetDescriptor(id=self.cluster.id, type=ADCMCoreType.CLUSTER), is_host_action=False, delta={} + ) + + group_inventory["all"]["children"].pop("target") + self.assertEqual(group_inventory, owner_inventory) + + def test_get_env_for_jinja_scripts_success(self) -> None: + group_id = self.action_group_service.create( + CreateDTO( + name="simple", description="", owner=CoreObjectDescriptor(id=self.service.id, type=ADCMCoreType.SERVICE) + ) + ) + self.action_group_service.set_hosts(group_id=group_id, hosts=(self.host_1.id, self.host_2.id)) + + action = Action.objects.get(prototype=self.service.prototype, name="dummy") + action_group = ActionHostGroup.objects.get(id=group_id) + + with RunTaskMock() as run_task: + run_action(action=action, obj=action_group, payload=ActionRunPayload()) + + result_env = get_env(task=run_task.target_task) + + self.assertSetEqual( + set(result_env["groups"]), + {"target", "CLUSTER", self.service.name, f"{self.service.name}.{self.component.name}"}, + ) + self.assertSetEqual(set(result_env["groups"]["target"]), {self.host_1.fqdn, self.host_2.fqdn}) + + def test_group_not_in_selector_success(self) -> None: + group_id = self.action_group_service.create( + CreateDTO( + name="simple", + description="", + owner=CoreObjectDescriptor(id=self.component.id, type=ADCMCoreType.COMPONENT), + ) + ) + self.action_group_service.set_hosts(group_id=group_id, hosts=(self.host_1.id, self.host_2.id)) + + action = Action.objects.get(prototype=self.service.prototype, name="dummy") + action_group = ActionHostGroup.objects.get(id=group_id) + + with RunTaskMock() as run_task: + run_action(action=action, obj=action_group, payload=ActionRunPayload()) + + task = JobRepoImpl.get_task(run_task.target_task.id) + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + config = prepare_ansible_job_config( + task=task, + job=job, + configuration=ExternalSettings( + adcm=ADCMSettings(code_root_dir=settings.CODE_DIR, run_dir=settings.RUN_DIR, log_dir=settings.LOG_DIR), + ansible=AnsibleSettings(ansible_secret_script=settings.CODE_DIR / "ansible_secret.py"), + integrations=IntegrationsSettings(status_server_token=settings.STATUS_SECRET_KEY), + ), + ) + + self.assertDictEqual( + config["context"], + { + "cluster_id": self.cluster.id, + "service_id": self.service.id, + "component_id": self.component.id, + "type": "component", + }, + ) + + def test_upload_success(self) -> None: + bundle = self.add_bundle(self.bundles_dir / "action_host_group" / "correct") + + cluster_action = Action.objects.get( + prototype__type="cluster", prototype__bundle=bundle, name="allow_true_ha_false" + ) + self.assertTrue(cluster_action.allow_for_action_host_group) + self.assertFalse(cluster_action.host_action) + + service_action = Action.objects.get( + prototype__type="service", prototype__bundle=bundle, name="allow_false_ha_true" + ) + self.assertFalse(service_action.allow_for_action_host_group) + self.assertTrue(service_action.host_action) + + component_action = Action.objects.get( + prototype__type="component", prototype__bundle=bundle, name="allow_true_ha_absent" + ) + self.assertTrue(component_action.allow_for_action_host_group) + self.assertFalse(component_action.host_action) + + def test_upload_fail(self) -> None: + negative_dir = self.bundles_dir / "action_host_group" / "negative" + + with self.subTest("Host Action AND Action Host Group"): + with self.assertRaises(AdcmEx) as err_context: + self.add_bundle(negative_dir / "with_host_action") + + self.assertEqual(err_context.exception.code, "INVALID_ACTION_DEFINITION") + self.assertIn("mutually exclusive", err_context.exception.msg) + + with self.subTest("Action Host Group In Upgrade"): + with self.assertRaises(AdcmEx) as err_context: + self.add_bundle(negative_dir / "in_upgrade") + + self.assertEqual(err_context.exception.code, "INVALID_OBJECT_DEFINITION") + self.assertIn('Map key "allow_for_action_host_group" is not allowed here', err_context.exception.msg) diff --git a/python/core/job/repo.py b/python/core/job/repo.py index be72d8cb49..50eabdafca 100644 --- a/python/core/job/repo.py +++ b/python/core/job/repo.py @@ -14,7 +14,7 @@ from core.job.dto import JobUpdateDTO, LogCreateDTO, TaskMutableFieldsDTO, TaskPayloadDTO, TaskUpdateDTO from core.job.types import ActionInfo, Job, JobSpec, Task -from core.types import ActionID, CoreObjectDescriptor +from core.types import ActionID, ActionTargetDescriptor, CoreObjectDescriptor class JobRepoInterface(Protocol): @@ -22,7 +22,7 @@ def get_task(self, id: int) -> Task: # noqa: A002 """Should raise `NotFoundError` on fail""" def create_task( - self, target: CoreObjectDescriptor, owner: CoreObjectDescriptor, action: ActionInfo, payload: TaskPayloadDTO + self, target: ActionTargetDescriptor, owner: CoreObjectDescriptor, action: ActionInfo, payload: TaskPayloadDTO ) -> Task: ... diff --git a/python/core/job/task.py b/python/core/job/task.py index 2fb089845e..dd4a031240 100644 --- a/python/core/job/task.py +++ b/python/core/job/task.py @@ -16,11 +16,11 @@ from core.job.dto import LogCreateDTO, TaskPayloadDTO from core.job.errors import TaskCreateError from core.job.repo import ActionRepoInterface, JobRepoInterface -from core.types import ActionID, CoreObjectDescriptor +from core.types import ActionID, ActionTargetDescriptor, CoreObjectDescriptor def compose_task( - target: CoreObjectDescriptor, + target: ActionTargetDescriptor, owner: CoreObjectDescriptor, action: ActionID, payload: TaskPayloadDTO, diff --git a/python/core/job/types.py b/python/core/job/types.py index e987325cad..89a9223809 100644 --- a/python/core/job/types.py +++ b/python/core/job/types.py @@ -19,7 +19,7 @@ from core.types import ( ActionID, ADCMCoreType, - NamedCoreObject, + NamedActionObject, NamedCoreObjectWithPrototype, ObjectID, PrototypeDescriptor, @@ -110,7 +110,7 @@ class Task(BaseModel): # Target is an object on which action should be performed # it's the same as owner for all cases except `host_action: true` - target: NamedCoreObject | None + target: NamedActionObject | None selector: dict diff --git a/python/core/types.py b/python/core/types.py index 9e8d1a7ce7..ab1bb5dc1a 100644 --- a/python/core/types.py +++ b/python/core/types.py @@ -10,6 +10,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from dataclasses import dataclass from enum import Enum from typing import NamedTuple, TypeAlias @@ -52,6 +53,10 @@ class ADCMCoreType(Enum): HOST = "host" +class ExtraActionTargetType(Enum): + ACTION_HOST_GROUP = "action-host-group" + + class ShortObjectInfo(NamedTuple): id: ObjectID name: str @@ -66,19 +71,26 @@ class PrototypeDescriptor(NamedTuple): type: ADCMCoreType -class GeneralEntityDescriptor(NamedTuple): +@dataclass(slots=True, frozen=True) +class GeneralEntityDescriptor: id: ObjectID type: str -class CoreObjectDescriptor(NamedTuple): - id: ObjectID - type: ADCMCoreType +@dataclass(slots=True, frozen=True) +class ActionTargetDescriptor(GeneralEntityDescriptor): + type: ADCMCoreType | ExtraActionTargetType -class NamedCoreObject(NamedTuple): - id: ObjectID +# inheritance from `ActionTargetDescriptor` is for convenience purposes, +# because `CoreObjectDescriptor` is just a bit stricter than `ActionTargetDescriptor` +@dataclass(slots=True, frozen=True) +class CoreObjectDescriptor(ActionTargetDescriptor): type: ADCMCoreType + + +@dataclass(slots=True, frozen=True) +class NamedActionObject(ActionTargetDescriptor): name: str diff --git a/python/rbac/roles.py b/python/rbac/roles.py index 815b6efab5..c0623a8ae7 100644 --- a/python/rbac/roles.py +++ b/python/rbac/roles.py @@ -13,6 +13,7 @@ from cm.errors import raise_adcm_ex from cm.models import ( Action, + ActionHostGroup, ADCMEntity, ClusterObject, ConfigLog, @@ -226,6 +227,11 @@ def apply_jobs(task: TaskLog, policy: Policy) -> None: def re_apply_policy_for_jobs(action_object: ADCMEntity, task: TaskLog) -> None: obj_type_map = get_objects_for_policy(obj=action_object) object_model = action_object.__class__.__name__.lower() + + target = task.task_object + if isinstance(target, ActionHostGroup): + target = target.object + task_role, _ = Role.objects.get_or_create( name=f"View role for task {task.id}", display_name=f"View role for task {task.id}", @@ -236,7 +242,7 @@ def re_apply_policy_for_jobs(action_object: ADCMEntity, task: TaskLog) -> None: init_params={ "task_id": task.id, }, - parametrized_by_type=[task.task_object.prototype.type], + parametrized_by_type=[target.prototype.type], ) for obj, content_type in obj_type_map.items(): From 41b54216e03e3bbc748065da33f6a1f05f7a5371 Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Mon, 10 Jun 2024 09:47:58 +0000 Subject: [PATCH 167/208] ADCM-5587: Rework unittests,`test_known_bugs.py` file --- python/api_v2/tests/test_cluster.py | 10 +++---- python/api_v2/tests/test_config.py | 16 +++++++---- python/api_v2/tests/test_group_config.py | 16 +++-------- python/api_v2/tests/test_known_bugs.py | 27 ------------------- python/api_v2/tests/test_profile.py | 2 +- python/api_v2/tests/test_tasks.py | 4 +-- python/api_v2/tests/test_user.py | 4 +-- python/cm/tests/test_hc.py | 2 +- .../test_inventory/test_action_config.py | 6 ++--- .../test_inventory/test_before_upgrade.py | 2 +- python/cm/tests/test_jinja_config.py | 2 +- 11 files changed, 30 insertions(+), 61 deletions(-) delete mode 100644 python/api_v2/tests/test_known_bugs.py diff --git a/python/api_v2/tests/test_cluster.py b/python/api_v2/tests/test_cluster.py index 08c89ab2da..f96172d498 100644 --- a/python/api_v2/tests/test_cluster.py +++ b/python/api_v2/tests/test_cluster.py @@ -178,7 +178,7 @@ def test_create_without_not_required_field_success(self): self.assertEqual(response.status_code, HTTP_201_CREATED) self.assertEqual(cluster.description, "") - def test_create_adcm_5371_start_digits_success(self): + def test_adcm_5371_create_start_digits_success(self): response = (self.client.v2 / "clusters").post( data={"prototype_id": self.cluster_1.prototype.pk, "name": "1new_test_cluster"} ) @@ -187,21 +187,21 @@ def test_create_adcm_5371_start_digits_success(self): self.assertEqual(response.status_code, HTTP_201_CREATED) self.assertEqual(cluster.description, "") - def test_create_adcm_5371_dot_fail(self): + def test_adcm_5371_create_dot_fail(self): response = (self.client.v2 / "clusters").post( data={"prototype_id": self.cluster_1.prototype.pk, "name": "new_test_cluster."} ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) - def test_create_adcm_5371_space_prohibited_end_start_fail(self): + def test_adcm_5371_create_space_prohibited_end_start_fail(self): response = (self.client.v2 / "clusters").post( data={"prototype_id": self.cluster_1.prototype.pk, "name": " new_test_cluster "} ) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) - def test_create_adcm_5371_min_name_2_chars_success(self): + def test_adcm_5371_create_min_name_2_chars_success(self): response = (self.client.v2 / "clusters").post(data={"prototype_id": self.cluster_1.prototype.pk, "name": "a"}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) @@ -211,7 +211,7 @@ def test_create_adcm_5371_min_name_2_chars_success(self): self.assertIsNotNone(Cluster.objects.filter(name="aa").first()) self.assertEqual(response.status_code, HTTP_201_CREATED) - def test_create_adcm_5371_max_name_150_chars_success(self): + def test_adcm_5371_create_max_name_150_chars_success(self): response = (self.client.v2 / "clusters").post( data={"prototype_id": self.cluster_1.prototype.pk, "name": "a" * 151} ) diff --git a/python/api_v2/tests/test_config.py b/python/api_v2/tests/test_config.py index 9c1a8879f7..391556d91a 100644 --- a/python/api_v2/tests/test_config.py +++ b/python/api_v2/tests/test_config.py @@ -188,6 +188,14 @@ def test_schema_permissions_another_object_role_denied(self): response = self.client.v2[self.cluster_1, CONFIG_SCHEMA].get() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + def test_adcm_4778_cluster_variant_bug(self): + # problem is with absent service + bundle = self.add_bundle(self.test_bundles_dir / "bugs" / "ADCM-4778") + cluster = self.add_cluster(bundle, "cooler") + + response = self.client.v2[cluster, CONFIG_SCHEMA].get() + self.assertEqual(response.status_code, HTTP_200_OK) + def test_permissions_model_role_list_success(self): self.client.login(**self.test_user_credentials) with self.grant_permissions(to=self.test_user, on=[], role_name="View any object configuration"): @@ -252,8 +260,6 @@ def test_schema_cluster_permissions_another_object_role_denied(self): class TestSaveConfigWithoutRequiredField(BaseAPITestCase): - """ADCM-4328""" - def setUp(self) -> None: super().setUp() @@ -261,7 +267,7 @@ def setUp(self) -> None: service_names=["service_4_save_config_without_required_field"], cluster=self.cluster_1 ).get() - def test_save_empty_config_success(self): + def test_adcm_4328_save_empty_config_success(self): response = self.client.v2[self.service, CONFIGS].post(data={"config": {}, "adcmMeta": {}}) self.assertEqual(response.status_code, HTTP_201_CREATED) @@ -270,7 +276,7 @@ def test_save_empty_config_success(self): self.assertDictEqual(current_config, {}) - def test_save_config_without_not_required_map_in_group_success(self): + def test_adcm_4328_save_config_without_not_required_map_in_group_success(self): response = self.client.v2[self.service, CONFIGS].post( data={ "config": { @@ -283,7 +289,7 @@ def test_save_config_without_not_required_map_in_group_success(self): ) self.assertEqual(response.status_code, HTTP_201_CREATED) - def test_default_raw_config_success(self): + def test_adcm_4328_default_raw_config_success(self): default_config_without_secrets = ConfigLog.objects.get( obj_ref=self.service.config, id=self.service.config.current ).config diff --git a/python/api_v2/tests/test_group_config.py b/python/api_v2/tests/test_group_config.py index 7f649e32ce..990b3bbf35 100644 --- a/python/api_v2/tests/test_group_config.py +++ b/python/api_v2/tests/test_group_config.py @@ -220,9 +220,7 @@ def test_delete_host_success(self): self.assertEqual(self.host, Host.objects.get(id=self.host.pk)) self.assertNotIn(self.host, self.cluster_1_group_config.hosts.all()) - def test_config_description_inheritance(self): - """ADCM-5199""" - + def test_adcm_5199_config_description_inheritance(self): config_data = { "config": { "activatable_group": {"integer": 500}, @@ -525,9 +523,7 @@ def test_host_candidates_success(self): self.assertEqual(len(response.json()), 1) self.assertEqual(response.json()[0]["name"], self.host_for_service.name) - def test_config_description_inheritance(self): - """ADCM-5199""" - + def test_adcm_5199_config_description_inheritance(self): config_data = { "config": { "group": {"password": "new_password"}, @@ -761,9 +757,7 @@ def test_delete_host_success(self): self.assertIn(self.host, self.service_1_group_config.hosts.all()) self.assertNotIn(self.host, self.component_1_group_config.hosts.all()) - def test_config_description_inheritance(self): - """ADCM-5199""" - + def test_adcm_5199_config_description_inheritance(self): config_data = { "config": { "group": {"file": "new_content"}, @@ -953,9 +947,7 @@ def test_delete_host_success(self): self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.assertNotIn(self.host, self.group_config.hosts.all()) - def test_config_description_inheritance(self): - """ADCM-5199""" - + def test_adcm_5199_config_description_inheritance(self): config_data = { "config": { "group": {"map": {"integer_key": "99", "string_key": "new_string"}}, diff --git a/python/api_v2/tests/test_known_bugs.py b/python/api_v2/tests/test_known_bugs.py deleted file mode 100644 index d9ff5bb9e2..0000000000 --- a/python/api_v2/tests/test_known_bugs.py +++ /dev/null @@ -1,27 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from rest_framework.status import HTTP_200_OK - -from api_v2.tests.base import BaseAPITestCase - -CONFIG_SCHEMA = "config-schema" - - -class TestConfigBugs(BaseAPITestCase): - def test_cluster_variant_bug_adcm_4778(self): - # problem is with absent service - bundle = self.add_bundle(self.test_bundles_dir / "bugs" / "ADCM-4778") - cluster = self.add_cluster(bundle, "cooler") - - response = self.client.v2[cluster, CONFIG_SCHEMA].get() - self.assertEqual(response.status_code, HTTP_200_OK) diff --git a/python/api_v2/tests/test_profile.py b/python/api_v2/tests/test_profile.py index 40e859f7a5..a1d6eef748 100644 --- a/python/api_v2/tests/test_profile.py +++ b/python/api_v2/tests/test_profile.py @@ -16,7 +16,7 @@ class TestProfile(BaseAPITestCase): - def test_unauthenticated_access_adcm_4946_fail(self): + def test_adcm_4946_unauthenticated_access_fail(self): self.client.logout() path = self.client.v2["profile"].path diff --git a/python/api_v2/tests/test_tasks.py b/python/api_v2/tests/test_tasks.py index 1f28b55c49..5915f415c1 100644 --- a/python/api_v2/tests/test_tasks.py +++ b/python/api_v2/tests/test_tasks.py @@ -157,9 +157,7 @@ def test_adcm_5158_adcm_task_view_for_not_superuser_fail(self): response = (self.client.v2 / "tasks").get() self.assertNotIn(self.adcm_task.pk, [task["id"] for task in response.json()["results"]]) - def test_visibility_after_object_deletion(self): - """ADCM-4142""" - + def test_adcm_4142_visibility_after_object_deletion(self): cluster_admin_credentials = self.test_user_credentials cluster_admin = self.test_user service_admin_credentials = {"username": "service_admin_username", "password": "service_admin_passwo"} diff --git a/python/api_v2/tests/test_user.py b/python/api_v2/tests/test_user.py index cc76e742e1..e797cab342 100644 --- a/python/api_v2/tests/test_user.py +++ b/python/api_v2/tests/test_user.py @@ -550,7 +550,7 @@ def test_permissions_updated_on_group_change_success(self) -> None: HTTP_200_OK, ) - def test_update_remove_from_groups_bug_adcm_5355(self) -> None: + def test_adcm_5355_update_remove_from_groups_bug(self) -> None: group_2 = Group.objects.create(name="test_group_2") user = self.create_user( user_data={"username": "somebody", "password": "very_long_veryvery", "groups": [{"id": self.group.pk}]} @@ -764,7 +764,7 @@ def test_filtering_by_type_success(self): self.assertEqual(len(response.json()["results"]), 1) self.assertEqual(response.json()["results"][0]["username"], target_user.username) - def test_list_users_when_auth_group_has_no_rbac_group_bug_adcm_5495(self) -> None: + def test_adcm_5495_list_users_when_auth_group_has_no_rbac_group_bug(self) -> None: user = self.create_user(user_data={"username": "test_user", "password": "test_user_password"}) # In regular usage it's not the case that there are `auth_group` without corresponding `rbac_group`, # but this bug was originated from such situation. diff --git a/python/cm/tests/test_hc.py b/python/cm/tests/test_hc.py index cf50a6f876..9bc78ab9f7 100644 --- a/python/cm/tests/test_hc.py +++ b/python/cm/tests/test_hc.py @@ -177,7 +177,7 @@ def test_empty_hostcomponent(self): self.assertEqual(response.status_code, HTTP_200_OK) - def test_run_same_hc_bug_adcm_4929(self) -> None: + def test_adcm_4929_run_same_hc_bug(self) -> None: bundles_dir = Path(__file__).parent / "bundles" bundle = self.add_bundle(bundles_dir / "cluster_1") cluster = self.add_cluster(bundle=bundle, name="Cool") diff --git a/python/cm/tests/test_inventory/test_action_config.py b/python/cm/tests/test_inventory/test_action_config.py index 50e2ce03ca..c0370432f7 100644 --- a/python/cm/tests/test_inventory/test_action_config.py +++ b/python/cm/tests/test_inventory/test_action_config.py @@ -164,7 +164,7 @@ def test_action_config(self) -> None: self.assertDictEqual(job_config, expected_data) - def test_action_config_with_secrets_bug_adcm_5305(self): + def test_adcm_5305_action_config_with_secrets_bug(self): """ Actually bug is about `run_action`, because it prepares `config` for task, but it was caught within `prepare_ansible_job_config` generation, so checked here @@ -188,7 +188,7 @@ def test_action_config_with_secrets_bug_adcm_5305(self): self.assertIn("__ansible_vault", job_config["job"]["config"]["rolepass"]) self.assertEqual(ansible_decrypt(job_config["job"]["config"]["rolepass"]["__ansible_vault"]), raw_value) - def test_action_jinja_config_with_secrets_bug_adcm_5314(self): + def test_adcm_5314_action_jinja_config_with_secrets_bug(self): """ Actually bug is about `run_action`, because it prepares `config` for task, but it was caught within `get_job_config` generation, so checked here @@ -214,7 +214,7 @@ def test_action_jinja_config_with_secrets_bug_adcm_5314(self): self.assertIn("__ansible_vault", job_config["job"]["config"]["rolepass"]) self.assertEqual(ansible_decrypt(job_config["job"]["config"]["rolepass"]["__ansible_vault"]), raw_value) - def test_action_jinja_config_with_secret_map_and_default_null_password_bug_adcm_5314(self): + def test_adcm_5314_action_jinja_config_with_secret_map_and_default_null_password_bug(self): """ Actually bug is about `run_action`, because it prepares `config` for task, but it was caught within `get_job_config` generation, so checked here diff --git a/python/cm/tests/test_inventory/test_before_upgrade.py b/python/cm/tests/test_inventory/test_before_upgrade.py index cdfdceb4af..178971ca25 100644 --- a/python/cm/tests/test_inventory/test_before_upgrade.py +++ b/python/cm/tests/test_inventory/test_before_upgrade.py @@ -419,7 +419,7 @@ def test_group_config_effect_on_before_upgrade(self) -> None: expected_data=expected_data, ) - def test_bug_adcm_5367(self) -> None: + def test_adcm_5367_bug(self) -> None: another_1 = self.add_services_to_cluster( service_names=["another_service_two_components"], cluster=self.cluster_1 ).first() diff --git a/python/cm/tests/test_jinja_config.py b/python/cm/tests/test_jinja_config.py index 50c6145780..9ddbdb6b58 100644 --- a/python/cm/tests/test_jinja_config.py +++ b/python/cm/tests/test_jinja_config.py @@ -26,7 +26,7 @@ def setUp(self) -> None: self.bugs_bundle_dir = Path(__file__).parent / "bundles" / "bugs" - def test_incorrect_path_bug_adcm_5556(self) -> None: + def test_adcm_5556_incorrect_path_bug(self) -> None: expected_full_limits = { "root": { "match": "dict_key_selection", From 128a0ad7dce6f246b29e7b9479a152bda0981dfc Mon Sep 17 00:00:00 2001 From: Dmitry Bezrukov Date: Mon, 13 May 2024 08:20:52 +0000 Subject: [PATCH 168/208] ADCM-5539 Add agent field for Audit - /python/audit/cef_logger.py - /python/audit/middleware.py - /python/audit/migrations/0007_add_agent.py - /python/audit/models.py - /python/audit/utils.py Update cef_logger.py Fixed Line too long (122 > 120) Update middleware.py Fixed I001 [*] Import block is un-sorted or un-formatted Update 0007_add_agent.py Fixed W293 [*] Blank line contains whitespace and W292 [*] No newline at end of file Update 2 files - /python/audit/cef_logger.py - /python/audit/middleware.py Update file cef_logger.py Update 2 files - /python/audit/models.py - /python/audit/utils.py Update file 0007_add_agent.py Apply 1 suggestion(s) to 1 file(s) --- python/audit/cef_logger.py | 13 ++++++++- python/audit/middleware.py | 6 ++-- python/audit/migrations/0007_add_agent.py | 34 +++++++++++++++++++++++ python/audit/models.py | 2 ++ python/audit/utils.py | 5 ++++ 5 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 python/audit/migrations/0007_add_agent.py diff --git a/python/audit/cef_logger.py b/python/audit/cef_logger.py index 286a807559..1099db4c6c 100644 --- a/python/audit/cef_logger.py +++ b/python/audit/cef_logger.py @@ -28,7 +28,16 @@ class CEFLogConstants: device_product: str = "Arenadata Cluster Manager" adcm_version: str = settings.ADCM_VERSION operation_name_session: str = "User logged" - extension_keys: tuple[str, ...] = ("actor", "act", "operation", "resource", "result", "timestamp", "address") + extension_keys: tuple[str, ...] = ( + "actor", + "act", + "operation", + "resource", + "result", + "timestamp", + "address", + "agent", + ) def cef_logger( @@ -49,6 +58,7 @@ def cef_logger( extension["result"] = audit_instance.login_result extension["timestamp"] = str(audit_instance.login_time) extension["address"] = audit_instance.address + extension["agent"] = audit_instance.agent elif isinstance(audit_instance, AuditLog): operation_name = audit_instance.operation_name @@ -63,6 +73,7 @@ def cef_logger( severity = 3 extension["timestamp"] = str(audit_instance.operation_time) extension["address"] = audit_instance.address + extension["agent"] = audit_instance.agent else: raise NotImplementedError diff --git a/python/audit/middleware.py b/python/audit/middleware.py index fa4346f04e..83e555a6e9 100644 --- a/python/audit/middleware.py +++ b/python/audit/middleware.py @@ -24,7 +24,7 @@ from audit.cef_logger import cef_logger from audit.models import AuditSession, AuditSessionLoginResult, AuditUser -from audit.utils import get_client_ip +from audit.utils import get_client_agent, get_client_ip class LoginMiddleware: @@ -35,6 +35,7 @@ def __init__(self, get_response): def _audit( request_path: str, request_host: str | None, + request_agent: str | None, user: User | AnonymousUser | None = None, username: str = None, ) -> tuple[User | None, AuditSessionLoginResult]: @@ -59,7 +60,7 @@ def _audit( audit_user = AuditUser.objects.filter(username=user.username).order_by("-pk").first() audit_session = AuditSession.objects.create( - user=audit_user, login_result=result, login_details=details, address=request_host + user=audit_user, login_result=result, login_details=details, address=request_host, agent=request_agent ) cef_logger(audit_instance=audit_session, signature_id=resolve(request_path).route) @@ -165,6 +166,7 @@ def __call__(self, request): user, result = self._audit( request_path=request.path, request_host=get_client_ip(request=request), + request_agent=get_client_agent(request=request), user=request.user, username=username, ) diff --git a/python/audit/migrations/0007_add_agent.py b/python/audit/migrations/0007_add_agent.py new file mode 100644 index 0000000000..8527355ead --- /dev/null +++ b/python/audit/migrations/0007_add_agent.py @@ -0,0 +1,34 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated by Django 3.2.19 on 2023-11-14 08:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("audit", "0006_add_address"), + ] + + operations = [ + migrations.AddField( + model_name="auditlog", + name="agent", + field=models.CharField(max_length=255, blank=True, default=""), + ), + migrations.AddField( + model_name="auditsession", + name="agent", + field=models.CharField(max_length=255, blank=True, default=""), + ), + ] diff --git a/python/audit/models.py b/python/audit/models.py index 4e6f0d4fe5..b2f6087cbc 100644 --- a/python/audit/models.py +++ b/python/audit/models.py @@ -95,6 +95,7 @@ class AuditLog(Model): user = ForeignKey(AuditUser, on_delete=CASCADE, null=True) object_changes = JSONField(default=dict) address = CharField(max_length=255, null=True) + agent = CharField(max_length=255, blank=True, default="") class AuditSession(Model): @@ -103,6 +104,7 @@ class AuditSession(Model): login_time = DateTimeField(auto_now_add=True) login_details = JSONField(default=dict, null=True) address = CharField(max_length=255, null=True) + agent = CharField(max_length=255, blank=True, default="") @dataclass diff --git a/python/audit/utils.py b/python/audit/utils.py index 6ca5da2a34..f6879dfa1d 100644 --- a/python/audit/utils.py +++ b/python/audit/utils.py @@ -572,6 +572,7 @@ def wrapped(*args, **kwargs): user=audit_user, object_changes=object_changes, address=get_client_ip(request=request), + agent=get_client_agent(request=request), ) cef_logger(audit_instance=auditlog, signature_id=resolve(request.path).route) @@ -623,6 +624,10 @@ def get_client_ip(request: WSGIRequest) -> str | None: return host +def get_client_agent(request: WSGIRequest) -> str: + return request.META.get("HTTP_USER_AGENT", "")[:255] + + def audit_job_finish(owner: NamedCoreObject, display_name: str, is_upgrade: bool, job_result: ExecutionStatus) -> None: operation_name = f"{display_name} {'upgrade' if is_upgrade else 'action'} completed" From 1e3400aac97f05ebb4fbf9f182a248a30466280c Mon Sep 17 00:00:00 2001 From: Skrynnik Daniil Date: Tue, 14 May 2024 15:55:18 +0300 Subject: [PATCH 169/208] ADCM-5542: tests for `agent` audit field ADCM-5539: review fixes: headers --- python/adcm/tests/client.py | 18 ++--- python/api_v2/tests/test_audit/test_data.py | 78 +++++++++++++++++++++ 2 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 python/api_v2/tests/test_audit/test_data.py diff --git a/python/adcm/tests/client.py b/python/adcm/tests/client.py index 5458319522..f66f010000 100644 --- a/python/adcm/tests/client.py +++ b/python/adcm/tests/client.py @@ -68,17 +68,19 @@ def path(self) -> str: return self._resolved_path - def get(self, *, query: dict | None = None) -> Response: - return self._client.get(path=self.path, data=query) + def get(self, *, query: dict | None = None, headers: dict | None = None) -> Response: + return self._client.get(path=self.path, data=query, **(headers or {})) - def post(self, *, data: dict | list[dict] | None = None, format_: str | None = None) -> Response: - return self._client.post(path=self.path, data=data, format=format_) + def post( + self, *, data: dict | list[dict] | None = None, headers: dict | None = None, format_: str | None = None + ) -> Response: + return self._client.post(path=self.path, data=data, format=format_, **(headers or {})) - def patch(self, *, data: dict) -> Response: - return self._client.patch(path=self.path, data=data) + def patch(self, *, data: dict, headers: dict | None = None) -> Response: + return self._client.patch(path=self.path, data=data, **(headers or {})) - def delete(self) -> Response: - return self._client.delete(path=self.path) + def delete(self, headers: dict | None = None) -> Response: + return self._client.delete(path=self.path, **(headers or {})) class AsyncAPINode(APINode): diff --git a/python/api_v2/tests/test_audit/test_data.py b/python/api_v2/tests/test_audit/test_data.py new file mode 100644 index 0000000000..da73a27ff8 --- /dev/null +++ b/python/api_v2/tests/test_audit/test_data.py @@ -0,0 +1,78 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from audit.models import AuditLog, AuditSession +from cm.models import ObjectType, Prototype +from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED + +from api_v2.tests.base import BaseAPITestCase + + +class TestAgentFieldAudit(BaseAPITestCase): + def setUp(self) -> None: + super().setUp() + self.client.logout() + + self.admin_creds = {"username": "admin", "password": "admin"} + self.user_agent = "Test-User-Agent" + self.long_user_agent = self.user_agent * 256 + + def test_session_agent(self): + response = (self.client.v2 / "login").post(data=self.admin_creds, headers={"HTTP_USER_AGENT": self.user_agent}) + session = AuditSession.objects.order_by("pk").last() + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertIsNotNone(session) + self.assertEqual(session.agent, self.user_agent) + + self.client.logout() + + response = (self.client.v2 / "login").post(data=self.admin_creds) + session = AuditSession.objects.order_by("pk").last() + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertIsNotNone(session) + self.assertEqual(session.agent, "") + + def test_log_agent(self): + self.client.login(**self.admin_creds) + + response = (self.client.v2 / "clusters" / str(self.cluster_1.pk) / "services").post( + data=[ + { + "prototypeId": Prototype.objects.get( + type=ObjectType.SERVICE, name="service_3_manual_add", bundle_id=self.cluster_1.bundle_id + ).pk + } + ], + ) + log = AuditLog.objects.order_by("pk").last() + + self.assertEqual(response.status_code, HTTP_201_CREATED) + self.assertIsNotNone(log) + self.assertEqual(log.agent, "") + + response = (self.client.v2 / "clusters" / str(self.cluster_1.pk) / "services").post( + data=[ + { + "prototypeId": Prototype.objects.get( + type=ObjectType.SERVICE, name="service_1", bundle_id=self.cluster_1.bundle_id + ).pk + } + ], + headers={"HTTP_USER_AGENT": self.long_user_agent}, + ) + log = AuditLog.objects.order_by("pk").last() + + self.assertEqual(response.status_code, HTTP_201_CREATED) + self.assertIsNotNone(log) + self.assertEqual(log.agent, self.long_user_agent[:255]) From 13a911614ef668121d79e3cf7595bb9f9d4e16aa Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Tue, 11 Jun 2024 07:37:36 +0000 Subject: [PATCH 170/208] ADCM-5655: Add validation for undocumented parameters to plugins --- python/ansible_plugin/base.py | 13 +++--- python/ansible_plugin/executors/add_host.py | 4 +- .../executors/add_host_to_cluster.py | 5 ++- python/ansible_plugin/executors/check.py | 5 ++- python/ansible_plugin/executors/config.py | 5 ++- python/ansible_plugin/executors/custom_log.py | 5 ++- .../executors/delete_service.py | 4 +- .../ansible_plugin/executors/hostcomponent.py | 7 +-- .../executors/remove_host_from_cluster.py | 5 ++- .../tests/test_adcm_add_host.py | 20 +++++++++ .../tests/test_adcm_add_host_to_cluster.py | 16 +++++++ .../tests/test_adcm_change_flag.py | 13 ++++++ .../test_adcm_change_maintenance_mode.py | 18 ++++++++ .../ansible_plugin/tests/test_adcm_check.py | 19 ++++++++ .../tests/test_adcm_custom_log.py | 28 ++++++++++++ .../tests/test_adcm_delete_service.py | 17 ++++++++ python/ansible_plugin/tests/test_adcm_hc.py | 43 +++++++++++++++++++ .../test_adcm_remove_host_from_cluster.py | 20 ++++++++- 18 files changed, 224 insertions(+), 23 deletions(-) diff --git a/python/ansible_plugin/base.py b/python/ansible_plugin/base.py index 2017691961..2667959cea 100644 --- a/python/ansible_plugin/base.py +++ b/python/ansible_plugin/base.py @@ -38,6 +38,11 @@ PluginValidationError, ) + +class BaseStrictModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + # Input @@ -95,7 +100,7 @@ class RuntimeEnvironment(BaseModel): # Target -class ObjectWithType(BaseModel): +class ObjectWithType(BaseStrictModel): type: TargetTypeLiteral @field_validator("type", mode="before") @@ -133,14 +138,12 @@ def validate_args_allowed_for_type(self) -> Self: class BaseTypedArguments(CoreObjectTargetDescription): - model_config = ConfigDict(extra="forbid") + pass -class BaseArgumentsWithTypedObjects(BaseModel): +class BaseArgumentsWithTypedObjects(BaseStrictModel): objects: list[CoreObjectTargetDescription] = Field(default_factory=list) - model_config = ConfigDict(extra="forbid") - class TargetDetector(Protocol): def __call__( diff --git a/python/ansible_plugin/executors/add_host.py b/python/ansible_plugin/executors/add_host.py index 393ae1adba..a8c7c39751 100644 --- a/python/ansible_plugin/executors/add_host.py +++ b/python/ansible_plugin/executors/add_host.py @@ -17,11 +17,11 @@ from core.types import ADCMCoreType, CoreObjectDescriptor from django.db import IntegrityError from django.db.transaction import atomic -from pydantic import BaseModel from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, ArgumentsConfig, + BaseStrictModel, CallResult, ContextConfig, PluginExecutorConfig, @@ -31,7 +31,7 @@ from ansible_plugin.errors import PluginRuntimeError -class AddHostArguments(BaseModel): +class AddHostArguments(BaseStrictModel): fqdn: str description: str = "" diff --git a/python/ansible_plugin/executors/add_host_to_cluster.py b/python/ansible_plugin/executors/add_host_to_cluster.py index 518d8b7309..078776be7f 100644 --- a/python/ansible_plugin/executors/add_host_to_cluster.py +++ b/python/ansible_plugin/executors/add_host_to_cluster.py @@ -16,12 +16,13 @@ from cm.services.cluster import perform_host_to_cluster_map from cm.services.status import notify from core.types import ADCMCoreType, CoreObjectDescriptor -from pydantic import BaseModel, model_validator +from pydantic import model_validator from typing_extensions import Self from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, ArgumentsConfig, + BaseStrictModel, CallResult, ContextConfig, PluginExecutorConfig, @@ -31,7 +32,7 @@ from ansible_plugin.errors import PluginRuntimeError, PluginValidationError -class AddHostToClusterArguments(BaseModel): +class AddHostToClusterArguments(BaseStrictModel): fqdn: str | None = None host_id: int | None = None diff --git a/python/ansible_plugin/executors/check.py b/python/ansible_plugin/executors/check.py index 751d369d24..5de4046bd5 100644 --- a/python/ansible_plugin/executors/check.py +++ b/python/ansible_plugin/executors/check.py @@ -17,12 +17,13 @@ from cm.models import CheckLog, GroupCheckLog, JobLog, LogStorage from core.types import CoreObjectDescriptor from django.db.transaction import atomic -from pydantic import BaseModel, model_validator +from pydantic import model_validator from typing_extensions import Self from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, ArgumentsConfig, + BaseStrictModel, CallArguments, CallResult, PluginExecutorConfig, @@ -34,7 +35,7 @@ from ansible_plugin.utils import assign_view_logstorage_permissions_by_job -class CheckArguments(BaseModel): +class CheckArguments(BaseStrictModel): title: str result: bool msg: str | None = None diff --git a/python/ansible_plugin/executors/config.py b/python/ansible_plugin/executors/config.py index 308e1d27e2..c618ba38ab 100644 --- a/python/ansible_plugin/executors/config.py +++ b/python/ansible_plugin/executors/config.py @@ -23,12 +23,13 @@ from cm.status_api import send_config_creation_event from core.types import CoreObjectDescriptor from django.db.transaction import atomic -from pydantic import BaseModel, model_validator +from pydantic import model_validator from typing_extensions import Self from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, ArgumentsConfig, + BaseStrictModel, BaseTypedArguments, CallResult, PluginExecutorConfig, @@ -46,7 +47,7 @@ OriginalValues: TypeAlias = ConfigAttrPair -class ParameterToChange(BaseModel): +class ParameterToChange(BaseStrictModel): key: str value: ParamValue = None active: bool | None = None diff --git a/python/ansible_plugin/executors/custom_log.py b/python/ansible_plugin/executors/custom_log.py index 362882ac7d..4cf38d83a7 100644 --- a/python/ansible_plugin/executors/custom_log.py +++ b/python/ansible_plugin/executors/custom_log.py @@ -17,12 +17,13 @@ from cm.models import LogStorage from core.types import CoreObjectDescriptor from django.db.transaction import atomic -from pydantic import BaseModel, model_validator +from pydantic import model_validator from typing_extensions import Self from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, ArgumentsConfig, + BaseStrictModel, CallResult, PluginExecutorConfig, RuntimeEnvironment, @@ -30,7 +31,7 @@ from ansible_plugin.utils import assign_view_logstorage_permissions_by_job -class CustomLogArguments(BaseModel): +class CustomLogArguments(BaseStrictModel): name: str format: str path: Path | None = None diff --git a/python/ansible_plugin/executors/delete_service.py b/python/ansible_plugin/executors/delete_service.py index e3df7d73e3..e67e5db0de 100644 --- a/python/ansible_plugin/executors/delete_service.py +++ b/python/ansible_plugin/executors/delete_service.py @@ -16,11 +16,11 @@ from cm.models import ClusterBind, ClusterObject, HostComponent from core.types import ADCMCoreType, CoreObjectDescriptor from django.db.transaction import atomic -from pydantic import BaseModel from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, ArgumentsConfig, + BaseStrictModel, CallResult, ContextConfig, PluginExecutorConfig, @@ -29,7 +29,7 @@ from ansible_plugin.errors import PluginRuntimeError, PluginTargetDetectionError -class DeleteServiceArguments(BaseModel): +class DeleteServiceArguments(BaseStrictModel): service: str | None = None diff --git a/python/ansible_plugin/executors/hostcomponent.py b/python/ansible_plugin/executors/hostcomponent.py index 576d177499..6622cfc1a7 100644 --- a/python/ansible_plugin/executors/hostcomponent.py +++ b/python/ansible_plugin/executors/hostcomponent.py @@ -15,11 +15,12 @@ from cm.api import add_hc, get_hc from cm.models import Cluster, Host, JobLog, ServiceComponent from core.types import ADCMCoreType, CoreObjectDescriptor -from pydantic import BaseModel, field_validator +from pydantic import field_validator from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, ArgumentsConfig, + BaseStrictModel, CallResult, ContextConfig, PluginExecutorConfig, @@ -29,7 +30,7 @@ from ansible_plugin.errors import PluginIncorrectCallError, PluginRuntimeError, PluginValidationError -class Operation(BaseModel): +class Operation(BaseStrictModel): action: Literal["add", "remove"] service: str component: str @@ -42,7 +43,7 @@ def convert_action_to_string(cls, v: Any) -> str: return str(v) -class ChangeHostComponentArguments(BaseModel): +class ChangeHostComponentArguments(BaseStrictModel): operations: list[Operation] diff --git a/python/ansible_plugin/executors/remove_host_from_cluster.py b/python/ansible_plugin/executors/remove_host_from_cluster.py index db5e085724..47ad41e1b9 100644 --- a/python/ansible_plugin/executors/remove_host_from_cluster.py +++ b/python/ansible_plugin/executors/remove_host_from_cluster.py @@ -15,12 +15,13 @@ from cm.api import remove_host_from_cluster from cm.models import Host from core.types import ADCMCoreType, CoreObjectDescriptor -from pydantic import BaseModel, model_validator +from pydantic import model_validator from typing_extensions import Self from ansible_plugin.base import ( ADCMAnsiblePluginExecutor, ArgumentsConfig, + BaseStrictModel, CallResult, ContextConfig, PluginExecutorConfig, @@ -32,7 +33,7 @@ ) -class RemoveHostFromClusterArguments(BaseModel): +class RemoveHostFromClusterArguments(BaseStrictModel): fqdn: str | None = None host_id: int | None = None diff --git a/python/ansible_plugin/tests/test_adcm_add_host.py b/python/ansible_plugin/tests/test_adcm_add_host.py index e5860d0bfc..c1e8e38925 100644 --- a/python/ansible_plugin/tests/test_adcm_add_host.py +++ b/python/ansible_plugin/tests/test_adcm_add_host.py @@ -98,6 +98,26 @@ def test_add_host_success(self) -> None: self.assertTrue(result.changed) self.assertEqual(result.value, {"host_id": Host.objects.get(fqdn=fqdn).id}) + def test_add_host_forbidden_arg_fail(self): + task = self.prepare_task(owner=self.target_provider, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMAddHostPluginExecutor, + call_arguments=""" + fqdn: special + test: arg + description: this is the best host ever + """, + call_context=job, + ) + + with patch(f"{EXECUTOR_MODULE}.add_host") as add_host_mock: + result = executor.execute() + + self.assertIsNotNone(result.error) + add_host_mock.assert_not_called() + def test_duplicate_fqdn_fail(self) -> None: task = self.prepare_task(owner=self.target_provider, name="dummy") job, *_ = JobRepoImpl.get_task_jobs(task.id) diff --git a/python/ansible_plugin/tests/test_adcm_add_host_to_cluster.py b/python/ansible_plugin/tests/test_adcm_add_host_to_cluster.py index eae120168a..4e889194c3 100644 --- a/python/ansible_plugin/tests/test_adcm_add_host_to_cluster.py +++ b/python/ansible_plugin/tests/test_adcm_add_host_to_cluster.py @@ -104,6 +104,22 @@ def test_both_arguments_specified_success(self) -> None: self.host_1.refresh_from_db() self.assertIsNone(self.host_1.cluster_id) + def test_forbidden_arg_fail(self): + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + executor = self.prepare_executor( + executor_type=ADCMAddHostToClusterPluginExecutor, + call_arguments={"host_id": self.host_2.id, "some": "argument"}, + call_context=job, + ) + + result = executor.execute() + + self.assertIsNotNone(result.error) + self.assertFalse(result.changed) + self.host_2.refresh_from_db() + self.assertIsNone(self.host_2.cluster_id) + def test_absent_arguments_call_fail(self) -> None: task = self.prepare_task(owner=self.cluster, name="dummy") job, *_ = JobRepoImpl.get_task_jobs(task.id) diff --git a/python/ansible_plugin/tests/test_adcm_change_flag.py b/python/ansible_plugin/tests/test_adcm_change_flag.py index dd9c41965e..6046b192f5 100644 --- a/python/ansible_plugin/tests/test_adcm_change_flag.py +++ b/python/ansible_plugin/tests/test_adcm_change_flag.py @@ -236,6 +236,19 @@ def test_incorrect_name_fail(self) -> None: self.assertIsNotNone(result.error) self.assertIn("`name` should be at least 1 symbol", result.error.message) + def test_forbidden_arg_fail(self): + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMChangeFlagPluginExecutor, + call_arguments={"operation": "up", "name": "adcm_outdated_config", "test": "arg"}, + call_context=job, + ) + result = executor.execute() + + self.assertIsNotNone(result.error) + def test_hierarchy_is_updated_on_raise(self) -> None: flag_name = "custom" diff --git a/python/ansible_plugin/tests/test_adcm_change_maintenance_mode.py b/python/ansible_plugin/tests/test_adcm_change_maintenance_mode.py index a2986e78eb..58d8b5aa8e 100644 --- a/python/ansible_plugin/tests/test_adcm_change_maintenance_mode.py +++ b/python/ansible_plugin/tests/test_adcm_change_maintenance_mode.py @@ -127,3 +127,21 @@ def test_incorrect_type_fail(self) -> None: self.assertIsInstance(result.error, PluginValidationError) self.assertIn(f"plugin can't be called to change {type_}'s MM", result.error.message) + + def test_forbidden_arg_fail(self): + self.host_1.maintenance_mode = MaintenanceMode.CHANGING + self.host_1.save() + + task = self.prepare_task(owner=self.host_1, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMChangeMMExecutor, + call_arguments={"type": "host", "value": True, "arg": "ument"}, + call_context=job, + ) + + result = executor.execute() + self.assertIsNotNone(result.error) + self.host_1.refresh_from_db() + self.assertEqual(self.host_1.maintenance_mode, MaintenanceMode.CHANGING) diff --git a/python/ansible_plugin/tests/test_adcm_check.py b/python/ansible_plugin/tests/test_adcm_check.py index f4c1f8c8ad..20f129ac88 100644 --- a/python/ansible_plugin/tests/test_adcm_check.py +++ b/python/ansible_plugin/tests/test_adcm_check.py @@ -49,6 +49,25 @@ def test_adcm_check_success(self) -> None: self.assertIsNone(None) self.assertTrue(result.changed) + def test_adcm_check_forbidden_arg_fail(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMCheckPluginExecutor, + call_arguments=""" + title: title + result: true + msg: test_message + extraarg: somevalue + """, + call_context=job, + ) + result = executor.execute() + + self.assertIsNotNone(result.error) + self.assertFalse(result.changed) + def test_adcm_check_no_title_fail(self) -> None: task = self.prepare_task(owner=self.cluster, name="dummy") job, *_ = JobRepoImpl.get_task_jobs(task.id) diff --git a/python/ansible_plugin/tests/test_adcm_custom_log.py b/python/ansible_plugin/tests/test_adcm_custom_log.py index fc46bddec6..ae473b90d1 100644 --- a/python/ansible_plugin/tests/test_adcm_custom_log.py +++ b/python/ansible_plugin/tests/test_adcm_custom_log.py @@ -93,3 +93,31 @@ def test_path_priority_over_content(self) -> None: log = LogStorage.objects.filter(job_id=job.id, type="custom", format=format_, name=name).get() self.assertEqual(log.body, path) permissions_mock.assert_called_once() + + def test_forbidden_arg_fail(self): + name = "cool name" + format_ = "txt" + content = "bestcontent ever !!!" + + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=self.EXECUTOR_CLASS, + call_arguments=f""" + name: {name} + format: {format_} + somearg: somevalue + content: "{content}" + """, + call_context=job, + ) + + with patch(f"{EXECUTOR_MODULE}.assign_view_logstorage_permissions_by_job") as permissions_mock: + result = executor.execute() + + self.assertIsNotNone(result.error) + self.assertFalse( + LogStorage.objects.filter(job_id=job.id, type="custom", format=format_, name=name, body=content).exists() + ) + permissions_mock.assert_not_called() diff --git a/python/ansible_plugin/tests/test_adcm_delete_service.py b/python/ansible_plugin/tests/test_adcm_delete_service.py index 896365eed8..ae72c842ed 100644 --- a/python/ansible_plugin/tests/test_adcm_delete_service.py +++ b/python/ansible_plugin/tests/test_adcm_delete_service.py @@ -54,6 +54,23 @@ def test_delete_service_from_cluster_context_success(self) -> None: self.assertTrue(ClusterObject.objects.filter(pk=self.service_2.pk).exists()) self.assertEqual(HostComponent.objects.filter(cluster_id=self.cluster.pk).count(), 0) + def test_delete_service_forbidden_arg_fail(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + executor = self.prepare_executor( + executor_type=ADCMDeleteServicePluginExecutor, + call_arguments=""" + service: service_1 + argument: value + """, + call_context=job, + ) + + result = executor.execute() + + self.assertIsNotNone(result.error) + self.assertTrue(ClusterObject.objects.filter(pk=self.service_1.pk).exists()) + def test_delete_service_from_own_context(self) -> None: task = self.prepare_task(owner=self.service_2, name="dummy") job, *_ = JobRepoImpl.get_task_jobs(task.id) diff --git a/python/ansible_plugin/tests/test_adcm_hc.py b/python/ansible_plugin/tests/test_adcm_hc.py index af3b371deb..99f8bde0be 100644 --- a/python/ansible_plugin/tests/test_adcm_hc.py +++ b/python/ansible_plugin/tests/test_adcm_hc.py @@ -69,6 +69,49 @@ def test_simple_call_success(self) -> None: self.assertEqual(len(actual_hc), 2) self.assertListEqual(actual_hc, expected_hc) + def test_simple_call_forbidden_arg_fail(self) -> None: + self.set_hostcomponent( + cluster=self.cluster, + entries=((self.host_1, self.component_1),), + ) + hostcomponent = self.get_current_hc_dicts() + self.assertEqual(len(hostcomponent), 1) + expected_hc = [hostcomponent[0]] + + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + extra_arg_outer = f""" + test: arg + operations: + - action: add + service: {self.service_1.name} + component: {self.component_1.name} + host: {self.host_2.fqdn} + """ + extra_arg_inner = f""" + operations: + - action: add + test: arg + service: {self.service_1.name} + component: {self.component_1.name} + host: {self.host_2.fqdn} + """ + for invalid_args in (extra_arg_outer, extra_arg_inner): + with self.subTest(call_arguments=invalid_args): + executor = self.prepare_executor( + executor_type=ADCMHostComponentPluginExecutor, + call_arguments=invalid_args, + call_context=job, + ) + + result = executor.execute() + self.assertIsNotNone(result.error) + + actual_hc = sorted(self.get_current_hc_dicts(), key=itemgetter("host_id")) + self.assertEqual(len(actual_hc), 1) + self.assertListEqual(actual_hc, expected_hc) + def test_complex_call_success(self) -> None: service_2 = self.add_services_to_cluster(["service_2"], cluster=self.cluster).get() component_2 = self.service_1.servicecomponent_set.get(prototype__name="component_2") diff --git a/python/ansible_plugin/tests/test_adcm_remove_host_from_cluster.py b/python/ansible_plugin/tests/test_adcm_remove_host_from_cluster.py index f4e58528ff..f0564bb62f 100644 --- a/python/ansible_plugin/tests/test_adcm_remove_host_from_cluster.py +++ b/python/ansible_plugin/tests/test_adcm_remove_host_from_cluster.py @@ -173,6 +173,25 @@ def test_remove_host_from_cluster_no_arguments_fail(self) -> None: self.assertIsInstance(result.error, PluginValidationError) self.assertIn("either `fqdn` or `host_id` have to be specified", result.error.message) + def test_remove_host_from_cluster_forbidden_arg_fail(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMRemoveHostFromClusterPluginExecutor, + call_arguments=f""" + host_id: {self.host_1.id} + argument: value + """, + call_context=job, + ) + result = executor.execute() + + self.assertIsNotNone(result.error) + self.assertFalse(result.changed) + self.host_1.refresh_from_db() + self.assertIsNotNone(self.host_1.cluster_id) + def test_remove_host_from_cluster_wrong_arguments_fail(self) -> None: task = self.prepare_task(owner=self.cluster, name="dummy") job, *_ = JobRepoImpl.get_task_jobs(task.id) @@ -187,7 +206,6 @@ def test_remove_host_from_cluster_wrong_arguments_fail(self) -> None: result = executor.execute() self.assertIsInstance(result.error, PluginValidationError) - self.assertIn("either `fqdn` or `host_id` have to be specified", result.error.message) def test_remove_host_from_cluster_no_host_fail(self) -> None: task = self.prepare_task(owner=self.cluster, name="dummy") From 148af3ff6f64214ba85dbcf22d91dd5d83aa6758 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Thu, 13 Jun 2024 14:00:07 +0000 Subject: [PATCH 171/208] ADCM-5553 Improve messages for errors occurred during plugin calls --- python/ansible_plugin/base.py | 26 ++++++++++++++----- python/ansible_plugin/errors.py | 6 +++++ .../tests/test_adcm_add_host.py | 5 ++-- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/python/ansible_plugin/base.py b/python/ansible_plugin/base.py index 2667959cea..d3d04cf9a1 100644 --- a/python/ansible_plugin/base.py +++ b/python/ansible_plugin/base.py @@ -14,6 +14,7 @@ from dataclasses import dataclass from typing import Any, Collection, Generic, Literal, Mapping, Protocol, TypeAlias, TypeVar import fcntl +import traceback try: # TODO: refactor when python >= 3.11 from typing import Self @@ -36,6 +37,7 @@ PluginRuntimeError, PluginTargetDetectionError, PluginValidationError, + compose_validation_error_details_message, ) @@ -132,10 +134,14 @@ def validate_args_allowed_for_type(self) -> Self: raise PluginTargetDetectionError(f"Unsupported type: {self.type}") if error_fields := [field for field in forbidden if getattr(self, field) is not None]: - raise ValidationError(f"{', '.join(error_fields)} option(s) is not allowed for {self.type} type") + message = f"{', '.join(error_fields)} option(s) is not allowed for {self.type} type" + raise ValueError(message) return self + def __str__(self) -> str: + return ", ".join(f"{key}='value'" for key, value in self.model_dump(exclude_none=True)) + class BaseTypedArguments(CoreObjectTargetDescription): pass @@ -215,7 +221,7 @@ def _from_target_description( if context.service_id: return CoreObjectDescriptor(id=context.service_id, type=ADCMCoreType.SERVICE) - message = f"Can't identify service based on {target_description=}" + message = f"Can't identify service based on arguments: {target_description}" raise PluginRuntimeError(message=message) case "component": @@ -244,7 +250,7 @@ def _from_target_description( if context.component_id: return CoreObjectDescriptor(id=context.component_id, type=ADCMCoreType.COMPONENT) - message = f"Can't identify component based on {target_description=}" + message = f"Can't identify component based on arguments: {target_description}" raise PluginRuntimeError(message=message) case "provider": @@ -434,7 +440,13 @@ def execute(self) -> CallResult[ReturnValue]: except ADCMPluginError as err: return CallResult(value={}, changed=False, error=err) except Exception as err: # noqa: BLE001 - message = f"Unhandled exception occurred during {self.__class__.__name__} call: {err}" + message = "\n".join( + ( + f"Unhandled exception {err.__class__.__name__} occurred during {self.__class__.__name__} call.", + f"Message : {err}", + f"\n{traceback.format_exc()}", + ) + ) return CallResult(value={}, changed=False, error=PluginRuntimeError(message=message)) return result @@ -443,7 +455,8 @@ def _validate_inputs(self) -> tuple[CallArguments, AnsibleRuntimeVars]: try: arguments = self._config.arguments.represent_as(**self._raw_arguments) except ValidationError as err: - message = f"Arguments doesn't match expected schema:\n{err}" + field_errors = compose_validation_error_details_message(err) + message = f"Arguments doesn't match expected schema:\n{field_errors}" raise PluginValidationError(message=message) from err for validator in self._config.arguments.validators: @@ -454,7 +467,8 @@ def _validate_inputs(self) -> tuple[CallArguments, AnsibleRuntimeVars]: try: ansible_vars = AnsibleRuntimeVars(**self._raw_vars) except ValidationError as err: - message = f"Ansible variables doesn't match expected schema:\n{err}" + field_errors = compose_validation_error_details_message(err) + message = f"Ansible variables doesn't match expected schema:\n{field_errors}" raise PluginValidationError(message=message) from err return arguments, ansible_vars diff --git a/python/ansible_plugin/errors.py b/python/ansible_plugin/errors.py index f043e0c930..fd59b4baef 100644 --- a/python/ansible_plugin/errors.py +++ b/python/ansible_plugin/errors.py @@ -10,6 +10,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from pydantic import ValidationError + class ADCMPluginError(Exception): def __init__(self, message: str): @@ -39,3 +41,7 @@ class PluginContextError(ADCMPluginError): class PluginIncorrectCallError(ADCMPluginError): ... + + +def compose_validation_error_details_message(err: ValidationError) -> str: + return "\n".join(f"\t{'.'.join(error['loc'])} - {error['msg']}" for error in err.errors()) diff --git a/python/ansible_plugin/tests/test_adcm_add_host.py b/python/ansible_plugin/tests/test_adcm_add_host.py index c1e8e38925..1bac355371 100644 --- a/python/ansible_plugin/tests/test_adcm_add_host.py +++ b/python/ansible_plugin/tests/test_adcm_add_host.py @@ -16,7 +16,7 @@ from cm.models import Host, Prototype, ServiceComponent from cm.services.job.run.repo import JobRepoImpl -from ansible_plugin.errors import PluginContextError, PluginRuntimeError +from ansible_plugin.errors import PluginContextError, PluginRuntimeError, PluginValidationError from ansible_plugin.executors.add_host import ADCMAddHostPluginExecutor from ansible_plugin.tests.base import BaseTestEffectsOfADCMAnsiblePlugins @@ -115,7 +115,8 @@ def test_add_host_forbidden_arg_fail(self): with patch(f"{EXECUTOR_MODULE}.add_host") as add_host_mock: result = executor.execute() - self.assertIsNotNone(result.error) + self.assertIsInstance(result.error, PluginValidationError) + self.assertIn("test - Extra inputs are not permitted", result.error.message) add_host_mock.assert_not_called() def test_duplicate_fqdn_fail(self) -> None: From 7e910d126afcff97f62b077c9355db85a14c5170 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Fri, 14 Jun 2024 09:56:52 +0000 Subject: [PATCH 172/208] ADCM-5649 API for Action Host Groups --- python/adcm/mixins.py | 2 +- python/adcm/tests/client.py | 4 + python/api_v2/action/views.py | 47 +- python/api_v2/action_host_group/__init__.py | 11 + .../api_v2/action_host_group/serializers.py | 58 ++ python/api_v2/action_host_group/views.py | 173 ++++++ python/api_v2/cluster/urls.py | 140 +++-- python/api_v2/task/serializers.py | 9 +- .../cluster_action_host_group/config.yaml | 43 ++ python/api_v2/tests/test_action_host_group.py | 565 ++++++++++++++++++ python/api_v2/views.py | 131 +++- python/audit/models.py | 3 + python/cm/converters.py | 15 +- python/cm/errors.py | 2 + .../cm/migrations/0126_action_host_group.py | 4 + python/cm/models.py | 10 +- python/cm/services/action_host_group.py | 157 ++++- python/cm/tests/test_action_host_group.py | 6 +- python/core/types.py | 13 + 19 files changed, 1283 insertions(+), 110 deletions(-) create mode 100644 python/api_v2/action_host_group/__init__.py create mode 100644 python/api_v2/action_host_group/serializers.py create mode 100644 python/api_v2/action_host_group/views.py create mode 100644 python/api_v2/tests/bundles/cluster_action_host_group/config.yaml create mode 100644 python/api_v2/tests/test_action_host_group.py diff --git a/python/adcm/mixins.py b/python/adcm/mixins.py index 9e8b7e060d..d17a2a4f1f 100644 --- a/python/adcm/mixins.py +++ b/python/adcm/mixins.py @@ -68,7 +68,7 @@ def get_parent_object(self) -> ParentObject: parent_object = GroupConfig.objects.get( pk=self.kwargs["group_config_pk"], object_id=parent_object.pk, - object_type=ContentType.objects.get_for_model(model=parent_object), + object_type=ContentType.objects.get_for_model(model=parent_object.__class__), ) return parent_object diff --git a/python/adcm/tests/client.py b/python/adcm/tests/client.py index 5458319522..3435b7975c 100644 --- a/python/adcm/tests/client.py +++ b/python/adcm/tests/client.py @@ -15,6 +15,7 @@ from typing import Protocol from cm.models import ( + ActionHostGroup, Bundle, Cluster, ClusterObject, @@ -161,6 +162,9 @@ def __getitem__(self, item: PathObject | tuple[PathObject, str | int | WithID, . # generally it's move clean and obvious when multiple `/` is used, but in here it looks like an overkill return self[path_object.object] / "/".join(("config-groups", str(path_object.id), *tail)) + if isinstance(path_object, ActionHostGroup): + return self[path_object.object] / "/".join(("action-host-groups", str(path_object.id), *tail)) + if isinstance(path_object, LogStorage): return self._node_class( *self._path, diff --git a/python/api_v2/action/views.py b/python/api_v2/action/views.py index 6d493eb6b4..0a9f0c618d 100644 --- a/python/api_v2/action/views.py +++ b/python/api_v2/action/views.py @@ -15,11 +15,12 @@ from adcm.mixins import GetParentObjectMixin from audit.utils import audit from cm.errors import AdcmEx -from cm.models import ADCM, Action, ConcernType, Host, HostComponent, PrototypeConfig +from cm.models import ADCM, Action, ActionHostGroup, ConcernType, Host, HostComponent, PrototypeConfig from cm.services.job.action import ActionRunPayload, run_action from cm.stack import check_hostcomponents_objects_exist from django.conf import settings -from django.db.models import Q +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q, QuerySet from django_filters.rest_framework.backends import DjangoFilterBackend from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from jinja_config import get_jinja_config @@ -119,6 +120,12 @@ class ActionViewSet(ListModelMixin, RetrieveModelMixin, GetParentObjectMixin, CamelCaseGenericViewSet): filter_backends = (DjangoFilterBackend,) filterset_class = ActionFilter + general_queryset = ( + Action.objects.select_related("prototype") + .exclude(name__in=settings.ADCM_SERVICE_ACTION_NAMES_SET) + .filter(upgrade__isnull=True) + .order_by("pk") + ) def get_queryset(self, *args, **kwargs): # noqa: ARG002 if self.parent_object is None or self.parent_object.concerns.filter(type=ConcernType.LOCK).exists(): @@ -135,16 +142,9 @@ def get_queryset(self, *args, **kwargs): # noqa: ARG002 self.prototype_objects[hc_item.service.prototype] = hc_item.service self.prototype_objects[hc_item.component.prototype] = hc_item.component - actions = ( - Action.objects.all() - .select_related("prototype") - .exclude(name__in=settings.ADCM_SERVICE_ACTION_NAMES_SET) - .filter(upgrade__isnull=True) - .filter( - Q(prototype=self.parent_object.prototype, host_action=False) - | Q(prototype__in=self.prototype_objects.keys(), host_action=True) - ) - .order_by("pk") + actions = self.general_queryset.filter( + Q(prototype=self.parent_object.prototype, host_action=False) + | Q(prototype__in=self.prototype_objects.keys(), host_action=True) ) self.prototype_objects[self.parent_object.prototype] = self.parent_object @@ -308,3 +308,26 @@ def get_parent_object(self): def list(self, request: Request, *args, **kwargs) -> Response: # noqa: ARG002 self.parent_object = self.get_parent_object() return self._list_actions_available_to_user(request) + + +class ActionHostGroupActionViewSet(ActionViewSet): + def get_parent_object(self) -> ActionHostGroup | None: + if "action_host_group_pk" not in self.kwargs: + return None + + parent = super().get_parent_object() + + return ( + ActionHostGroup.objects.prefetch_related("object__prototype") + .filter( + pk=self.kwargs["action_host_group_pk"], + object_id=parent.pk, + object_type=ContentType.objects.get_for_model(model=parent.__class__), + ) + .first() + ) + + def get_queryset(self, *_, **__) -> QuerySet: + group_owner = self.parent_object.object + self.prototype_objects = {group_owner.prototype: group_owner} + return self.general_queryset.filter(prototype=group_owner.prototype, allow_for_action_host_group=True) diff --git a/python/api_v2/action_host_group/__init__.py b/python/api_v2/action_host_group/__init__.py new file mode 100644 index 0000000000..824dd6c8fe --- /dev/null +++ b/python/api_v2/action_host_group/__init__.py @@ -0,0 +1,11 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/python/api_v2/action_host_group/serializers.py b/python/api_v2/action_host_group/serializers.py new file mode 100644 index 0000000000..45c9028ea7 --- /dev/null +++ b/python/api_v2/action_host_group/serializers.py @@ -0,0 +1,58 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from operator import itemgetter + +from adcm.serializers import EmptySerializer +from cm.models import ActionHostGroup +from rest_framework.fields import CharField, IntegerField, SerializerMethodField +from rest_framework.serializers import ModelSerializer + + +class ShortHostSerializer(EmptySerializer): + id = IntegerField() + name = CharField() + + +class AddHostSerializer(EmptySerializer): + host_id = IntegerField() + + +class ActionHostGroupCreateSerializer(EmptySerializer): + name = CharField(max_length=150) + description = CharField(max_length=255, allow_blank=True) + + +class ActionHostGroupSerializer(ModelSerializer): + id = IntegerField() + name = CharField(max_length=150) + description = CharField(max_length=255) + hosts = SerializerMethodField() + + def get_hosts(self, group: ActionHostGroup) -> list: + # NOTE: + # Here we return "unpaginated" list of hosts, so if there will be lots of them, there may be problems with: + # - sorting here instead of DB (use `Prefetch` object with order) + # - prefetching WHOLE hosts, when the only thing we require is "id" and "name" ("fqdn" field in DB) + # - prefetching ALL hosts (this one will require API changes => impossible to solve at this level) + # + # See implementation of `ActionHostGroupViewSet` for more details + return sorted(ShortHostSerializer(instance=group.hosts.all(), many=True).data, key=itemgetter("name")) + + class Meta: + model = ActionHostGroup + fields = ("id", "name", "description", "hosts") + + +class ActionHostGroupCreateResultSerializer(ActionHostGroupSerializer): + def get_hosts(self, _: ActionHostGroup) -> list: + return [] diff --git a/python/api_v2/action_host_group/views.py b/python/api_v2/action_host_group/views.py new file mode 100644 index 0000000000..dbc449a3de --- /dev/null +++ b/python/api_v2/action_host_group/views.py @@ -0,0 +1,173 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from audit.utils import audit +from cm.converters import core_type_to_model +from cm.errors import AdcmEx +from cm.models import ActionHostGroup, Cluster, Host +from cm.services.action_host_group import ( + ActionHostGroupRepo, + ActionHostGroupService, + CreateDTO, + GroupIsLockedError, + HostError, + NameCollisionError, +) +from core.types import ADCMCoreType, CoreObjectDescriptor, HostGroupDescriptor +from django.contrib.contenttypes.models import ContentType +from django.db.models import F, QuerySet +from django.db.transaction import atomic +from rest_framework.decorators import action +from rest_framework.exceptions import NotFound +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import Serializer +from rest_framework.status import HTTP_201_CREATED, HTTP_204_NO_CONTENT + +from api_v2.action_host_group.serializers import ( + ActionHostGroupCreateResultSerializer, + ActionHostGroupCreateSerializer, + ActionHostGroupSerializer, + AddHostSerializer, + ShortHostSerializer, +) +from api_v2.views import CamelCaseGenericViewSet, with_group_object, with_parent_object + + +class ActionHostGroupViewSet(CamelCaseGenericViewSet): + queryset = ActionHostGroup.objects.prefetch_related("hosts").order_by("id") + action_host_group_service = ActionHostGroupService(repository=ActionHostGroupRepo()) + + def get_serializer_class(self) -> type[Serializer]: + if self.action == "create": + return ActionHostGroupCreateSerializer + + if self.action == "host_candidate": + return ShortHostSerializer + + return ActionHostGroupSerializer + + @audit + @with_parent_object + def create(self, request: Request, *_, parent: CoreObjectDescriptor, **__) -> Response: + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + with atomic(): + new_group_id = self.action_host_group_service.create( + dto=CreateDTO(owner=parent, **serializer.validated_data) + ) + except NameCollisionError: + message = ( + f"Action host group with name {serializer.validated_data['name']} already exists " + f"for {parent.type.value} {self.get_parent_name(parent=parent)}" + ) + raise AdcmEx("CREATE_CONFLICT", msg=message) from None + + return Response( + data=ActionHostGroupCreateResultSerializer(instance=ActionHostGroup.objects.get(id=new_group_id)).data, + status=HTTP_201_CREATED, + ) + + @with_parent_object + def list(self, *_, parent: CoreObjectDescriptor, **__) -> Response: + serializer = self.get_serializer( + instance=self.paginate_queryset(self.filter_by_parent(qs=self.get_queryset(), parent=parent)), many=True + ) + return self.get_paginated_response(serializer.data) + + @with_parent_object + def retrieve(self, *_, parent: CoreObjectDescriptor, pk: str, **__) -> Response: + try: + instance = self.filter_by_parent(qs=self.get_queryset(), parent=parent).get(id=int(pk)) + except ActionHostGroup.DoesNotExist: + raise NotFound() from None + + return Response(data=self.get_serializer(instance=instance).data) + + @with_parent_object + def destroy(self, *_, parent: CoreObjectDescriptor, pk: str, **__) -> Response: + if not self.filter_by_parent(qs=ActionHostGroup.objects.filter(id=pk), parent=parent).exists(): + raise NotFound() + + try: + self.action_host_group_service.delete(group_id=int(pk)) + except GroupIsLockedError as err: + raise AdcmEx(code="TASK_ERROR", msg=err.message) from None + + return Response(status=HTTP_204_NO_CONTENT) + + @action(methods=["get"], detail=True, url_path="host-candidates", url_name="host-candidates", pagination_class=None) + @with_parent_object + def host_candidate(self, *_, parent: CoreObjectDescriptor, pk: str, **__): + if not self.filter_by_parent(qs=ActionHostGroup.objects.filter(id=pk), parent=parent).exists(): + raise NotFound() + + hosts = self.action_host_group_service.get_host_candidates(group_id=int(pk)) + return Response(data=list(Host.objects.values("id", name=F("fqdn")).filter(id__in=hosts).order_by("fqdn"))) + + def filter_by_parent(self, qs: QuerySet, parent: CoreObjectDescriptor) -> QuerySet: + return qs.filter( + object_id=parent.id, object_type=ContentType.objects.get_for_model(core_type_to_model(parent.type)) + ) + + def get_parent_name(self, parent: CoreObjectDescriptor) -> str: + if parent.type == ADCMCoreType.CLUSTER: + return Cluster.objects.values_list("name", flat=True).get(id=parent.id) + + return ( + core_type_to_model(parent.type) + .objects.values_list("prototype__display_name", flat=True) + .filter(id=parent.id) + .get() + ) + + +class HostActionHostGroupViewSet(CamelCaseGenericViewSet): + serializer_class = AddHostSerializer + action_host_group_service = ActionHostGroupService(repository=ActionHostGroupRepo()) + + def handle_exception(self, exc: Exception) -> None: + if isinstance(exc, HostError): + exc = AdcmEx(code="HOST_GROUP_CONFLICT", msg=exc.message) + elif isinstance(exc, GroupIsLockedError): + exc = AdcmEx(code="TASK_ERROR", msg=exc.message) + + return super().handle_exception(exc) + + @audit + @with_group_object + def create(self, request: Request, *_, host_group: HostGroupDescriptor, **__) -> Response: + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + host_id = serializer.validated_data["host_id"] + + with atomic(): + self.action_host_group_service.add_hosts_to_group(group_id=host_group.id, hosts=[host_id]) + + return Response( + data=ShortHostSerializer(instance=Host.objects.values("id", name=F("fqdn")).get(id=host_id)).data, + status=HTTP_201_CREATED, + ) + + @audit + @with_group_object + def destroy(self, *_, host_group: HostGroupDescriptor, pk: str, **__) -> Response: + if not ActionHostGroup.hosts.through.objects.filter(actionhostgroup_id=host_group.id, host_id=pk).exists(): + raise NotFound() + + with atomic(): + self.action_host_group_service.remove_hosts_from_group(group_id=host_group.id, hosts=[int(pk)]) + + return Response(status=HTTP_204_NO_CONTENT) diff --git a/python/api_v2/cluster/urls.py b/python/api_v2/cluster/urls.py index a3a29b65ae..b9f55a80bf 100644 --- a/python/api_v2/cluster/urls.py +++ b/python/api_v2/cluster/urls.py @@ -10,9 +10,13 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Iterable +import itertools + from rest_framework_nested.routers import NestedSimpleRouter, SimpleRouter -from api_v2.action.views import ActionViewSet +from api_v2.action.views import ActionHostGroupActionViewSet, ActionViewSet +from api_v2.action_host_group.views import ActionHostGroupViewSet, HostActionHostGroupViewSet from api_v2.cluster.views import ClusterViewSet from api_v2.component.views import ComponentViewSet, HostComponentViewSet from api_v2.config.views import ConfigLogViewSet @@ -30,70 +34,99 @@ CONFIG_PREFIX = "configs" IMPORT_PREFIX = "imports" CONFIG_GROUPS_PREFIX = "config-groups" +ACTION_HOST_GROUPS_PREFIX = "action-host-groups" + + +def extract_urls_from_routers(routers: Iterable[NestedSimpleRouter]) -> tuple[str, ...]: + return tuple(itertools.chain.from_iterable(router.urls for router in routers)) + + +def add_group_config_routers( + parent_router: NestedSimpleRouter | SimpleRouter, parent_prefix: str, lookup: str +) -> tuple[NestedSimpleRouter, ...]: + group_config_router = NestedSimpleRouter(parent_router=parent_router, parent_prefix=parent_prefix, lookup=lookup) + group_config_router.register( + prefix=CONFIG_GROUPS_PREFIX, viewset=GroupConfigViewSet, basename=f"{lookup}-group-config" + ) + + hosts_router = NestedSimpleRouter(group_config_router, CONFIG_GROUPS_PREFIX, lookup="group_config") + hosts_router.register(prefix=r"hosts", viewset=HostGroupConfigViewSet, basename=f"{lookup}-group-config-hosts") + + config_router = NestedSimpleRouter( + parent_router=group_config_router, parent_prefix=CONFIG_GROUPS_PREFIX, lookup="group_config" + ) + config_router.register(prefix=CONFIG_PREFIX, viewset=ConfigLogViewSet, basename=f"{lookup}-group-config-config") + + return group_config_router, hosts_router, config_router + + +def add_action_host_groups_routers( + parent_router: NestedSimpleRouter | SimpleRouter, parent_prefix: str, lookup: str +) -> tuple[NestedSimpleRouter, ...]: + action_host_groups_router = NestedSimpleRouter( + parent_router=parent_router, parent_prefix=parent_prefix, lookup=lookup + ) + action_host_groups_router.register( + prefix=ACTION_HOST_GROUPS_PREFIX, viewset=ActionHostGroupViewSet, basename=f"{lookup}-action-host-group" + ) + + action_host_groups_actions_router = NestedSimpleRouter( + parent_router=action_host_groups_router, parent_prefix=ACTION_HOST_GROUPS_PREFIX, lookup="action_host_group" + ) + action_host_groups_actions_router.register( + prefix=ACTION_PREFIX, viewset=ActionHostGroupActionViewSet, basename=f"{lookup}-action-host-group-action" + ) + + action_host_groups_hosts_router = NestedSimpleRouter( + parent_router=action_host_groups_router, parent_prefix=ACTION_HOST_GROUPS_PREFIX, lookup="action_host_group" + ) + action_host_groups_hosts_router.register( + prefix=HOST_PREFIX, viewset=HostActionHostGroupViewSet, basename=f"{lookup}-action-host-group-host" + ) + + return action_host_groups_router, action_host_groups_actions_router, action_host_groups_hosts_router + # cluster cluster_router = SimpleRouter() cluster_router.register(prefix=CLUSTER_PREFIX, viewset=ClusterViewSet) +import_cluster_router = NestedSimpleRouter(parent_router=cluster_router, parent_prefix=CLUSTER_PREFIX, lookup="cluster") +import_cluster_router.register(prefix=IMPORT_PREFIX, viewset=ClusterImportViewSet, basename="cluster-import") + cluster_action_router = NestedSimpleRouter(parent_router=cluster_router, parent_prefix=CLUSTER_PREFIX, lookup="cluster") cluster_action_router.register(prefix=ACTION_PREFIX, viewset=ActionViewSet, basename="cluster-action") cluster_config_router = NestedSimpleRouter(parent_router=cluster_router, parent_prefix=CLUSTER_PREFIX, lookup="cluster") cluster_config_router.register(prefix=CONFIG_PREFIX, viewset=ConfigLogViewSet, basename="cluster-config") -cluster_group_config_router = NestedSimpleRouter( +cluster_config_group_routers = add_group_config_routers( parent_router=cluster_router, parent_prefix=CLUSTER_PREFIX, lookup="cluster" ) -cluster_group_config_router.register( - prefix=CONFIG_GROUPS_PREFIX, viewset=GroupConfigViewSet, basename="cluster-group-config" -) -cluster_group_config_hosts_router = NestedSimpleRouter( - cluster_group_config_router, CONFIG_GROUPS_PREFIX, lookup="group_config" -) -cluster_group_config_hosts_router.register( - prefix=r"hosts", viewset=HostGroupConfigViewSet, basename="cluster-group-config-hosts" -) -cluster_group_config_config_router = NestedSimpleRouter( - parent_router=cluster_group_config_router, parent_prefix=CONFIG_GROUPS_PREFIX, lookup="group_config" -) -cluster_group_config_config_router.register( - prefix=CONFIG_PREFIX, viewset=ConfigLogViewSet, basename="cluster-group-config-config" +cluster_action_host_groups_routers = add_action_host_groups_routers( + parent_router=cluster_router, parent_prefix=CLUSTER_PREFIX, lookup="cluster" ) -import_cluster_router = NestedSimpleRouter(parent_router=cluster_router, parent_prefix=CLUSTER_PREFIX, lookup="cluster") -import_cluster_router.register(prefix=IMPORT_PREFIX, viewset=ClusterImportViewSet, basename="cluster-import") # service service_router = NestedSimpleRouter(parent_router=cluster_router, parent_prefix=CLUSTER_PREFIX, lookup="cluster") service_router.register(prefix=SERVICE_PREFIX, viewset=ServiceViewSet, basename="service") +import_service_router = NestedSimpleRouter(parent_router=service_router, parent_prefix=SERVICE_PREFIX, lookup="service") +import_service_router.register(prefix=IMPORT_PREFIX, viewset=ServiceImportViewSet, basename="service-import") + service_action_router = NestedSimpleRouter(parent_router=service_router, parent_prefix=SERVICE_PREFIX, lookup="service") service_action_router.register(prefix=ACTION_PREFIX, viewset=ActionViewSet, basename="service-action") service_config_router = NestedSimpleRouter(parent_router=service_router, parent_prefix=SERVICE_PREFIX, lookup="service") service_config_router.register(prefix=CONFIG_PREFIX, viewset=ConfigLogViewSet, basename="service-config") -service_group_config_router = NestedSimpleRouter( +service_group_config_routers = add_group_config_routers( parent_router=service_router, parent_prefix=SERVICE_PREFIX, lookup="service" ) -service_group_config_router.register( - prefix=CONFIG_GROUPS_PREFIX, viewset=GroupConfigViewSet, basename="service-group-config" -) - -service_group_config_config_router = NestedSimpleRouter( - parent_router=service_group_config_router, parent_prefix=CONFIG_GROUPS_PREFIX, lookup="group_config" -) -service_group_config_config_router.register( - prefix=CONFIG_PREFIX, viewset=ConfigLogViewSet, basename="service-group-config-config" -) -service_group_config_hosts_router = NestedSimpleRouter( - service_group_config_router, CONFIG_GROUPS_PREFIX, lookup="group_config" -) -service_group_config_hosts_router.register( - prefix=r"hosts", viewset=HostGroupConfigViewSet, basename="service-group-config-hosts" +service_action_host_groups_routers = add_action_host_groups_routers( + parent_router=service_router, parent_prefix=SERVICE_PREFIX, lookup="service" ) -import_service_router = NestedSimpleRouter(parent_router=service_router, parent_prefix=SERVICE_PREFIX, lookup="service") -import_service_router.register(prefix=IMPORT_PREFIX, viewset=ServiceImportViewSet, basename="service-import") # component component_router = NestedSimpleRouter(parent_router=service_router, parent_prefix=SERVICE_PREFIX, lookup="service") @@ -109,27 +142,13 @@ ) component_config_router.register(prefix=CONFIG_PREFIX, viewset=ConfigLogViewSet, basename="component-config") -component_group_config_router = NestedSimpleRouter( +component_group_config_routers = add_group_config_routers( parent_router=component_router, parent_prefix=COMPONENT_PREFIX, lookup="component" ) -component_group_config_router.register( - prefix=CONFIG_GROUPS_PREFIX, viewset=GroupConfigViewSet, basename="component-group-config" -) - -component_group_config_config_router = NestedSimpleRouter( - parent_router=component_group_config_router, parent_prefix=CONFIG_GROUPS_PREFIX, lookup="group_config" -) -component_group_config_config_router.register( - prefix=CONFIG_PREFIX, viewset=ConfigLogViewSet, basename="component-group-config-config" -) -component_group_config_hosts_router = NestedSimpleRouter( - component_group_config_router, CONFIG_GROUPS_PREFIX, lookup="group_config" -) -component_group_config_hosts_router.register( - prefix=r"hosts", viewset=HostGroupConfigViewSet, basename="component-group-config-hosts" +component_action_host_groups_routers = add_action_host_groups_routers( + parent_router=component_router, parent_prefix=COMPONENT_PREFIX, lookup="component" ) - # host host_router = NestedSimpleRouter(parent_router=cluster_router, parent_prefix=CLUSTER_PREFIX, lookup="cluster") host_router.register(prefix=HOST_PREFIX, viewset=HostClusterViewSet, basename="host-cluster") @@ -150,25 +169,22 @@ *cluster_router.urls, *cluster_action_router.urls, *cluster_config_router.urls, - *cluster_group_config_router.urls, - *cluster_group_config_config_router.urls, - *cluster_group_config_hosts_router.urls, *import_cluster_router.urls, + *extract_urls_from_routers(cluster_config_group_routers), + *extract_urls_from_routers(cluster_action_host_groups_routers), # service *service_router.urls, *service_action_router.urls, *service_config_router.urls, - *service_group_config_router.urls, - *service_group_config_config_router.urls, - *service_group_config_hosts_router.urls, *import_service_router.urls, + *extract_urls_from_routers(service_group_config_routers), + *extract_urls_from_routers(service_action_host_groups_routers), # component *component_router.urls, *component_action_router.urls, *component_config_router.urls, - *component_group_config_router.urls, - *component_group_config_config_router.urls, - *component_group_config_hosts_router.urls, + *extract_urls_from_routers(component_group_config_routers), + *extract_urls_from_routers(component_action_host_groups_routers), # host *host_router.urls, *host_action_router.urls, diff --git a/python/api_v2/task/serializers.py b/python/api_v2/task/serializers.py index 0e1e4bc0ef..52926cfb06 100644 --- a/python/api_v2/task/serializers.py +++ b/python/api_v2/task/serializers.py @@ -16,14 +16,7 @@ from api_v2.action.serializers import ActionNameSerializer -OBJECT_ORDER = { - "adcm": 0, - "cluster": 1, - "service": 2, - "component": 3, - "provider": 4, - "host": 5, -} +OBJECT_ORDER = {"adcm": 0, "cluster": 1, "service": 2, "component": 3, "provider": 4, "host": 5, "action_host_group": 6} class JobListSerializer(ModelSerializer): diff --git a/python/api_v2/tests/bundles/cluster_action_host_group/config.yaml b/python/api_v2/tests/bundles/cluster_action_host_group/config.yaml new file mode 100644 index 0000000000..9a2834a3db --- /dev/null +++ b/python/api_v2/tests/bundles/cluster_action_host_group/config.yaml @@ -0,0 +1,43 @@ +- type: cluster + name: with_action_host_group + version: 2 + + actions: &actions + regular: &job + type: job + script: ./stuff.yaml + script_type: ansible + masking: + + on_host: + <<: *job + host_action: true + + allowed_in_group_1: &allowed + <<: *job + allow_for_action_host_group: true + +- type: service + name: example + version: 3 + + actions: + <<: *actions + + allowed_from_service: *allowed + + components: + example: + actions: + <<: *actions + + allowed_from_component: *allowed + +- type: service + name: second + version: 4 + + components: + c1: {} + c2: {} + diff --git a/python/api_v2/tests/test_action_host_group.py b/python/api_v2/tests/test_action_host_group.py new file mode 100644 index 0000000000..e67a348d77 --- /dev/null +++ b/python/api_v2/tests/test_action_host_group.py @@ -0,0 +1,565 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from operator import itemgetter + +from cm.converters import model_to_core_type, orm_object_to_core_type +from cm.models import Action, ActionHostGroup, Cluster, ClusterObject, ConcernItem, Host, ServiceComponent, TaskLog +from cm.services.action_host_group import ActionHostGroupRepo, ActionHostGroupService, CreateDTO +from cm.tests.mocks.task_runner import RunTaskMock +from core.types import CoreObjectDescriptor +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_404_NOT_FOUND, + HTTP_409_CONFLICT, +) + +from api_v2.tests.base import BaseAPITestCase + +ACTION_HOST_GROUPS = "action-host-groups" + + +class TestActionHostGroup(BaseAPITestCase): + def setUp(self) -> None: + super().setUp() + + self.action_host_group_service = ActionHostGroupService(repository=ActionHostGroupRepo()) + + self.bundle = self.add_bundle(source_dir=self.test_bundles_dir / "cluster_action_host_group") + + self.cluster = self.service = self.component = None + for i in range(3): + self.cluster = self.add_cluster(bundle=self.bundle, name=f"Cluster {i}") + self.service = self.add_services_to_cluster(["example"], cluster=self.cluster).get() + self.component = self.service.servicecomponent_set.first() + + self.hostprovider = self.add_provider(bundle=self.provider_bundle, name="Provider") + self.hosts = [ + self.add_host(provider=self.hostprovider, fqdn=f"host-{i}", cluster=self.cluster) for i in range(3) + ] + + def create_group( + self, name: str, owner: Cluster | ClusterObject | ServiceComponent, description: str = "" + ) -> ActionHostGroup: + return ActionHostGroup.objects.get( + id=self.action_host_group_service.create( + CreateDTO( + name=name, + owner=CoreObjectDescriptor(id=owner.id, type=orm_object_to_core_type(owner)), + description=description, + ) + ) + ) + + def test_create_group_success(self) -> None: + group_counter = 0 + + for target in (self.cluster, self.service, self.component): + type_ = model_to_core_type(target.__class__) + group_counter += 1 + + data = {"name": f"group for {type_.value}", "description": "simple group"} + + response = self.client.v2[target, ACTION_HOST_GROUPS].post(data=data) + + with self.subTest(f"[{type_.name}] CREATED SUCCESS"): + self.assertEqual(response.status_code, HTTP_201_CREATED) + self.assertEqual(ActionHostGroup.objects.count(), group_counter) + created_group = ActionHostGroup.objects.filter( + object_id=target.id, object_type=target.content_type, name=data["name"] + ).first() + self.assertIsNotNone(created_group) + self.assertEqual(response.json(), {"id": created_group.id, **data, "hosts": []}) + + # todo implement + # with self.subTest(f"[{type_.name}] AUDITED SUCCESS"): + # self.assertEqual(response.status_code, HTTP_201_CREATED) + + def test_create_multiple_groups(self) -> None: + with self.subTest("[COMPONENT] Same Object + Different Names SUCCESS"): + endpoint = self.client.v2[self.component, ACTION_HOST_GROUPS] + response = endpoint.post(data={"name": "best-1", "description": ""}) + self.assertEqual(response.status_code, HTTP_201_CREATED) + response = endpoint.post(data={"name": "best-2", "description": ""}) + self.assertEqual(response.status_code, HTTP_201_CREATED) + + groups = sorted( + ActionHostGroup.objects.values_list("name", flat=True).filter( + object_id=self.component.id, object_type=self.component.content_type + ) + ) + self.assertListEqual(groups, ["best-1", "best-2"]) + + with self.subTest("[SERVICE] Different Objects + Same Name SUCCESS"): + second_service = ClusterObject.objects.exclude(id=self.service.id).first() + data = {"name": "cool", "description": ""} + + response = self.client.v2[self.service, ACTION_HOST_GROUPS].post(data=data) + self.assertEqual(response.status_code, HTTP_201_CREATED) + response = self.client.v2[second_service, ACTION_HOST_GROUPS].post(data=data) + self.assertEqual(response.status_code, HTTP_201_CREATED) + + groups = sorted( + ActionHostGroup.objects.values_list("object_id", flat=True).filter( + name=data["name"], object_type=ClusterObject.class_content_type + ) + ) + self.assertListEqual(groups, sorted((self.service.id, second_service.id))) + + with self.subTest("[SERVICE] Same Object + Same Name FAIL"): + name = "sgroup" + response = self.client.v2[self.service, ACTION_HOST_GROUPS].post(data={"name": name, "description": "cool"}) + self.assertEqual(response.status_code, HTTP_201_CREATED) + + response = self.client.v2[self.service, ACTION_HOST_GROUPS].post(data={"name": name, "description": "best"}) + + self.assertEqual(response.status_code, HTTP_409_CONFLICT) + self.assertEqual(response.json()["code"], "CREATE_CONFLICT") + self.assertEqual(ActionHostGroup.objects.filter(name=name).count(), 1) + + def test_delete_success(self) -> None: + self.set_hostcomponent( + cluster=self.cluster, entries=((self.hosts[0], self.component), (self.hosts[1], self.component)) + ) + + cluster_group = self.create_group(name="Cluster Group", owner=self.cluster) + self.action_host_group_service.add_hosts_to_group(group_id=cluster_group.id, hosts=[self.hosts[0].id]) + service_group_1 = self.create_group(name="Service Group", owner=self.service) + self.create_group(name="Service Group #2", owner=self.service) + component_group = self.create_group(name="Component Group", owner=self.component) + self.action_host_group_service.add_hosts_to_group( + group_id=component_group.id, hosts=[self.hosts[0].id, self.hosts[1].id] + ) + + for target, group_to_delete, groups_left_amount in ( + (self.cluster, cluster_group, 0), + (self.service, service_group_1, 1), + (self.component, component_group, 0), + ): + with self.subTest(orm_object_to_core_type(target).name): + response = self.client.v2[group_to_delete].delete() + + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) + self.assertEqual( + ActionHostGroup.objects.filter(object_id=target.id, object_type=target.content_type).count(), + groups_left_amount, + ) + + # todo add audit + + def test_retrieve_success(self) -> None: + name = "aWeSOME Group NAmE" + host_1, host_2, host_3, *_ = self.hosts + self.set_hostcomponent(cluster=self.cluster, entries=[(host, self.component) for host in self.hosts]) + another_group = self.create_group(name=f"{name}XXX21321", owner=self.service, description="hoho") + service_group = self.create_group(name=name, owner=self.service) + self.action_host_group_service.add_hosts_to_group(group_id=service_group.id, hosts=[host_1.id, host_3.id]) + self.action_host_group_service.add_hosts_to_group(group_id=another_group.id, hosts=[host_1.id, host_2.id]) + + response = self.client.v2[service_group].get() + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual( + response.json(), + { + "id": service_group.id, + "name": name, + "description": "", + "hosts": [ + {"id": host_1.id, "name": host_1.fqdn}, + {"id": host_3.id, "name": host_3.fqdn}, + ], + }, + ) + + def test_list_success(self) -> None: + name_1 = "compo Group 1" + name_2 = "comPOnent gorup 123" + name_3 = "tired fantasies" + description = "nananan" + + another_component = ( + self.add_services_to_cluster(["second"], cluster=self.cluster).get().servicecomponent_set.first() + ) + + self.set_hostcomponent( + cluster=self.cluster, + entries=[(host, self.component) for host in self.hosts] + [(self.hosts[1], another_component)], + ) + + self.create_group(name="Cluster Group", owner=self.cluster) + self.create_group(name="Service Group", owner=self.service) + self.create_group(name="Service Group #2", owner=self.service) + component_group_1 = self.create_group(name=name_1, owner=self.component) + component_group_2 = self.create_group(name=name_2, owner=self.component, description=description) + another_component_group = self.create_group(name=name_2, owner=another_component, description=description) + component_group_3 = self.create_group(name=name_3, owner=self.component, description=description) + self.action_host_group_service.add_hosts_to_group( + group_id=component_group_1.id, hosts=[self.hosts[0].id, self.hosts[2].id] + ) + self.action_host_group_service.add_hosts_to_group(group_id=component_group_3.id, hosts=[self.hosts[0].id]) + self.action_host_group_service.add_hosts_to_group(group_id=another_component_group.id, hosts=[self.hosts[1].id]) + + # amount of queries checked on 1 component group -- it's the same + with self.assertNumQueries(6): + response = self.client.v2[self.component, ACTION_HOST_GROUPS].get() + + self.assertEqual(response.status_code, HTTP_200_OK) + data = response.json() + self.assertEqual(data["count"], 3) + self.assertEqual( + data["results"], + [ + { + "id": component_group_1.id, + "name": name_1, + "description": "", + "hosts": [ + {"id": self.hosts[0].id, "name": self.hosts[0].fqdn}, + {"id": self.hosts[2].id, "name": self.hosts[2].fqdn}, + ], + }, + { + "id": component_group_2.id, + "name": name_2, + "description": description, + "hosts": [], + }, + { + "id": component_group_3.id, + "name": name_3, + "description": description, + "hosts": [{"id": self.hosts[0].id, "name": self.hosts[0].fqdn}], + }, + ], + ) + + def test_host_candidates_success(self) -> None: + host_1, host_2, host_3 = self.hosts + host_1_data, host_2_data, host_3_data = ({"id": host.id, "name": host.fqdn} for host in self.hosts) + self.set_hostcomponent(cluster=self.cluster, entries=[(host_1, self.component), (host_2, self.component)]) + + cluster_group = self.create_group(name="Some Taken", owner=self.cluster) + cluster_group_2 = self.create_group(name="None Taken", owner=self.cluster) + service_group = self.create_group(name="One Taken", owner=self.service) + component_group = self.create_group(name="None Taken", owner=self.component) + + self.action_host_group_service.add_hosts_to_group(group_id=cluster_group.id, hosts=[host_1.id, host_2.id]) + self.action_host_group_service.add_hosts_to_group(group_id=service_group.id, hosts=[host_1.id]) + + for target, expected in ( + (cluster_group, [host_3_data]), + (cluster_group_2, [host_1_data, host_2_data, host_3_data]), + (service_group, [host_2_data]), + (component_group, [host_1_data, host_2_data]), + ): + type_ = orm_object_to_core_type(target.object) + + with self.subTest(f"[{type_.name}] {target.name} Expect {len(expected)}"): + response = self.client.v2[target, "host-candidates"].get() + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertListEqual(response.json(), expected) + + +class TestHostsInActionHostGroup(BaseAPITestCase): + def setUp(self) -> None: + super().setUp() + + self.action_host_group_service = ActionHostGroupService(repository=ActionHostGroupRepo()) + + self.bundle = self.add_bundle(source_dir=self.test_bundles_dir / "cluster_action_host_group") + + self.cluster = self.service = self.component = None + for i in range(2): + self.cluster = self.add_cluster(bundle=self.bundle, name=f"Cluster {i}") + self.service = self.add_services_to_cluster(["example"], cluster=self.cluster).get() + self.component = self.service.servicecomponent_set.first() + + self.hostprovider = self.add_provider(bundle=self.provider_bundle, name="Provider") + self.hosts = [self.add_host(provider=self.hostprovider, fqdn=f"host-{i}") for i in range(5)] + + self.service_2 = self.add_services_to_cluster(["second"], cluster=self.cluster).get() + self.component_2, self.component_3 = self.service_2.servicecomponent_set.all() + + for host in self.hosts[:3]: + self.add_host_to_cluster(cluster=self.cluster, host=host) + + self.set_hostcomponent( + cluster=self.cluster, + entries=( + (self.hosts[0], self.component), + (self.hosts[1], self.component), + (self.hosts[0], self.component_2), + ), + ) + + objects = (self.cluster, self.service, self.component, self.service_2, self.component_2, self.component_3) + self.group_map: dict[Cluster | ClusterObject | ServiceComponent, ActionHostGroup] = { + object_: ActionHostGroup.objects.get( + id=self.action_host_group_service.create( + CreateDTO( + owner=CoreObjectDescriptor(id=object_.id, type=orm_object_to_core_type(object_)), + name=f"Group for {object_.name}", + description="", + ) + ) + ) + for object_ in objects + } + + def test_add_host_to_group(self) -> None: + host_1, host_2, host_3, host_4, *_ = self.hosts + + for target in (self.cluster, self.service, self.component): + type_ = orm_object_to_core_type(target) + group = self.group_map[target] + + # request and check is out of subtests, because success of it is crucial for all subtests later + response = self.client.v2[group, "hosts"].post(data={"hostId": host_1.id}) + self.assertEqual( + response.status_code, + HTTP_201_CREATED, + f"Host add failed for {type_.name} with status {response.status_code}", + ) + + with self.subTest(f"[{type_.name}] Add Host SUCCESS"): + hosts_in_group = self.action_host_group_service.retrieve(group.id).hosts + self.assertEqual(len(hosts_in_group), 1) + self.assertEqual(hosts_in_group[0].id, host_1.id) + + # todo add audit check for all success/fail cases + # with self.subTest(f"Add Audit {type_.name} SUCCESS"): + # ... + + with self.subTest(f"[{type_.name}] Add Host Duplicate FAIL"): + response = self.client.v2[group, "hosts"].post(data={"hostId": host_1.id}) + self.assertEqual(response.status_code, HTTP_409_CONFLICT) + self.assertEqual(len(self.action_host_group_service.retrieve(group.id).hosts), 1) + self.assertIn("hosts are already in action group", response.json()["desc"]) + + with self.subTest("[SERVICE] Add Second Host SUCCESS"): + group = self.group_map[self.service] + response = self.client.v2[group, "hosts"].post(data={"hostId": host_2.id}) + + self.assertEqual(response.status_code, HTTP_201_CREATED) + self.assertEqual(len(self.action_host_group_service.retrieve(group.id).hosts), 2) + + for target, unmapped_host, expected_host_count in ( + (self.cluster, host_4, 1), + (self.service, host_3, 2), + (self.service_2, host_2, 0), + (self.component_3, host_1, 0), + ): + type_ = orm_object_to_core_type(target) + group = self.group_map[target] + + with self.subTest(f"[{type_.name}] Add Unmapped Host {unmapped_host.fqdn} FAIL"): + response = self.client.v2[group, "hosts"].post(data={"hostId": unmapped_host.id}) + + self.assertEqual(response.status_code, HTTP_409_CONFLICT) + self.assertEqual(len(self.action_host_group_service.retrieve(group.id).hosts), expected_host_count) + + with self.subTest("[SERVICE] Add Non Existing Host FAIL"): + response = self.client.v2[self.group_map[self.service], "hosts"].post( + data={"hostId": self.get_non_existent_pk(Host)} + ) + + self.assertEqual(response.status_code, HTTP_409_CONFLICT) + + def test_remove_host_from_group(self) -> None: + host_1, host_2, *_ = self.hosts + + for target in (self.cluster, self.service, self.component): + group = self.group_map[target] + type_ = orm_object_to_core_type(target) + endpoint = self.client.v2[group, "hosts", host_1] + + self.action_host_group_service.add_hosts_to_group(group_id=group.id, hosts=[host_1.id, host_2.id]) + + with self.subTest(f"[{type_.name}] Remove Host SUCCESS"): + response = endpoint.delete() + + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) + hosts_in_group = self.action_host_group_service.retrieve(group.id).hosts + self.assertEqual(len(hosts_in_group), 1) + self.assertEqual(hosts_in_group[0].id, host_2.id) + + with self.subTest(f"[{type_.name}] Remove Removed Host FAIL"): + response = endpoint.delete() + + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + self.assertEqual(len(self.action_host_group_service.retrieve(group.id).hosts), 1) + + with self.subTest(f"[{type_.name}] Remove Last Host SUCCESS"): + response = self.client.v2[group, "hosts", host_2].delete() + + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) + hosts_in_group = self.action_host_group_service.retrieve(group.id).hosts + self.assertEqual(len(hosts_in_group), 0) + + # todo add audit + + def test_same_hosts_in_group_of_one_object_success(self) -> None: + host_1, host_2, *_ = self.hosts + + for target in (self.cluster, self.service, self.component): + group = self.group_map[target] + type_ = orm_object_to_core_type(target) + second_group = ActionHostGroup.objects.get( + id=self.action_host_group_service.create( + CreateDTO(owner=CoreObjectDescriptor(id=target.id, type=type_), name="Another Group") + ) + ) + + with self.subTest(type_.name): + for host in (host_1, host_2): + response_first_group = self.client.v2[group, "hosts"].post(data={"hostId": host.id}) + response_second_group = self.client.v2[second_group, "hosts"].post(data={"hostId": host.id}) + + self.assertEqual(response_first_group.status_code, HTTP_201_CREATED) + self.assertEqual(response_second_group.status_code, HTTP_201_CREATED) + + +class TestActionsOnActionHostGroup(BaseAPITestCase): + def setUp(self) -> None: + super().setUp() + + self.action_host_group_service = ActionHostGroupService(repository=ActionHostGroupRepo()) + + self.bundle = self.add_bundle(source_dir=self.test_bundles_dir / "cluster_action_host_group") + + self.cluster = self.add_cluster(bundle=self.bundle, name="Cluster Bombaster") + self.service = self.add_services_to_cluster(["example"], cluster=self.cluster).get() + self.component = self.service.servicecomponent_set.first() + + objects = (self.cluster, self.service, self.component) + self.group_map: dict[Cluster | ClusterObject | ServiceComponent, ActionHostGroup] = { + object_: ActionHostGroup.objects.get( + id=self.action_host_group_service.create( + CreateDTO( + owner=CoreObjectDescriptor(id=object_.id, type=orm_object_to_core_type(object_)), + name=f"Group for {object_.name}", + description="wait for action", + ) + ) + ) + for object_ in objects + } + + def test_get(self) -> None: + regular_actions = ["regular"] + + for target, expected in ( + (self.cluster, ["allowed_in_group_1"]), + (self.service, ["allowed_in_group_1", "allowed_from_service"]), + (self.component, ["allowed_in_group_1", "allowed_from_component"]), + ): + group = self.group_map[target] + type_name = orm_object_to_core_type(target).name + group_action = Action.objects.get(prototype=target.prototype, name="allowed_in_group_1") + regular_action = Action.objects.get(prototype=target.prototype, name="regular") + + with self.subTest(f"[{type_name}] Group List SUCCESS"): + response = self.client.v2[group, "actions"].get() + + self.assertEqual(response.status_code, HTTP_200_OK) + actual_action_names = sorted(map(itemgetter("name"), response.json())) + self.assertEqual(actual_action_names, sorted(expected)) + + with self.subTest(f"[{type_name}] Group Retrieve SUCCESS"): + response = self.client.v2[group, "actions", group_action].get() + + self.assertEqual(response.status_code, HTTP_200_OK) + data = response.json() + self.assertEqual(data["id"], group_action.id) + self.assertEqual(data["displayName"], group_action.display_name) + + with self.subTest(f"[{type_name}] Group Retrieve FAIL"): + response = self.client.v2[group, "actions", regular_action].get() + + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + + with self.subTest(f"[{type_name}] Own List Include Group Actions SUCCESS"): + response = self.client.v2[target, "actions"].get() + + self.assertEqual(response.status_code, HTTP_200_OK) + actual_action_names = sorted(map(itemgetter("name"), response.json())) + self.assertEqual(actual_action_names, sorted(expected + regular_actions)) + + with self.subTest(f"[{type_name}] Own Retrieve Include Group Actions SUCCESS"): + response = self.client.v2[target, "actions", group_action].get() + + self.assertEqual(response.status_code, HTTP_200_OK) + data = response.json() + self.assertEqual(data["id"], group_action.id) + self.assertEqual(data["displayName"], group_action.display_name) + + def test_run(self) -> None: + hostprovider = self.add_provider(bundle=self.provider_bundle, name="Provider") + host_1, host_2 = ( + self.add_host(provider=hostprovider, fqdn=f"host-{i}", cluster=self.cluster) for i in range(2) + ) + self.set_hostcomponent(cluster=self.cluster, entries=[(host_1, self.component), (host_2, self.component)]) + + # todo add audit cases + for target, action_name in ( + (self.cluster, "allowed_in_group_1"), + (self.service, "allowed_from_service"), + (self.component, "allowed_from_component"), + ): + group = self.group_map[target] + self.action_host_group_service.add_hosts_to_group(group_id=group.id, hosts=[host_1.id]) + type_name = orm_object_to_core_type(target).name + action = Action.objects.get(prototype=target.prototype, name=action_name) + expected_lock_error_message = f"group #{group.id}, because it has running task: " + + for action_run_target, message_name in ((group, f"{type_name} Group"), (target, type_name)): + # cleanup + ConcernItem.objects.all().delete() + TaskLog.objects.all().delete() + + with RunTaskMock() as run_task: + response = self.client.v2[action_run_target, "actions", action, "run"].post(data={}) + + self.assertEqual(response.status_code, HTTP_200_OK) + + with self.subTest(f"[{message_name}] Run SUCCESS"): + self.assertIsNotNone(run_task.target_task) + self.assertEqual(run_task.target_task.task_object, action_run_target) + + with self.subTest(f"[{message_name}] Running Task Add Hosts FAIL"): + response = self.client.v2[group, "hosts"].post(data={"hostId": host_2.id}) + + self.assertEqual(response.status_code, HTTP_409_CONFLICT) + self.assertIn(f"Can't add hosts to {expected_lock_error_message}", response.json()["desc"]) + + with self.subTest(f"[{message_name}] Running Task Remove Hosts FAIL"): + response = self.client.v2[group, "hosts", host_1].delete() + + self.assertEqual(response.status_code, HTTP_409_CONFLICT) + self.assertIn(f"Can't remove hosts from {expected_lock_error_message}", response.json()["desc"]) + + with self.subTest(f"[{message_name}] Running Task Delete Group FAIL"): + response = self.client.v2[group].delete() + + self.assertEqual(response.status_code, HTTP_409_CONFLICT) + self.assertIn(f"Can't delete {expected_lock_error_message}", response.json()["desc"]) + + with self.subTest(f"[{message_name}] Running Task Create New Group SUCCESS"): + response = self.client.v2[target, ACTION_HOST_GROUPS].post( + data={"name": f"New Best Group Ever {message_name}", "description": "That's it"} + ) + + self.assertEqual(response.status_code, HTTP_201_CREATED) diff --git a/python/api_v2/views.py b/python/api_v2/views.py index 93adbc4fbe..dc2af61fe4 100644 --- a/python/api_v2/views.py +++ b/python/api_v2/views.py @@ -10,11 +10,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Collection +from functools import wraps +from typing import Callable, Collection +from cm.converters import core_type_to_model, host_group_type_to_model from cm.models import Cluster, ClusterObject, Host, ServiceComponent from cm.services.status.client import retrieve_status_map from cm.status_api import get_raw_status +from core.types import ADCMCoreType, ADCMHostGroupType, CoreObjectDescriptor, HostGroupDescriptor +from django.contrib.contenttypes.models import ContentType from djangorestframework_camel_case.parser import ( CamelCaseFormParser, CamelCaseJSONParser, @@ -24,6 +28,7 @@ CamelCaseBrowsableAPIRenderer, CamelCaseJSONRenderer, ) +from rest_framework.exceptions import NotFound from rest_framework.mixins import ( CreateModelMixin, DestroyModelMixin, @@ -123,3 +128,127 @@ def get_serializer_context(self) -> dict: raise RuntimeError(message) return {**context, "status": get_raw_status(url=url)} + + +# Parent extractor helpers + + +class UndetectableParentError(RuntimeError): + ... + + +class UndetectableHostGroupError(RuntimeError): + ... + + +class NonExistingError(NotFound): + ... + + +class NonExistingParentError(NonExistingError): + ... + + +class NonExistingHostGroupError(NonExistingError): + ... + + +def extract_core_object_from_lookup_kwargs(**kwargs) -> CoreObjectDescriptor: + lookup_keys = set(kwargs.keys()) + extra_filter: dict = {} + + if lookup_keys.issuperset({"component_pk", "service_pk", "cluster_pk"}): + parent = CoreObjectDescriptor(id=int(kwargs["component_pk"]), type=ADCMCoreType.COMPONENT) + extra_filter = {"service_id": kwargs["service_pk"], "cluster_id": kwargs["cluster_pk"]} + + elif lookup_keys.issuperset({"service_pk", "cluster_pk"}): + parent = CoreObjectDescriptor(id=int(kwargs["service_pk"]), type=ADCMCoreType.SERVICE) + extra_filter = {"cluster_id": kwargs["cluster_pk"]} + + elif lookup_keys.issuperset({"hostprovider_pk"}): + parent = CoreObjectDescriptor(id=int(kwargs["hostprovider_pk"]), type=ADCMCoreType.HOSTPROVIDER) + + elif lookup_keys.issuperset({"host_pk"}): + parent = CoreObjectDescriptor(id=int(kwargs["host_pk"]), type=ADCMCoreType.HOST) + if "cluster_pk" in lookup_keys: + extra_filter = {"cluster_id": kwargs["cluster_pk"]} + + elif lookup_keys.issuperset({"cluster_pk"}): + parent = CoreObjectDescriptor(id=int(kwargs["cluster_pk"]), type=ADCMCoreType.CLUSTER) + + else: + message = "Failed to detect core parent based on given arguments" + raise UndetectableParentError(message) + + if not core_type_to_model(parent.type).objects.filter(id=parent.id, **extra_filter).exists(): + raise NonExistingParentError() + + return parent + + +def extract_host_group_from_lookup_kwargs_and_parent(parent: CoreObjectDescriptor, **kwargs) -> HostGroupDescriptor: + if "group_config_pk" in kwargs: + host_group = HostGroupDescriptor(id=int(kwargs["group_config_pk"]), type=ADCMHostGroupType.CONFIG) + elif "action_host_group_pk" in kwargs: + host_group = HostGroupDescriptor(id=int(kwargs["action_host_group_pk"]), type=ADCMHostGroupType.ACTION) + else: + message = "Failed to detect core parent based on given arguments" + raise UndetectableHostGroupError(message) + + object_type = ContentType.objects.get_for_model(core_type_to_model(core_type=parent.type)) + if ( + not host_group_type_to_model(host_group_type=host_group.type) + .objects.filter(id=host_group.id, object_id=parent.id, object_type=object_type) + .exists() + ): + raise NonExistingHostGroupError() + + return host_group + + +def with_parent_object(func: Callable) -> Callable: + """ + Decorator to extract "parent" object from kwargs (request lookup kwargs): + - parent is presented as instance of `CoreObjectDescriptor` + - if object is extracted and presented in DB, it is placed to a "parent" argument of wrapped func + - otherwise, an exception will be raised that descends from DRF's `NotFound` + + If you'll need to put extracted parent not in "parent" kwarg, + make this function accepting arguments and let user specify where to put it. + + If you'll need to not raise exception or raise another one, + make this function accepting callable in arguments that'll decide what to do, + then call in after catching the `NotFound` descendant exception. + """ + + @wraps(func) + def wrapped(*args, **kwargs): + parent = extract_core_object_from_lookup_kwargs(**kwargs) + + return func(*args, parent=parent, **kwargs) + + return wrapped + + +def with_group_object(func: Callable) -> Callable: + """ + Same as `with_parent_object`, but detects Action/Config Host Group and puts it to "host_group" argument. + Parent detection and existence will be checked too. + It'll be ensured that group is part of parent + + Should be used like: + + class SomeViewSet: + @with_group_object + def post(self, request, *args, parent: CoreObjectDescriptor, host_group: HostGroupDescriptor, **kwargs): + ... + """ + + @wraps(func) + def wrapped(*args, **kwargs): + parent = extract_core_object_from_lookup_kwargs(**kwargs) + host_group = extract_host_group_from_lookup_kwargs_and_parent(parent=parent, **kwargs) + + return func(*args, parent=parent, host_group=host_group, **kwargs) + + return wrapped diff --git a/python/audit/models.py b/python/audit/models.py index 9426d2982d..f3aa4d422d 100644 --- a/python/audit/models.py +++ b/python/audit/models.py @@ -14,6 +14,7 @@ from cm.models import ( ADCM, + ActionHostGroup, Bundle, Cluster, ClusterObject, @@ -125,6 +126,7 @@ class AuditOperation: Role: AuditObjectType.ROLE, Policy: AuditObjectType.POLICY, Prototype: AuditObjectType.PROTOTYPE, + ActionHostGroup: AuditObjectType.ACTION_HOST_GROUP, } AUDIT_OBJECT_TYPE_TO_MODEL_MAP = {v: k for k, v in MODEL_TO_AUDIT_OBJECT_TYPE_MAP.items()} @@ -141,4 +143,5 @@ class AuditOperation: "hosts": Host, "cluster": Cluster, "clusters": Cluster, + "action-host-groups": ActionHostGroup, } diff --git a/python/cm/converters.py b/python/cm/converters.py index 900d2686b3..6d0167b847 100644 --- a/python/cm/converters.py +++ b/python/cm/converters.py @@ -12,12 +12,13 @@ from typing import TypeAlias -from core.types import ADCMCoreType, ExtraActionTargetType +from core.types import ADCMCoreType, ADCMHostGroupType, ExtraActionTargetType from django.db.models import Model -from cm.models import ADCM, ActionHostGroup, Cluster, ClusterObject, Host, HostProvider, ServiceComponent +from cm.models import ADCM, ActionHostGroup, Cluster, ClusterObject, GroupConfig, Host, HostProvider, ServiceComponent CoreObject: TypeAlias = Cluster | ClusterObject | ServiceComponent | HostProvider | Host +GroupObject: TypeAlias = GroupConfig | ActionHostGroup def core_type_to_model(core_type: ADCMCoreType) -> type[CoreObject | ADCM]: @@ -38,6 +39,16 @@ def core_type_to_model(core_type: ADCMCoreType) -> type[CoreObject | ADCM]: raise ValueError(f"Can't convert {core_type} to ORM model") +def host_group_type_to_model(host_group_type: ADCMHostGroupType) -> type[GroupObject]: + if host_group_type == ADCMHostGroupType.CONFIG: + return GroupConfig + + if host_group_type == ADCMHostGroupType.ACTION: + return ActionHostGroup + + raise ValueError(f"Can't convert {host_group_type} to ORM model") + + def core_type_to_db_record_type(core_type: ADCMCoreType) -> str: match core_type: case ADCMCoreType.CLUSTER: diff --git a/python/cm/errors.py b/python/cm/errors.py index e3f3bb98d0..d922578f2f 100644 --- a/python/cm/errors.py +++ b/python/cm/errors.py @@ -244,6 +244,8 @@ "CONFIG_OPTION_ERROR": ("error in config option type", HTTP_409_CONFLICT, ERR), "DATABASE_IS_LOCKED": ("SQLite not for production", HTTP_500_INTERNAL_SERVER_ERROR, ERR), "UNPROCESSABLE_ENTITY": ("Can't process data", HTTP_422_UNPROCESSABLE_ENTITY, ERR), + "CREATE_CONFLICT": ("Can't create object", HTTP_409_CONFLICT, ERR), + "HOST_GROUP_CONFLICT": ("Can't change hosts in group", HTTP_409_CONFLICT, ERR), } diff --git a/python/cm/migrations/0126_action_host_group.py b/python/cm/migrations/0126_action_host_group.py index fdb2a6b894..ab263d16be 100644 --- a/python/cm/migrations/0126_action_host_group.py +++ b/python/cm/migrations/0126_action_host_group.py @@ -50,4 +50,8 @@ class Migration(migrations.Migration): "abstract": False, }, ), + migrations.AlterUniqueTogether( + name="actionhostgroup", + unique_together={("object_id", "object_type", "name")}, + ), ] diff --git a/python/cm/models.py b/python/cm/models.py index ec4df9bc80..3415399cec 100644 --- a/python/cm/models.py +++ b/python/cm/models.py @@ -752,7 +752,7 @@ def auto_delete_config_with_servicecomponent(sender, instance, **kwargs): # noq instance.config.delete() -class ActionHostGroup(ADCMModel): +class ActionHostGroup(models.Model): object_id = models.PositiveIntegerField(null=False) object_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=False) object = GenericForeignKey("object_type", "object_id") @@ -760,6 +760,9 @@ class ActionHostGroup(ADCMModel): description = models.CharField(max_length=255) hosts = models.ManyToManyField(Host) + class Meta: + unique_together = ["object_id", "object_type", "name"] + class GroupConfig(ADCMModel): object_id = models.PositiveIntegerField() @@ -1117,7 +1120,10 @@ def allowed(self, obj: ADCMEntity) -> bool: return state_allowed and multi_state_allowed - def get_start_impossible_reason(self, obj: ADCMEntity) -> str | None: + def get_start_impossible_reason(self, obj: ADCMEntity | ActionHostGroup) -> str | None: + if isinstance(obj, ActionHostGroup): + obj = obj.object + if obj.prototype.type == "adcm": obj: ADCM diff --git a/python/cm/services/action_host_group.py b/python/cm/services/action_host_group.py index 8aa540dded..ed320a1ec7 100644 --- a/python/cm/services/action_host_group.py +++ b/python/cm/services/action_host_group.py @@ -13,11 +13,21 @@ from dataclasses import dataclass from typing import Iterable, NamedTuple, TypeAlias -from core.types import ADCMCoreType, CoreObjectDescriptor, HostID, ShortObjectInfo +from core.types import ( + ADCMCoreType, + ADCMMessageError, + ClusterID, + ComponentID, + CoreObjectDescriptor, + HostID, + ServiceID, + ShortObjectInfo, + TaskID, +) from django.contrib.contenttypes.models import ContentType from cm.converters import core_type_to_model, model_name_to_core_type -from cm.models import ActionHostGroup, Host +from cm.models import ActionHostGroup, Host, HostComponent, TaskLog ActionHostGroupID: TypeAlias = int @@ -33,35 +43,91 @@ class ActionTargetHostGroup: class CreateDTO(NamedTuple): owner: CoreObjectDescriptor name: str - description: str + description: str = "" + + +class ActionHostGroupError(ADCMMessageError): + ... + + +class NameCollisionError(ActionHostGroupError): + ... + + +class GroupIsLockedError(ActionHostGroupError): + def __init__(self, message: str, task_id: int): + self.message = message + self.task_id = task_id + + def __str__(self): + return self.message + + +class HostError(ADCMMessageError): + ... class ActionHostGroupRepo: - @staticmethod - def create(dto: CreateDTO) -> ActionHostGroupID: + group_hosts_model = ActionHostGroup.hosts.through + + def create(self, dto: CreateDTO) -> ActionHostGroupID: + object_type = ContentType.objects.get_for_model(core_type_to_model(dto.owner.type)) + + # todo maybe just catch integration error and analyze it? + if ActionHostGroup.objects.filter(name=dto.name, object_id=dto.owner.id, object_type=object_type).exists(): + message = f'Group with name "{dto.name}" exists for {dto.owner}' + raise NameCollisionError(message) + return ActionHostGroup.objects.create( name=dto.name, description=dto.description, object_id=dto.owner.id, - object_type=ContentType.objects.get_for_model(core_type_to_model(dto.owner.type)), + object_type=object_type, ).id - @staticmethod - def retrieve(id: ActionHostGroupID) -> ActionTargetHostGroup: # noqa: A002 + def retrieve(self, id: ActionHostGroupID) -> ActionTargetHostGroup: # noqa: A002 group = ActionHostGroup.objects.get(id=id) owner = CoreObjectDescriptor(id=group.object_id, type=model_name_to_core_type(group.object_type.model)) - hosts_qs = group.hosts_set.values_list("id", flat=True) - hosts = tuple(map(ShortObjectInfo, Host.objects.values_list("id", "fqdn").filter(id__in=hosts_qs))) + hosts_qs = group.hosts.values_list("id", flat=True) + hosts = tuple( + ShortObjectInfo(*entry) for entry in Host.objects.values_list("id", "fqdn").filter(id__in=hosts_qs) + ) return ActionTargetHostGroup(id=group.id, name=group.name, owner=owner, hosts=hosts) - @staticmethod - def reset_hosts(id: ActionHostGroupID, hosts: Iterable[HostID]) -> None: # noqa: A002 - # todo optimize requests - m2m_model = ActionHostGroup.hosts.through - m2m_model.objects.filter(actionhostgroup_id=id).delete() - m2m_model.objects.bulk_create(objs=(m2m_model(actionhostgroup_id=id, host_id=host_id) for host_id in hosts)) + def delete(self, id: ActionHostGroupID) -> None: # noqa: A002 + ActionHostGroup.objects.filter(id=id).delete() + + def get_hosts(self, id: ActionHostGroupID) -> set[HostID]: # noqa: A002 + return set(self.group_hosts_model.objects.values_list("host_id", flat=True).filter(actionhostgroup_id=id)) + + def get_all_host_candidates_for_cluster(self, cluster_id: ClusterID) -> set[HostID]: + return set(Host.objects.values_list("id", flat=True).filter(cluster_id=cluster_id)) + + def get_all_host_candidates_for_service(self, service_id: ServiceID) -> set[HostID]: + return set(HostComponent.objects.values_list("host_id", flat=True).filter(service_id=service_id)) + + def get_all_host_candidates_for_component(self, component_id: ComponentID) -> set[HostID]: + return set(HostComponent.objects.values_list("host_id", flat=True).filter(component_id=component_id)) + + def add_hosts(self, id: ActionHostGroupID, hosts: Iterable[HostID]) -> None: # noqa: A002 + self.group_hosts_model.objects.bulk_create( + objs=(self.group_hosts_model(actionhostgroup_id=id, host_id=host_id) for host_id in hosts) + ) + + def remove_hosts(self, id: ActionHostGroupID, hosts: Iterable[HostID]) -> None: # noqa: A002 + self.group_hosts_model.objects.filter(actionhostgroup_id=id, host_id__in=hosts).delete() + + def get_blocking_task_id(self, id: ActionHostGroupID) -> TaskID | None: # noqa: A002 + object_id, model_name = ActionHostGroup.objects.values_list("object_id", "object_type__model").get(id=id) + return ( + TaskLog.objects.values_list("id", flat=True) + .filter( + owner_id=object_id, owner_type=model_name_to_core_type(model_name=model_name).value, lock__isnull=False + ) + .first() + ) class ActionHostGroupService: @@ -80,6 +146,59 @@ def create(self, dto: CreateDTO) -> ActionHostGroupID: def retrieve(self, group_id: ActionHostGroupID) -> ActionTargetHostGroup: return self._repo.retrieve(id=group_id) - def set_hosts(self, group_id: ActionHostGroupID, hosts: tuple[HostID, ...]) -> None: - # todo add check that hosts belong to group owner - self._repo.reset_hosts(id=group_id, hosts=hosts) + def delete(self, group_id: int) -> None: + if task_id := self._repo.get_blocking_task_id(id=group_id): + message = f"Can't delete group #{group_id}, because it has running task: {task_id}" + raise GroupIsLockedError(message=message, task_id=task_id) + + self._repo.delete(id=group_id) + + def get_host_candidates(self, group_id: ActionHostGroupID) -> set[HostID]: + group = self._repo.retrieve(id=group_id) + + match group.owner.type: + case ADCMCoreType.CLUSTER: + all_candidates = self._repo.get_all_host_candidates_for_cluster(cluster_id=group.owner.id) + case ADCMCoreType.SERVICE: + all_candidates = self._repo.get_all_host_candidates_for_service(service_id=group.owner.id) + case ADCMCoreType.COMPONENT: + all_candidates = self._repo.get_all_host_candidates_for_component(component_id=group.owner.id) + case _: + message = f"Can't detect host candidates for owner of type {group.owner.type}" + raise NotImplementedError(message) + + return all_candidates - {host.id for host in group.hosts} + + def add_hosts_to_group(self, group_id: ActionHostGroupID, hosts: Iterable[HostID]) -> None: + if task_id := self._repo.get_blocking_task_id(id=group_id): + message = f"Can't add hosts to group #{group_id}, because it has running task: {task_id}" + raise GroupIsLockedError(message=message, task_id=task_id) + + hosts_in_group: set[HostID] = self._repo.get_hosts(id=group_id) + hosts_to_add = set(hosts) + + if incorrect_hosts := hosts_in_group & hosts_to_add: + message = f"Some hosts are already in action group #{group_id}: {', '.join(map(str, incorrect_hosts))}" + raise HostError(message) + + candidates = self.get_host_candidates(group_id=group_id) + if incorrect_hosts := hosts_to_add - candidates: + message = f"Some hosts can't be added to action group #{group_id}: {', '.join(map(str, incorrect_hosts))}" + raise HostError(message) + + self._repo.add_hosts(id=group_id, hosts=hosts_to_add) + + def remove_hosts_from_group(self, group_id: ActionHostGroupID, hosts: Iterable[HostID]) -> None: + if task_id := self._repo.get_blocking_task_id(id=group_id): + message = f"Can't remove hosts from group #{group_id}, because it has running task: {task_id}" + raise GroupIsLockedError(message=message, task_id=task_id) + + group = self._repo.retrieve(id=group_id) + hosts_in_group: set[HostID] = {host.id for host in group.hosts} + hosts_to_remove = set(hosts) + + if absent_hosts := hosts_to_remove - hosts_in_group: + message = f"Some hosts can't be removed from action group #{group_id}: {', '.join(map(str, absent_hosts))}" + raise HostError(message) + + self._repo.remove_hosts(id=group_id, hosts=hosts_to_remove) diff --git a/python/cm/tests/test_action_host_group.py b/python/cm/tests/test_action_host_group.py index 3fb101eeca..7e707efcf7 100644 --- a/python/cm/tests/test_action_host_group.py +++ b/python/cm/tests/test_action_host_group.py @@ -78,7 +78,7 @@ def setUp(self) -> None: name="simple", description="", owner=CoreObjectDescriptor(id=self.cluster.id, type=ADCMCoreType.CLUSTER) ) ) - self.action_group_service.set_hosts(group_id=group_id, hosts=(self.host_1.id, self.host_2.id)) + self.action_group_service.add_hosts_to_group(group_id=group_id, hosts=(self.host_1.id, self.host_2.id)) self.action_group = ActionHostGroup.objects.get(id=group_id) def test_run_action_success(self) -> None: @@ -117,7 +117,7 @@ def test_get_env_for_jinja_scripts_success(self) -> None: name="simple", description="", owner=CoreObjectDescriptor(id=self.service.id, type=ADCMCoreType.SERVICE) ) ) - self.action_group_service.set_hosts(group_id=group_id, hosts=(self.host_1.id, self.host_2.id)) + self.action_group_service.add_hosts_to_group(group_id=group_id, hosts=(self.host_1.id, self.host_2.id)) action = Action.objects.get(prototype=self.service.prototype, name="dummy") action_group = ActionHostGroup.objects.get(id=group_id) @@ -141,7 +141,7 @@ def test_group_not_in_selector_success(self) -> None: owner=CoreObjectDescriptor(id=self.component.id, type=ADCMCoreType.COMPONENT), ) ) - self.action_group_service.set_hosts(group_id=group_id, hosts=(self.host_1.id, self.host_2.id)) + self.action_group_service.add_hosts_to_group(group_id=group_id, hosts=(self.host_1.id, self.host_2.id)) action = Action.objects.get(prototype=self.service.prototype, name="dummy") action_group = ActionHostGroup.objects.get(id=group_id) diff --git a/python/core/types.py b/python/core/types.py index ab1bb5dc1a..5465adead9 100644 --- a/python/core/types.py +++ b/python/core/types.py @@ -53,6 +53,11 @@ class ADCMCoreType(Enum): HOST = "host" +class ADCMHostGroupType(Enum): + CONFIG = "config-group" + ACTION = "action-group" + + class ExtraActionTargetType(Enum): ACTION_HOST_GROUP = "action-host-group" @@ -77,10 +82,18 @@ class GeneralEntityDescriptor: type: str +@dataclass(slots=True, frozen=True) +class HostGroupDescriptor(GeneralEntityDescriptor): + type: ADCMHostGroupType + + @dataclass(slots=True, frozen=True) class ActionTargetDescriptor(GeneralEntityDescriptor): type: ADCMCoreType | ExtraActionTargetType + def __str__(self) -> str: + return f"{self.type.value} #{self.id}" + # inheritance from `ActionTargetDescriptor` is for convenience purposes, # because `CoreObjectDescriptor` is just a bit stricter than `ActionTargetDescriptor` From c48779f8a3452a0b068190249867517db29098e6 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Mon, 17 Jun 2024 04:46:32 +0000 Subject: [PATCH 173/208] ADCM-5650 RBAC for Action Host Groups --- python/adcm/permissions.py | 2 + python/api_v2/action/utils.py | 2 +- python/api_v2/action/views.py | 88 ++-- python/api_v2/action_host_group/views.py | 149 ++++++- python/api_v2/cluster/urls.py | 8 +- python/api_v2/tests/test_action_host_group.py | 408 +++++++++++++++--- .../test_policy/test_cluster_admin_role.py | 18 + python/rbac/upgrade/role_spec.yaml | 126 +++++- 8 files changed, 667 insertions(+), 134 deletions(-) diff --git a/python/adcm/permissions.py b/python/adcm/permissions.py index 1ba07a193d..3e6ce68b81 100644 --- a/python/adcm/permissions.py +++ b/python/adcm/permissions.py @@ -31,6 +31,8 @@ VIEW_SERVICE_PERM = "cm.view_clusterobject" VIEW_ACTION_PERM = "cm.view_action" CHANGE_MM_PERM = "change_maintenance_mode" +VIEW_ACTION_HOST_GROUPS = "view_action_host_group" +EDIT_ACTION_HOST_GROUPS = "edit_action_host_group" ADD_SERVICE_PERM = "add_service_to" RUN_ACTION_PERM_PREFIX = "cm.run_action_" ADD_HOST_TO = "add_host_to" diff --git a/python/api_v2/action/utils.py b/python/api_v2/action/utils.py index e67804f1fc..2335e7183f 100644 --- a/python/api_v2/action/utils.py +++ b/python/api_v2/action/utils.py @@ -50,7 +50,7 @@ def filter_actions_by_user_perm(user: User, obj: ADCMEntity, actions: Iterable[A return compress(data=actions, selectors=mask) -def check_run_perms(user: User, action: Action, obj: ADCMEntity) -> bool: +def has_run_perms(user: User, action: Action, obj: ADCMEntity) -> bool: return user.has_perm(perm=f"{RUN_ACTION_PERM_PREFIX}{get_str_hash(value=action.name)}", obj=obj) diff --git a/python/api_v2/action/views.py b/python/api_v2/action/views.py index 0a9f0c618d..a58fdfcd25 100644 --- a/python/api_v2/action/views.py +++ b/python/api_v2/action/views.py @@ -15,12 +15,11 @@ from adcm.mixins import GetParentObjectMixin from audit.utils import audit from cm.errors import AdcmEx -from cm.models import ADCM, Action, ActionHostGroup, ConcernType, Host, HostComponent, PrototypeConfig +from cm.models import ADCM, Action, ADCMEntity, ConcernType, Host, HostComponent, PrototypeConfig from cm.services.job.action import ActionRunPayload, run_action from cm.stack import check_hostcomponents_objects_exist from django.conf import settings -from django.contrib.contenttypes.models import ContentType -from django.db.models import Q, QuerySet +from django.db.models import Q from django_filters.rest_framework.backends import DjangoFilterBackend from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from jinja_config import get_jinja_config @@ -44,9 +43,9 @@ ActionRunSerializer, ) from api_v2.action.utils import ( - check_run_perms, filter_actions_by_user_perm, get_action_configuration, + has_run_perms, insert_service_ids, unique_hc_entries, ) @@ -162,8 +161,7 @@ def get_serializer_class( return ActionListSerializer - def list(self, request: Request, *args, **kwargs) -> Response: # noqa: ARG002 - self.parent_object = self.get_parent_object() + def check_permissions_for_list(self, request: Request) -> None: if ( not self.parent_object or not request.user.has_perm(perm=f"cm.view_{self.parent_object.__class__.__name__.lower()}") @@ -173,32 +171,30 @@ def list(self, request: Request, *args, **kwargs) -> Response: # noqa: ARG002 ): raise NotFound() - return self._list_actions_available_to_user(request) - - def _list_actions_available_to_user(self, request: Request) -> Response: - actions = self.filter_queryset(self.get_queryset()) - allowed_actions_mask = [act.allowed(self.prototype_objects[act.prototype]) for act in actions] - actions = list(compress(actions, allowed_actions_mask)) - actions = filter_actions_by_user_perm(user=request.user, obj=self.parent_object, actions=actions) - - serializer = self.get_serializer_class()(instance=actions, many=True, context={"obj": self.parent_object}) - - return Response(data=serializer.data) - - def retrieve(self, request, *args, **kwargs): # noqa: ARG002 - self.parent_object = self.get_parent_object() - action_ = self.get_object() - + def check_permissions_for_run(self, request: Request, action: Action) -> None: if ( not self.parent_object or not request.user.has_perm(perm=f"cm.view_{self.parent_object.__class__.__name__.lower()}") and not request.user.has_perm( perm=f"cm.view_{self.parent_object.__class__.__name__.lower()}", obj=self.parent_object ) - or not check_run_perms(user=request.user, action=action_, obj=self.parent_object) + or not has_run_perms(user=request.user, action=action, obj=self.parent_object) ): raise NotFound() + def list(self, request: Request, *args, **kwargs) -> Response: # noqa: ARG002 + self.parent_object = self.get_parent_object() + + self.check_permissions_for_list(request=request) + + return self._list_actions_available_to_user(request) + + def retrieve(self, request, *args, **kwargs): # noqa: ARG002 + self.parent_object = self.get_parent_object() + action_ = self.get_object() + + self.check_permissions_for_run(request=request, action=action_) + config_schema, config, adcm_meta = get_action_configuration(action_=action_, object_=self.parent_object) serializer = self.get_serializer_class()( @@ -219,15 +215,7 @@ def run(self, request: Request, *args, **kwargs) -> Response: # noqa: ARG001, A self.parent_object = self.get_parent_object() target_action = self.get_object() - if ( - not self.parent_object - or not request.user.has_perm(perm=f"cm.view_{self.parent_object.__class__.__name__.lower()}") - and not request.user.has_perm( - perm=f"cm.view_{self.parent_object.__class__.__name__.lower()}", obj=self.parent_object - ) - or not check_run_perms(user=request.user, action=target_action, obj=self.parent_object) - ): - raise NotFound() + self.check_permissions_for_run(request=request, action=target_action) if reason := target_action.get_start_impossible_reason(self.parent_object): raise AdcmEx("ACTION_ERROR", msg=reason) @@ -273,6 +261,19 @@ def run(self, request: Request, *args, **kwargs) -> Response: # noqa: ARG001, A return Response(status=HTTP_200_OK, data=TaskListSerializer(instance=task).data) + def _list_actions_available_to_user(self, request: Request) -> Response: + actions = self.filter_queryset(self.get_queryset()) + allowed_actions_mask = [act.allowed(self.prototype_objects[act.prototype]) for act in actions] + actions = list(compress(actions, allowed_actions_mask)) + actions = filter_actions_by_user_perm(user=request.user, obj=self._get_actions_owner(), actions=actions) + + serializer = self.get_serializer_class()(instance=actions, many=True, context={"obj": self.parent_object}) + + return Response(data=serializer.data) + + def _get_actions_owner(self) -> ADCMEntity: + return self.parent_object + @extend_schema_view( run=extend_schema( @@ -308,26 +309,3 @@ def get_parent_object(self): def list(self, request: Request, *args, **kwargs) -> Response: # noqa: ARG002 self.parent_object = self.get_parent_object() return self._list_actions_available_to_user(request) - - -class ActionHostGroupActionViewSet(ActionViewSet): - def get_parent_object(self) -> ActionHostGroup | None: - if "action_host_group_pk" not in self.kwargs: - return None - - parent = super().get_parent_object() - - return ( - ActionHostGroup.objects.prefetch_related("object__prototype") - .filter( - pk=self.kwargs["action_host_group_pk"], - object_id=parent.pk, - object_type=ContentType.objects.get_for_model(model=parent.__class__), - ) - .first() - ) - - def get_queryset(self, *_, **__) -> QuerySet: - group_owner = self.parent_object.object - self.prototype_objects = {group_owner.prototype: group_owner} - return self.general_queryset.filter(prototype=group_owner.prototype, allow_for_action_host_group=True) diff --git a/python/api_v2/action_host_group/views.py b/python/api_v2/action_host_group/views.py index dbc449a3de..1ccb2af601 100644 --- a/python/api_v2/action_host_group/views.py +++ b/python/api_v2/action_host_group/views.py @@ -10,10 +10,19 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import NamedTuple + +from adcm.permissions import ( + EDIT_ACTION_HOST_GROUPS, + VIEW_ACTION_HOST_GROUPS, + VIEW_CLUSTER_PERM, + VIEW_COMPONENT_PERM, + VIEW_SERVICE_PERM, +) from audit.utils import audit from cm.converters import core_type_to_model from cm.errors import AdcmEx -from cm.models import ActionHostGroup, Cluster, Host +from cm.models import Action, ActionHostGroup, ADCMEntity, Cluster, ClusterObject, Host, ServiceComponent from cm.services.action_host_group import ( ActionHostGroupRepo, ActionHostGroupService, @@ -24,15 +33,19 @@ ) from core.types import ADCMCoreType, CoreObjectDescriptor, HostGroupDescriptor from django.contrib.contenttypes.models import ContentType -from django.db.models import F, QuerySet +from django.db.models import F, Model, QuerySet from django.db.transaction import atomic +from guardian.shortcuts import get_objects_for_user +from rbac.models import User from rest_framework.decorators import action -from rest_framework.exceptions import NotFound +from rest_framework.exceptions import NotFound, PermissionDenied from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import Serializer from rest_framework.status import HTTP_201_CREATED, HTTP_204_NO_CONTENT +from api_v2.action.utils import has_run_perms +from api_v2.action.views import ActionViewSet from api_v2.action_host_group.serializers import ( ActionHostGroupCreateResultSerializer, ActionHostGroupCreateSerializer, @@ -42,6 +55,60 @@ ) from api_v2.views import CamelCaseGenericViewSet, with_group_object, with_parent_object +_PARENT_PERMISSION_MAP: dict[ADCMCoreType, tuple[str, type[Model]]] = { + ADCMCoreType.CLUSTER: (VIEW_CLUSTER_PERM, Cluster), + ADCMCoreType.SERVICE: (VIEW_SERVICE_PERM, ClusterObject), + ADCMCoreType.COMPONENT: (VIEW_COMPONENT_PERM, ServiceComponent), +} + + +class PermissionCheckDTO(NamedTuple): + require_edit: bool + no_group_view_err: type[Exception] = NotFound + + +VIEW_ONLY_PERMISSION_DENIED = PermissionCheckDTO(require_edit=False, no_group_view_err=PermissionDenied) +VIEW_ONLY_NOT_FOUND = PermissionCheckDTO(require_edit=False, no_group_view_err=NotFound) +REQUIRE_EDIT_NOT_FOUND = PermissionCheckDTO(require_edit=True, no_group_view_err=NotFound) +REQUIRE_EDIT_PERMISSION_DENIED = PermissionCheckDTO(require_edit=True, no_group_view_err=PermissionDenied) + + +def check_has_group_permissions_for_object( + user: User, parent_object: Cluster | ClusterObject | ServiceComponent, dto: PermissionCheckDTO +) -> None: + """ + If user hasn't got enough permissions on group, an error will be raised. + + Doesn't check permissions on parent. + """ + + model_name = parent_object.__class__.__name__.lower() + view_perm_name = f"{VIEW_ACTION_HOST_GROUPS}_{model_name}" + + if not (user.has_perm(view_perm_name, obj=parent_object) or user.has_perm(f"cm.{view_perm_name}")): + raise dto.no_group_view_err() + + if not dto.require_edit: + return + + if not user.has_perm(f"{EDIT_ACTION_HOST_GROUPS}_{model_name}", obj=parent_object): + raise PermissionDenied() + + +def check_has_group_permissions(user: User, parent: CoreObjectDescriptor, dto: PermissionCheckDTO) -> None: + """ + Same as `check_has_group_permissions_for_object`, but with checking view permissions on parent + """ + + view_perm, model_class = _PARENT_PERMISSION_MAP[parent.type] + + try: + parent_object = get_objects_for_user(user=user, perms=view_perm, klass=model_class).get(pk=parent.id) + except model_class.DoesNotExist: + raise NotFound() from None + + check_has_group_permissions_for_object(user=user, parent_object=parent_object, dto=dto) + class ActionHostGroupViewSet(CamelCaseGenericViewSet): queryset = ActionHostGroup.objects.prefetch_related("hosts").order_by("id") @@ -59,6 +126,8 @@ def get_serializer_class(self) -> type[Serializer]: @audit @with_parent_object def create(self, request: Request, *_, parent: CoreObjectDescriptor, **__) -> Response: + check_has_group_permissions(user=request.user, parent=parent, dto=REQUIRE_EDIT_PERMISSION_DENIED) + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -80,14 +149,18 @@ def create(self, request: Request, *_, parent: CoreObjectDescriptor, **__) -> Re ) @with_parent_object - def list(self, *_, parent: CoreObjectDescriptor, **__) -> Response: + def list(self, request: Request, parent: CoreObjectDescriptor, **__) -> Response: + check_has_group_permissions(user=request.user, parent=parent, dto=VIEW_ONLY_PERMISSION_DENIED) + serializer = self.get_serializer( instance=self.paginate_queryset(self.filter_by_parent(qs=self.get_queryset(), parent=parent)), many=True ) return self.get_paginated_response(serializer.data) @with_parent_object - def retrieve(self, *_, parent: CoreObjectDescriptor, pk: str, **__) -> Response: + def retrieve(self, request: Request, parent: CoreObjectDescriptor, pk: str, **__) -> Response: + check_has_group_permissions(user=request.user, parent=parent, dto=VIEW_ONLY_NOT_FOUND) + try: instance = self.filter_by_parent(qs=self.get_queryset(), parent=parent).get(id=int(pk)) except ActionHostGroup.DoesNotExist: @@ -96,7 +169,9 @@ def retrieve(self, *_, parent: CoreObjectDescriptor, pk: str, **__) -> Response: return Response(data=self.get_serializer(instance=instance).data) @with_parent_object - def destroy(self, *_, parent: CoreObjectDescriptor, pk: str, **__) -> Response: + def destroy(self, request: Request, parent: CoreObjectDescriptor, pk: str, **__) -> Response: + check_has_group_permissions(user=request.user, parent=parent, dto=REQUIRE_EDIT_NOT_FOUND) + if not self.filter_by_parent(qs=ActionHostGroup.objects.filter(id=pk), parent=parent).exists(): raise NotFound() @@ -109,10 +184,12 @@ def destroy(self, *_, parent: CoreObjectDescriptor, pk: str, **__) -> Response: @action(methods=["get"], detail=True, url_path="host-candidates", url_name="host-candidates", pagination_class=None) @with_parent_object - def host_candidate(self, *_, parent: CoreObjectDescriptor, pk: str, **__): + def host_candidate(self, request: Request, parent: CoreObjectDescriptor, pk: str, **__): if not self.filter_by_parent(qs=ActionHostGroup.objects.filter(id=pk), parent=parent).exists(): raise NotFound() + check_has_group_permissions(user=request.user, parent=parent, dto=VIEW_ONLY_NOT_FOUND) + hosts = self.action_host_group_service.get_host_candidates(group_id=int(pk)) return Response(data=list(Host.objects.values("id", name=F("fqdn")).filter(id__in=hosts).order_by("fqdn"))) @@ -147,7 +224,11 @@ def handle_exception(self, exc: Exception) -> None: @audit @with_group_object - def create(self, request: Request, *_, host_group: HostGroupDescriptor, **__) -> Response: + def create( + self, request: Request, *_, parent: CoreObjectDescriptor, host_group: HostGroupDescriptor, **__ + ) -> Response: + check_has_group_permissions(user=request.user, parent=parent, dto=REQUIRE_EDIT_NOT_FOUND) + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -163,7 +244,11 @@ def create(self, request: Request, *_, host_group: HostGroupDescriptor, **__) -> @audit @with_group_object - def destroy(self, *_, host_group: HostGroupDescriptor, pk: str, **__) -> Response: + def destroy( + self, request: Request, parent: CoreObjectDescriptor, host_group: HostGroupDescriptor, pk: str, **__ + ) -> Response: + check_has_group_permissions(user=request.user, parent=parent, dto=REQUIRE_EDIT_NOT_FOUND) + if not ActionHostGroup.hosts.through.objects.filter(actionhostgroup_id=host_group.id, host_id=pk).exists(): raise NotFound() @@ -171,3 +256,49 @@ def destroy(self, *_, host_group: HostGroupDescriptor, pk: str, **__) -> Respons self.action_host_group_service.remove_hosts_from_group(group_id=host_group.id, hosts=[int(pk)]) return Response(status=HTTP_204_NO_CONTENT) + + +class ActionHostGroupActionViewSet(ActionViewSet): + def get_parent_object(self) -> ActionHostGroup | None: + if "action_host_group_pk" not in self.kwargs: + return None + + parent = super().get_parent_object() + + return ( + ActionHostGroup.objects.prefetch_related("object__prototype") + .filter( + pk=self.kwargs["action_host_group_pk"], + object_id=parent.pk, + object_type=ContentType.objects.get_for_model(model=parent.__class__), + ) + .first() + ) + + def get_queryset(self, *_, **__) -> QuerySet: + group_owner = self.parent_object.object + self.prototype_objects = {group_owner.prototype: group_owner} + return self.general_queryset.filter(prototype=group_owner.prototype, allow_for_action_host_group=True) + + def check_permissions_for_list(self, request: Request) -> None: + if not (self.parent_object and self.parent_object.object): + raise NotFound() + + group_owner = self.parent_object.object + model_name = group_owner.__class__.__name__.lower() + if not ( + request.user.has_perm(perm=f"cm.view_{model_name}") + or request.user.has_perm(perm=f"cm.view_{model_name}", obj=group_owner) + ): + raise NotFound() + + check_has_group_permissions_for_object(user=request.user, parent_object=group_owner, dto=VIEW_ONLY_NOT_FOUND) + + def check_permissions_for_run(self, request: Request, action: Action) -> None: + self.check_permissions_for_list(request=request) + + if not has_run_perms(user=request.user, action=action, obj=self.parent_object.object): + raise NotFound() + + def _get_actions_owner(self) -> ADCMEntity: + return self.parent_object.object diff --git a/python/api_v2/cluster/urls.py b/python/api_v2/cluster/urls.py index b9f55a80bf..869a65eee5 100644 --- a/python/api_v2/cluster/urls.py +++ b/python/api_v2/cluster/urls.py @@ -15,8 +15,12 @@ from rest_framework_nested.routers import NestedSimpleRouter, SimpleRouter -from api_v2.action.views import ActionHostGroupActionViewSet, ActionViewSet -from api_v2.action_host_group.views import ActionHostGroupViewSet, HostActionHostGroupViewSet +from api_v2.action.views import ActionViewSet +from api_v2.action_host_group.views import ( + ActionHostGroupActionViewSet, + ActionHostGroupViewSet, + HostActionHostGroupViewSet, +) from api_v2.cluster.views import ClusterViewSet from api_v2.component.views import ComponentViewSet, HostComponentViewSet from api_v2.config.views import ConfigLogViewSet diff --git a/python/api_v2/tests/test_action_host_group.py b/python/api_v2/tests/test_action_host_group.py index e67a348d77..e575e10999 100644 --- a/python/api_v2/tests/test_action_host_group.py +++ b/python/api_v2/tests/test_action_host_group.py @@ -10,6 +10,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from contextlib import contextmanager, nullcontext +from itertools import chain from operator import itemgetter from cm.converters import model_to_core_type, orm_object_to_core_type @@ -17,10 +19,15 @@ from cm.services.action_host_group import ActionHostGroupRepo, ActionHostGroupService, CreateDTO from cm.tests.mocks.task_runner import RunTaskMock from core.types import CoreObjectDescriptor +from rbac.models import Role +from rbac.services.group import create +from rbac.services.policy import policy_create +from rbac.services.role import role_create from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT, + HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT, ) @@ -30,12 +37,27 @@ ACTION_HOST_GROUPS = "action-host-groups" -class TestActionHostGroup(BaseAPITestCase): +class CommonActionHostGroupTest(BaseAPITestCase): + action_host_group_service = ActionHostGroupService(repository=ActionHostGroupRepo()) + + def create_action_host_group( + self, name: str, owner: Cluster | ClusterObject | ServiceComponent, description: str = "" + ) -> ActionHostGroup: + return ActionHostGroup.objects.get( + id=self.action_host_group_service.create( + CreateDTO( + name=name, + owner=CoreObjectDescriptor(id=owner.id, type=orm_object_to_core_type(owner)), + description=description, + ) + ) + ) + + +class TestActionHostGroup(CommonActionHostGroupTest): def setUp(self) -> None: super().setUp() - self.action_host_group_service = ActionHostGroupService(repository=ActionHostGroupRepo()) - self.bundle = self.add_bundle(source_dir=self.test_bundles_dir / "cluster_action_host_group") self.cluster = self.service = self.component = None @@ -49,19 +71,6 @@ def setUp(self) -> None: self.add_host(provider=self.hostprovider, fqdn=f"host-{i}", cluster=self.cluster) for i in range(3) ] - def create_group( - self, name: str, owner: Cluster | ClusterObject | ServiceComponent, description: str = "" - ) -> ActionHostGroup: - return ActionHostGroup.objects.get( - id=self.action_host_group_service.create( - CreateDTO( - name=name, - owner=CoreObjectDescriptor(id=owner.id, type=orm_object_to_core_type(owner)), - description=description, - ) - ) - ) - def test_create_group_success(self) -> None: group_counter = 0 @@ -133,11 +142,11 @@ def test_delete_success(self) -> None: cluster=self.cluster, entries=((self.hosts[0], self.component), (self.hosts[1], self.component)) ) - cluster_group = self.create_group(name="Cluster Group", owner=self.cluster) + cluster_group = self.create_action_host_group(name="Cluster Group", owner=self.cluster) self.action_host_group_service.add_hosts_to_group(group_id=cluster_group.id, hosts=[self.hosts[0].id]) - service_group_1 = self.create_group(name="Service Group", owner=self.service) - self.create_group(name="Service Group #2", owner=self.service) - component_group = self.create_group(name="Component Group", owner=self.component) + service_group_1 = self.create_action_host_group(name="Service Group", owner=self.service) + self.create_action_host_group(name="Service Group #2", owner=self.service) + component_group = self.create_action_host_group(name="Component Group", owner=self.component) self.action_host_group_service.add_hosts_to_group( group_id=component_group.id, hosts=[self.hosts[0].id, self.hosts[1].id] ) @@ -162,8 +171,8 @@ def test_retrieve_success(self) -> None: name = "aWeSOME Group NAmE" host_1, host_2, host_3, *_ = self.hosts self.set_hostcomponent(cluster=self.cluster, entries=[(host, self.component) for host in self.hosts]) - another_group = self.create_group(name=f"{name}XXX21321", owner=self.service, description="hoho") - service_group = self.create_group(name=name, owner=self.service) + another_group = self.create_action_host_group(name=f"{name}XXX21321", owner=self.service, description="hoho") + service_group = self.create_action_host_group(name=name, owner=self.service) self.action_host_group_service.add_hosts_to_group(group_id=service_group.id, hosts=[host_1.id, host_3.id]) self.action_host_group_service.add_hosts_to_group(group_id=another_group.id, hosts=[host_1.id, host_2.id]) @@ -198,21 +207,23 @@ def test_list_success(self) -> None: entries=[(host, self.component) for host in self.hosts] + [(self.hosts[1], another_component)], ) - self.create_group(name="Cluster Group", owner=self.cluster) - self.create_group(name="Service Group", owner=self.service) - self.create_group(name="Service Group #2", owner=self.service) - component_group_1 = self.create_group(name=name_1, owner=self.component) - component_group_2 = self.create_group(name=name_2, owner=self.component, description=description) - another_component_group = self.create_group(name=name_2, owner=another_component, description=description) - component_group_3 = self.create_group(name=name_3, owner=self.component, description=description) + self.create_action_host_group(name="Cluster Group", owner=self.cluster) + self.create_action_host_group(name="Service Group", owner=self.service) + self.create_action_host_group(name="Service Group #2", owner=self.service) + component_group_1 = self.create_action_host_group(name=name_1, owner=self.component) + component_group_2 = self.create_action_host_group(name=name_2, owner=self.component, description=description) + another_component_group = self.create_action_host_group( + name=name_2, owner=another_component, description=description + ) + component_group_3 = self.create_action_host_group(name=name_3, owner=self.component, description=description) self.action_host_group_service.add_hosts_to_group( group_id=component_group_1.id, hosts=[self.hosts[0].id, self.hosts[2].id] ) self.action_host_group_service.add_hosts_to_group(group_id=component_group_3.id, hosts=[self.hosts[0].id]) self.action_host_group_service.add_hosts_to_group(group_id=another_component_group.id, hosts=[self.hosts[1].id]) - # amount of queries checked on 1 component group -- it's the same - with self.assertNumQueries(6): + # amount of queries checked on no host group -- it's the same + with self.assertNumQueries(8): response = self.client.v2[self.component, ACTION_HOST_GROUPS].get() self.assertEqual(response.status_code, HTTP_200_OK) @@ -250,10 +261,10 @@ def test_host_candidates_success(self) -> None: host_1_data, host_2_data, host_3_data = ({"id": host.id, "name": host.fqdn} for host in self.hosts) self.set_hostcomponent(cluster=self.cluster, entries=[(host_1, self.component), (host_2, self.component)]) - cluster_group = self.create_group(name="Some Taken", owner=self.cluster) - cluster_group_2 = self.create_group(name="None Taken", owner=self.cluster) - service_group = self.create_group(name="One Taken", owner=self.service) - component_group = self.create_group(name="None Taken", owner=self.component) + cluster_group = self.create_action_host_group(name="Some Taken", owner=self.cluster) + cluster_group_2 = self.create_action_host_group(name="None Taken", owner=self.cluster) + service_group = self.create_action_host_group(name="One Taken", owner=self.service) + component_group = self.create_action_host_group(name="None Taken", owner=self.component) self.action_host_group_service.add_hosts_to_group(group_id=cluster_group.id, hosts=[host_1.id, host_2.id]) self.action_host_group_service.add_hosts_to_group(group_id=service_group.id, hosts=[host_1.id]) @@ -273,12 +284,10 @@ def test_host_candidates_success(self) -> None: self.assertListEqual(response.json(), expected) -class TestHostsInActionHostGroup(BaseAPITestCase): +class TestHostsInActionHostGroup(CommonActionHostGroupTest): def setUp(self) -> None: super().setUp() - self.action_host_group_service = ActionHostGroupService(repository=ActionHostGroupRepo()) - self.bundle = self.add_bundle(source_dir=self.test_bundles_dir / "cluster_action_host_group") self.cluster = self.service = self.component = None @@ -307,15 +316,7 @@ def setUp(self) -> None: objects = (self.cluster, self.service, self.component, self.service_2, self.component_2, self.component_3) self.group_map: dict[Cluster | ClusterObject | ServiceComponent, ActionHostGroup] = { - object_: ActionHostGroup.objects.get( - id=self.action_host_group_service.create( - CreateDTO( - owner=CoreObjectDescriptor(id=object_.id, type=orm_object_to_core_type(object_)), - name=f"Group for {object_.name}", - description="", - ) - ) - ) + object_: self.create_action_host_group(owner=object_, name=f"Group for {object_.name}") for object_ in objects } @@ -417,11 +418,7 @@ def test_same_hosts_in_group_of_one_object_success(self) -> None: for target in (self.cluster, self.service, self.component): group = self.group_map[target] type_ = orm_object_to_core_type(target) - second_group = ActionHostGroup.objects.get( - id=self.action_host_group_service.create( - CreateDTO(owner=CoreObjectDescriptor(id=target.id, type=type_), name="Another Group") - ) - ) + second_group = self.create_action_host_group(owner=target, name="Another Group") with self.subTest(type_.name): for host in (host_1, host_2): @@ -432,12 +429,10 @@ def test_same_hosts_in_group_of_one_object_success(self) -> None: self.assertEqual(response_second_group.status_code, HTTP_201_CREATED) -class TestActionsOnActionHostGroup(BaseAPITestCase): +class TestActionsOnActionHostGroup(CommonActionHostGroupTest): def setUp(self) -> None: super().setUp() - self.action_host_group_service = ActionHostGroupService(repository=ActionHostGroupRepo()) - self.bundle = self.add_bundle(source_dir=self.test_bundles_dir / "cluster_action_host_group") self.cluster = self.add_cluster(bundle=self.bundle, name="Cluster Bombaster") @@ -446,14 +441,8 @@ def setUp(self) -> None: objects = (self.cluster, self.service, self.component) self.group_map: dict[Cluster | ClusterObject | ServiceComponent, ActionHostGroup] = { - object_: ActionHostGroup.objects.get( - id=self.action_host_group_service.create( - CreateDTO( - owner=CoreObjectDescriptor(id=object_.id, type=orm_object_to_core_type(object_)), - name=f"Group for {object_.name}", - description="wait for action", - ) - ) + object_: self.create_action_host_group( + name=f"Group for {object_.name}", owner=object_, description="wait for action" ) for object_ in objects } @@ -563,3 +552,296 @@ def test_run(self) -> None: ) self.assertEqual(response.status_code, HTTP_201_CREATED) + + +class TestActionHostGroupRBAC(CommonActionHostGroupTest): + def setUp(self) -> None: + super().setUp() + + self.bundle = self.add_bundle(source_dir=self.test_bundles_dir / "cluster_action_host_group") + + self.cluster = self.add_cluster(bundle=self.bundle, name="Cluster") + self.service = self.add_services_to_cluster(["example"], cluster=self.cluster).get() + self.component = self.service.servicecomponent_set.first() + + self.control_cluster = self.add_cluster(bundle=self.bundle, name="Control Cluster") + self.control_service = self.add_services_to_cluster(["example"], cluster=self.control_cluster).get() + self.control_component = self.control_service.servicecomponent_set.first() + + self.hostprovider = self.add_provider(bundle=self.provider_bundle, name="Provider") + self.host_1, self.host_2 = ( + self.add_host(provider=self.hostprovider, fqdn=f"host-{i}", cluster=self.cluster) for i in range(2) + ) + self.host_3, self.host_4 = ( + self.add_host(provider=self.hostprovider, fqdn=f"control-host-{i}", cluster=self.control_cluster) + for i in range(2) + ) + + self.set_hostcomponent( + cluster=self.cluster, entries=[(self.host_1, self.component), (self.host_2, self.component)] + ) + self.set_hostcomponent( + cluster=self.control_cluster, + entries=[(self.host_3, self.control_component), (self.host_4, self.control_component)], + ) + + self.group_map: dict[Cluster | ClusterObject | ServiceComponent, ActionHostGroup] = { + object_: self.create_action_host_group(name=f"Group for {object_.name}", owner=object_) + for object_ in (self.cluster, self.service, self.component) + } + for group in self.group_map.values(): + self.action_host_group_service.add_hosts_to_group(group_id=group.id, hosts=[self.host_2.id]) + + self.control_group_map: dict[Cluster | ClusterObject | ServiceComponent, ActionHostGroup] = { + object_: self.create_action_host_group(name=f"Group for {object_.name}", owner=object_) + for object_ in (self.control_cluster, self.control_service, self.control_component) + } + for group in self.control_group_map.values(): + self.action_host_group_service.add_hosts_to_group(group_id=group.id, hosts=[self.host_3.id]) + + self.user_credentials = {"username": "test_user_username", "password": "test_user_password"} + self.user = self.create_user(user_data=self.user_credentials) + self.user_client = self.client_class() + self.user_client.login(**self.user_credentials) + + @contextmanager + def grant_permissions_to_run_actions(self): + # let user see 1 exact action on each object + actions_role = role_create( + display_name="Run action of action host group", + child=list(Role.objects.filter(name__contains="allowed_in_group_1", type="business")), + ) + group = create(name_to_display="Group for actions policy", user_set=[{"id": self.user.pk}]) + policies = [ + policy_create( + name=f"Action Policy for {target.__class__.__name__}", role=actions_role, group=[group], object=[target] + ) + for target in self.group_map + ] + + yield + + for object_ in (*policies, actions_role, group): + object_.delete() + + def test_no_perms_or_cluster_view_no_access(self) -> None: + for context, name in ( + ( + self.grant_permissions(to=self.user, on=self.cluster, role_name="View cluster configurations"), + "Cluster View", + ), + (nullcontext(), "No Perms"), + ): + with context: + for target, group in chain.from_iterable((self.group_map.items(), self.control_group_map.items())): + action = Action.objects.get(prototype=target.prototype, name="allowed_in_group_1") + + for ep, disallowed_method in ( + (self.user_client.v2[group], "get"), + (self.user_client.v2[group, "host-candidates"], "get"), + (self.user_client.v2[group, "actions"], "get"), + (self.user_client.v2[group, "actions", action], "get"), + (self.user_client.v2[group, "actions", action, "run"], "post"), + (self.user_client.v2[group, "hosts"], "post"), + (self.user_client.v2[group, "hosts", self.host_2.id], "delete"), + (self.user_client.v2[group, "hosts", self.host_3.id], "delete"), + (self.user_client.v2[group], "delete"), + ): + with self.subTest(f"{name} | {disallowed_method.upper()} {ep.path} - 404"): + response = getattr(ep, disallowed_method)() + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + + def test_view_role_on_cluster_access(self) -> None: + with self.grant_permissions_to_run_actions(), self.grant_permissions( + to=self.user, on=self.cluster, role_name="View action host groups" + ): + for target, group in self.group_map.items(): + action = Action.objects.get(prototype=target.prototype, name="allowed_in_group_1") + + for allowed_to_view_ep in ( + self.user_client.v2[target, ACTION_HOST_GROUPS], + self.user_client.v2[group], + self.user_client.v2[group, "host-candidates"], + self.user_client.v2[group, "actions"], + self.user_client.v2[group, "actions", action], + ): + with self.subTest(f"GET {allowed_to_view_ep.path} - 200"): + response = allowed_to_view_ep.get() + self.assertEqual(response.status_code, HTTP_200_OK) + + for ep, disallowed_method in ( + (self.user_client.v2[target, ACTION_HOST_GROUPS], "post"), + (self.user_client.v2[group, "hosts"], "post"), + (self.user_client.v2[group, "hosts", self.host_2.id], "delete"), + (self.user_client.v2[group], "delete"), + ): + with self.subTest(f"{disallowed_method.upper()} {ep.path} - 403"): + response = getattr(ep, disallowed_method)() + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + + for target, group in self.control_group_map.items(): + action = Action.objects.get(prototype=target.prototype, name="allowed_in_group_1") + + for ep, disallowed_method in ( + (self.user_client.v2[group], "get"), + (self.user_client.v2[group, "host-candidates"], "get"), + (self.user_client.v2[group, "actions"], "get"), + (self.user_client.v2[group, "actions", action], "get"), + (self.user_client.v2[target, ACTION_HOST_GROUPS], "post"), + (self.user_client.v2[group, "actions", action, "run"], "post"), + (self.user_client.v2[group, "hosts"], "post"), + (self.user_client.v2[group, "hosts", self.host_3.id], "delete"), + (self.user_client.v2[group], "delete"), + ): + with self.subTest(f"{disallowed_method.upper()} {ep.path} - 404"): + response = getattr(ep, disallowed_method)() + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + + def test_edit_role_on_service_access(self) -> None: + with self.grant_permissions(to=self.user, on=self.service, role_name="Manage action host groups"): + for target in (self.service, self.component): + type_name = orm_object_to_core_type(target).name + group = self.group_map[target] + action = Action.objects.get(prototype=target.prototype, name="allowed_in_group_1") + + with self.subTest(f"[{type_name}] GET EPs"): + for ep in ( + self.user_client.v2[target, ACTION_HOST_GROUPS], + self.user_client.v2[group], + self.user_client.v2[group, "host-candidates"], + ): + self.assertEqual(ep.get().status_code, HTTP_200_OK) + + with self.subTest(f"[{type_name}] GET Actions No Run Perms"): + response = self.user_client.v2[group, "actions"].get() + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(len(response.json()), 0) + + response = self.user_client.v2[group, "actions", action].get() + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + + with self.grant_permissions_to_run_actions(): + with self.subTest(f"[{type_name}] GET Actions With Run Perms"): + response = self.user_client.v2[group, "actions"].get() + self.assertEqual(response.status_code, HTTP_200_OK) + data = response.json() + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["name"], action.name) + + response = self.user_client.v2[group, "actions", action].get() + self.assertEqual(response.status_code, HTTP_200_OK) + + with self.subTest(f"[{type_name}] RUN Action With Run Perms"), RunTaskMock(): + response = self.user_client.v2[group, "actions", action, "run"].post(data={}) + self.assertEqual(response.status_code, HTTP_200_OK) + + ConcernItem.objects.all().delete() + TaskLog.objects.all().delete() + + with self.subTest(f"[{type_name}] Create/Delete Group"): + response = self.user_client.v2[target, ACTION_HOST_GROUPS].post( + data={"name": "bestcool", "description": ""} + ) + self.assertEqual(response.status_code, HTTP_201_CREATED) + new_group_id = response.json()["id"] + response = self.user_client.v2[target, ACTION_HOST_GROUPS, new_group_id].delete() + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) + + with self.subTest(f"[{type_name}] Edit Group Hosts"): + response = self.user_client.v2[group, "hosts"].post(data={"hostId": self.host_1.id}) + self.assertEqual(response.status_code, HTTP_201_CREATED) + response = self.user_client.v2[group, "hosts", self.host_1].delete() + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) + + with self.subTest("No edit/view on cluster's groups"): + target = self.cluster + group = self.group_map[target] + + response = self.user_client.v2[target, ACTION_HOST_GROUPS].get() + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + + response = self.user_client.v2[target, ACTION_HOST_GROUPS].post() + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + + for ep, disallowed_method in ( + (self.user_client.v2[group], "get"), + (self.user_client.v2[group, "host-candidates"], "get"), + (self.user_client.v2[group, "actions"], "get"), + (self.user_client.v2[group, "actions", action], "get"), + (self.user_client.v2[group, "actions", action, "run"], "post"), + (self.user_client.v2[group, "hosts"], "post"), + (self.user_client.v2[group, "hosts", self.host_2.id], "delete"), + (self.user_client.v2[group, "hosts", self.host_3.id], "delete"), + (self.user_client.v2[group], "delete"), + ): + response = getattr(ep, disallowed_method)() + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + + def test_built_in_roles(self) -> None: + with self.grant_permissions(to=self.user, on=[], role_name="ADCM User"), self.subTest("ADCM User"): + response = self.user_client.v2[self.control_cluster, ACTION_HOST_GROUPS].get() + self.assertEqual(response.status_code, HTTP_200_OK) + + response = self.user_client.v2[self.control_cluster, ACTION_HOST_GROUPS].post() + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + + response = self.user_client.v2[self.control_group_map[self.control_service], "actions"].get() + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertListEqual(response.json(), []) + + action = Action.objects.get(prototype=self.control_service.prototype, name="allowed_from_service") + response = self.user_client.v2[ + self.control_group_map[self.control_service], "actions", action, "run" + ].post() + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + + with self.grant_permissions(to=self.user, on=self.cluster, role_name="Cluster Administrator"), self.subTest( + "Cluster Admin" + ): + response = self.user_client.v2[self.control_cluster, ACTION_HOST_GROUPS].get() + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + + response = self.user_client.v2[self.group_map[self.cluster], "hosts", self.host_2].delete() + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) + + response = self.user_client.v2[self.group_map[self.service], "hosts"].post(data={"hostId": self.host_1.id}) + self.assertEqual(response.status_code, HTTP_201_CREATED) + + action = Action.objects.get(prototype=self.component.prototype, name="allowed_from_component") + with RunTaskMock(): + response = self.user_client.v2[self.group_map[self.component], "actions", action, "run"].post() + self.assertEqual(response.status_code, HTTP_200_OK) + + ConcernItem.objects.all().delete() + TaskLog.objects.all().delete() + + with self.grant_permissions(to=self.user, on=self.service, role_name="Service Administrator"), self.subTest( + "Service Admin" + ): + response = self.user_client.v2[self.cluster, ACTION_HOST_GROUPS].get() + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + + response = self.user_client.v2[self.cluster, ACTION_HOST_GROUPS].post() + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + + response = self.user_client.v2[self.group_map[self.service], "actions"].get() + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(len(response.json()), 2) + + response = self.user_client.v2[self.group_map[self.component], "hosts", self.host_2].delete() + self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) + + response = self.user_client.v2[self.group_map[self.cluster], "actions"].get() + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + + response = self.user_client.v2[self.group_map[self.service], "actions"].get() + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual( + sorted(entry["name"] for entry in response.json()), ["allowed_from_service", "allowed_in_group_1"] + ) + + response = self.user_client.v2[self.group_map[self.component], "actions"].get() + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual( + sorted(entry["name"] for entry in response.json()), ["allowed_from_component", "allowed_in_group_1"] + ) diff --git a/python/rbac/tests/test_policy/test_cluster_admin_role.py b/python/rbac/tests/test_policy/test_cluster_admin_role.py index 9ed5805f0a..3e18f6ba7a 100644 --- a/python/rbac/tests/test_policy/test_cluster_admin_role.py +++ b/python/rbac/tests/test_policy/test_cluster_admin_role.py @@ -91,6 +91,12 @@ def test_policy_with_cluster_admin_role(self): "view_objectconfig", "view_servicecomponent", "view_upgrade_of_cluster", + "view_action_host_group_cluster", + "view_action_host_group_clusterobject", + "view_action_host_group_servicecomponent", + "edit_action_host_group_cluster", + "edit_action_host_group_clusterobject", + "edit_action_host_group_servicecomponent", }, ) @@ -445,6 +451,12 @@ def test_adding_new_policy_keeps_previous_permission(self): "unmap_host_from_cluster", "view_action", "view_cluster", + "view_action_host_group_cluster", + "view_action_host_group_clusterobject", + "view_action_host_group_servicecomponent", + "edit_action_host_group_cluster", + "edit_action_host_group_clusterobject", + "edit_action_host_group_servicecomponent", "view_clusterobject", "view_configlog", "view_host", @@ -522,5 +534,11 @@ def test_adding_new_policy_keeps_previous_permission(self): "view_servicecomponent", "view_upgrade_of_cluster", "view_upgrade_of_hostprovider", + "view_action_host_group_cluster", + "view_action_host_group_clusterobject", + "view_action_host_group_servicecomponent", + "edit_action_host_group_cluster", + "edit_action_host_group_clusterobject", + "edit_action_host_group_servicecomponent", }, ) diff --git a/python/rbac/upgrade/role_spec.yaml b/python/rbac/upgrade/role_spec.yaml index c96c1b2b43..ad90ec8b44 100644 --- a/python/rbac/upgrade/role_spec.yaml +++ b/python/rbac/upgrade/role_spec.yaml @@ -1,6 +1,6 @@ --- -version: 11 +version: 12 roles: - name: Add host @@ -1402,8 +1402,9 @@ roles: - Get service - Get component - Get task and jobs - - View any object host-components + - View any object action host groups - View any object configuration + - View any object host-components - View any object import - name: Service Administrator @@ -1416,15 +1417,19 @@ roles: module_name: rbac.roles class_name: ParentRole child: + - Edit action host groups of component + - Edit action host groups of service - Edit component config - Edit config - Edit service config - Get component object - Get host object - Get service object - - Manage service imports - - Manage service Maintenance mode - Manage component Maintenance mode + - Manage service Maintenance mode + - Manage service imports + - View action host groups of component + - View action host groups of service - View component config - View host config - View host-components hidden @@ -1461,6 +1466,9 @@ roles: - Add service hidden - Create service - Delete host + - Edit action host groups of cluster + - Edit action host groups of component + - Edit action host groups of service - Edit cluster - Edit cluster config - Edit component config @@ -1484,6 +1492,9 @@ roles: - Remove service - Unmap hosts hidden - Upgrade cluster bundle + - View action host groups of cluster + - View action host groups of component + - View action host groups of service - View cluster config - View component config - View host config @@ -1624,3 +1635,110 @@ roles: child: - View audit logins - View audit operations + +# Action Host Groups + + - &view_action_host_group_hidden + module_name: rbac.roles + name: View action host groups of cluster + type: hidden + class_name: ObjectRole + parametrized_by: + - cluster + apps: + - label: cm + models: + - name: cluster + codenames: &ahg_view_hostnames + - view + - view_action_host_group + + - <<: *view_action_host_group_hidden + name: View action host groups of service + parametrized_by: + - service + apps: + - label: cm + models: + - name: clusterobject + codenames: *ahg_view_hostnames + + - <<: *view_action_host_group_hidden + name: View action host groups of component + parametrized_by: + - component + apps: + - label: cm + models: + - name: servicecomponent + codenames: *ahg_view_hostnames + + - name: View any object action host groups + type: hidden + any_category: false + module_name: rbac.roles + class_name: ModelRole + apps: + - label: cm + models: + - name: cluster + codenames: *ahg_view_hostnames + - name: clusterobject + codenames: *ahg_view_hostnames + - name: servicecomponent + codenames: *ahg_view_hostnames + + - &edit_action_host_group_hidden + <<: *view_action_host_group_hidden + module_name: rbac.roles + name: Edit action host groups of cluster + parametrized_by: + - cluster + apps: + - label: cm + models: + - name: cluster + codenames: &ahg_edit_hostnames + - view + - view_action_host_group + - edit_action_host_group + + - <<: *edit_action_host_group_hidden + name: Edit action host groups of service + parametrized_by: + - service + apps: + - label: cm + models: + - name: clusterobject + codenames: *ahg_edit_hostnames + + - <<: *edit_action_host_group_hidden + name: Edit action host groups of component + parametrized_by: + - component + apps: + - label: cm + models: + - name: servicecomponent + codenames: *ahg_edit_hostnames + + - &action_host_group_business + name: View action host groups + type: business + parametrized_by: + - cluster + - service + module_name: rbac.roles + class_name: ParentRole + child: + - View action host groups of cluster + - View action host groups of service + - View action host groups of component + + - <<: *action_host_group_business + name: Manage action host groups + child: + - Edit action host groups of cluster + - Edit action host groups of service + - Edit action host groups of component From c1b1d277806beef82b898f6ab5431aacece27f81 Mon Sep 17 00:00:00 2001 From: Egor Araslanov Date: Fri, 14 Jun 2024 12:41:16 +0500 Subject: [PATCH 174/208] ADCM-5665 Audit for Action Host Group --- python/api_v2/action_host_group/views.py | 28 ++- python/api_v2/tests/base.py | 25 ++- python/api_v2/tests/test_action_host_group.py | 166 ++++++++++++++++-- python/audit/cases/action_host_groups.py | 66 +++++++ python/audit/cases/cases.py | 3 + python/audit/cases/common.py | 53 ++++-- python/audit/cases/config.py | 38 +--- .../migrations/0007_action_host_group.py | 2 +- python/audit/models.py | 2 +- python/audit/utils.py | 6 + python/cm/models.py | 1 + .../cm/services/job/run/_task_finalizers.py | 19 +- 12 files changed, 335 insertions(+), 74 deletions(-) create mode 100644 python/audit/cases/action_host_groups.py diff --git a/python/api_v2/action_host_group/views.py b/python/api_v2/action_host_group/views.py index 1ccb2af601..635f104fe2 100644 --- a/python/api_v2/action_host_group/views.py +++ b/python/api_v2/action_host_group/views.py @@ -10,6 +10,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from contextlib import contextmanager from typing import NamedTuple from adcm.permissions import ( @@ -168,6 +169,7 @@ def retrieve(self, request: Request, parent: CoreObjectDescriptor, pk: str, **__ return Response(data=self.get_serializer(instance=instance).data) + @audit @with_parent_object def destroy(self, request: Request, parent: CoreObjectDescriptor, pk: str, **__) -> Response: check_has_group_permissions(user=request.user, parent=parent, dto=REQUIRE_EDIT_NOT_FOUND) @@ -214,13 +216,23 @@ class HostActionHostGroupViewSet(CamelCaseGenericViewSet): serializer_class = AddHostSerializer action_host_group_service = ActionHostGroupService(repository=ActionHostGroupRepo()) - def handle_exception(self, exc: Exception) -> None: - if isinstance(exc, HostError): - exc = AdcmEx(code="HOST_GROUP_CONFLICT", msg=exc.message) - elif isinstance(exc, GroupIsLockedError): - exc = AdcmEx(code="TASK_ERROR", msg=exc.message) + @contextmanager + def convert_exception(self) -> None: + """ + Customization of `handle_exception` leads to "problems" with audit: + either audit should catch all exception itself + or "correct" exceptions should be raised inside of function wrapped by audit decorator. + + The latter is the solution for now to avoid more customization for audit + without rethinking its place and usage. + """ - return super().handle_exception(exc) + try: + yield + except HostError as err: + raise AdcmEx(code="HOST_GROUP_CONFLICT", msg=err.message) from None + except GroupIsLockedError as err: + raise AdcmEx(code="TASK_ERROR", msg=err.message) from None @audit @with_group_object @@ -234,7 +246,7 @@ def create( host_id = serializer.validated_data["host_id"] - with atomic(): + with self.convert_exception(), atomic(): self.action_host_group_service.add_hosts_to_group(group_id=host_group.id, hosts=[host_id]) return Response( @@ -252,7 +264,7 @@ def destroy( if not ActionHostGroup.hosts.through.objects.filter(actionhostgroup_id=host_group.id, host_id=pk).exists(): raise NotFound() - with atomic(): + with self.convert_exception(), atomic(): self.action_host_group_service.remove_hosts_from_group(group_id=host_group.id, hosts=[int(pk)]) return Response(status=HTTP_204_NO_CONTENT) diff --git a/python/api_v2/tests/base.py b/python/api_v2/tests/base.py index cce2931820..b5be79b3bd 100644 --- a/python/api_v2/tests/base.py +++ b/python/api_v2/tests/base.py @@ -18,9 +18,10 @@ from adcm.tests.base import BusinessLogicMixin, ParallelReadyTestCase from adcm.tests.client import ADCMTestClient -from audit.models import AuditLog, AuditSession +from audit.models import AuditLog, AuditObjectType, AuditSession from cm.models import ( ADCM, + ActionHostGroup, Bundle, Cluster, ClusterObject, @@ -37,7 +38,17 @@ from rest_framework.test import APITestCase AuditTarget: TypeAlias = ( - Bundle | Cluster | ClusterObject | ServiceComponent | HostProvider | Host | User | Group | Role | Policy + Bundle + | Cluster + | ClusterObject + | ServiceComponent + | ActionHostGroup + | HostProvider + | Host + | User + | Group + | Role + | Policy ) @@ -124,8 +135,8 @@ def get_most_recent_audit_log() -> AuditLog | None: """Mostly for debug purposes""" return AuditLog.objects.order_by("pk").last() - @staticmethod def prepare_audit_object_arguments( + self, expected_object: AuditTarget | None, *, is_deleted: bool = False, @@ -133,7 +144,13 @@ def prepare_audit_object_arguments( if expected_object is None: return {"audit_object__isnull": True} - if isinstance(expected_object, ServiceComponent): + if isinstance(expected_object, ActionHostGroup): + owner_name = self.prepare_audit_object_arguments(expected_object=expected_object.object)[ + "audit_object__object_name" + ] + name = f"{owner_name}/{expected_object.name}" + type_ = AuditObjectType.ACTION_HOST_GROUP + elif isinstance(expected_object, ServiceComponent): name = ( f"{expected_object.cluster.name}/{expected_object.service.display_name}/{expected_object.display_name}" ) diff --git a/python/api_v2/tests/test_action_host_group.py b/python/api_v2/tests/test_action_host_group.py index e575e10999..5966c645cb 100644 --- a/python/api_v2/tests/test_action_host_group.py +++ b/python/api_v2/tests/test_action_host_group.py @@ -11,6 +11,7 @@ # limitations under the License. from contextlib import contextmanager, nullcontext +from functools import partial from itertools import chain from operator import itemgetter @@ -82,7 +83,7 @@ def test_create_group_success(self) -> None: response = self.client.v2[target, ACTION_HOST_GROUPS].post(data=data) - with self.subTest(f"[{type_.name}] CREATED SUCCESS"): + with self.subTest(f"[{type_.name}] CREATE SUCCESS"): self.assertEqual(response.status_code, HTTP_201_CREATED) self.assertEqual(ActionHostGroup.objects.count(), group_counter) created_group = ActionHostGroup.objects.filter( @@ -91,9 +92,13 @@ def test_create_group_success(self) -> None: self.assertIsNotNone(created_group) self.assertEqual(response.json(), {"id": created_group.id, **data, "hosts": []}) - # todo implement - # with self.subTest(f"[{type_.name}] AUDITED SUCCESS"): - # self.assertEqual(response.status_code, HTTP_201_CREATED) + with self.subTest(f"[{type_.name}] CREATE AUDITED"): + self.check_last_audit_record( + operation_name=f"{data['name']} action host group created", + operation_type="create", + operation_result="success", + **self.prepare_audit_object_arguments(expected_object=target), + ) def test_create_multiple_groups(self) -> None: with self.subTest("[COMPONENT] Same Object + Different Names SUCCESS"): @@ -156,7 +161,9 @@ def test_delete_success(self) -> None: (self.service, service_group_1, 1), (self.component, component_group, 0), ): - with self.subTest(orm_object_to_core_type(target).name): + type_name = orm_object_to_core_type(target).name + + with self.subTest(f"[{type_name}] DELETE SUCCESS"): response = self.client.v2[group_to_delete].delete() self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) @@ -165,7 +172,13 @@ def test_delete_success(self) -> None: groups_left_amount, ) - # todo add audit + with self.subTest(f"[{type_name}] DELETE AUDITED"): + self.check_last_audit_record( + operation_name=f"{group_to_delete.name} action host group deleted", + operation_type="delete", + operation_result="success", + **self.prepare_audit_object_arguments(expected_object=target), + ) def test_retrieve_success(self) -> None: name = "aWeSOME Group NAmE" @@ -340,9 +353,13 @@ def test_add_host_to_group(self) -> None: self.assertEqual(len(hosts_in_group), 1) self.assertEqual(hosts_in_group[0].id, host_1.id) - # todo add audit check for all success/fail cases - # with self.subTest(f"Add Audit {type_.name} SUCCESS"): - # ... + with self.subTest(f"[{type_.name}] Add Host Audit {type_.name} SUCCESS"): + self.check_last_audit_record( + operation_name=f"Host {host_1.fqdn} added to action host group {group.name}", + operation_type="update", + operation_result="success", + **self.prepare_audit_object_arguments(expected_object=group), + ) with self.subTest(f"[{type_.name}] Add Host Duplicate FAIL"): response = self.client.v2[group, "hosts"].post(data={"hostId": host_1.id}) @@ -350,6 +367,14 @@ def test_add_host_to_group(self) -> None: self.assertEqual(len(self.action_host_group_service.retrieve(group.id).hosts), 1) self.assertIn("hosts are already in action group", response.json()["desc"]) + with self.subTest(f"[{type_.name}] Add Host Duplicate Audit FAIL"): + self.check_last_audit_record( + operation_name=f"Host added to action host group {group.name}", + operation_type="update", + operation_result="fail", + **self.prepare_audit_object_arguments(expected_object=group), + ) + with self.subTest("[SERVICE] Add Second Host SUCCESS"): group = self.group_map[self.service] response = self.client.v2[group, "hosts"].post(data={"hostId": host_2.id}) @@ -373,12 +398,21 @@ def test_add_host_to_group(self) -> None: self.assertEqual(len(self.action_host_group_service.retrieve(group.id).hosts), expected_host_count) with self.subTest("[SERVICE] Add Non Existing Host FAIL"): + group = self.group_map[self.service] response = self.client.v2[self.group_map[self.service], "hosts"].post( data={"hostId": self.get_non_existent_pk(Host)} ) self.assertEqual(response.status_code, HTTP_409_CONFLICT) + with self.subTest("[SERVICE] Add Non Existing Host Audit FAIL"): + self.check_last_audit_record( + operation_name=f"Host added to action host group {group.name}", + operation_type="update", + operation_result="fail", + **self.prepare_audit_object_arguments(expected_object=group), + ) + def test_remove_host_from_group(self) -> None: host_1, host_2, *_ = self.hosts @@ -397,12 +431,28 @@ def test_remove_host_from_group(self) -> None: self.assertEqual(len(hosts_in_group), 1) self.assertEqual(hosts_in_group[0].id, host_2.id) + with self.subTest(f"[{type_.name}] Remove Host Audit SUCCESS"): + self.check_last_audit_record( + operation_name=f"Host {host_1.fqdn} removed from action host group {group.name}", + operation_type="update", + operation_result="success", + **self.prepare_audit_object_arguments(expected_object=group), + ) + with self.subTest(f"[{type_.name}] Remove Removed Host FAIL"): response = endpoint.delete() self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) self.assertEqual(len(self.action_host_group_service.retrieve(group.id).hosts), 1) + with self.subTest(f"[{type_.name}] Remove Removed Host Audit FAIL"): + self.check_last_audit_record( + operation_name=f"Host {host_1.fqdn} removed from action host group {group.name}", + operation_type="update", + operation_result="fail", + **self.prepare_audit_object_arguments(expected_object=group), + ) + with self.subTest(f"[{type_.name}] Remove Last Host SUCCESS"): response = self.client.v2[group, "hosts", host_2].delete() @@ -410,8 +460,6 @@ def test_remove_host_from_group(self) -> None: hosts_in_group = self.action_host_group_service.retrieve(group.id).hosts self.assertEqual(len(hosts_in_group), 0) - # todo add audit - def test_same_hosts_in_group_of_one_object_success(self) -> None: host_1, host_2, *_ = self.hosts @@ -502,7 +550,6 @@ def test_run(self) -> None: ) self.set_hostcomponent(cluster=self.cluster, entries=[(host_1, self.component), (host_2, self.component)]) - # todo add audit cases for target, action_name in ( (self.cluster, "allowed_in_group_1"), (self.service, "allowed_from_service"), @@ -528,24 +575,56 @@ def test_run(self) -> None: self.assertIsNotNone(run_task.target_task) self.assertEqual(run_task.target_task.task_object, action_run_target) + with self.subTest(f"[{message_name}] Run Audit SUCCESS"): + self.check_last_audit_record( + operation_name=f"{action.display_name} action launched", + operation_type="update", + operation_result="success", + **self.prepare_audit_object_arguments(expected_object=action_run_target), + ) + with self.subTest(f"[{message_name}] Running Task Add Hosts FAIL"): response = self.client.v2[group, "hosts"].post(data={"hostId": host_2.id}) self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertIn(f"Can't add hosts to {expected_lock_error_message}", response.json()["desc"]) + with self.subTest(f"[{message_name}] Running Task Add Hosts Audit FAIL"): + self.check_last_audit_record( + operation_name=f"Host added to action host group {group.name}", + operation_type="update", + operation_result="fail", + **self.prepare_audit_object_arguments(expected_object=group), + ) + with self.subTest(f"[{message_name}] Running Task Remove Hosts FAIL"): response = self.client.v2[group, "hosts", host_1].delete() self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertIn(f"Can't remove hosts from {expected_lock_error_message}", response.json()["desc"]) + with self.subTest(f"[{message_name}] Running Task Remove Hosts Audit FAIL"): + self.check_last_audit_record( + operation_name=f"Host {host_1.fqdn} removed from action host group {group.name}", + operation_type="update", + operation_result="fail", + **self.prepare_audit_object_arguments(expected_object=group), + ) + with self.subTest(f"[{message_name}] Running Task Delete Group FAIL"): response = self.client.v2[group].delete() self.assertEqual(response.status_code, HTTP_409_CONFLICT) self.assertIn(f"Can't delete {expected_lock_error_message}", response.json()["desc"]) + with self.subTest(f"[{message_name}] Running Task Delete Group Audit FAIL"): + self.check_last_audit_record( + operation_name=f"{group.name} action host group deleted", + operation_type="delete", + operation_result="fail", + **self.prepare_audit_object_arguments(expected_object=target), + ) + with self.subTest(f"[{message_name}] Running Task Create New Group SUCCESS"): response = self.client.v2[target, ACTION_HOST_GROUPS].post( data={"name": f"New Best Group Ever {message_name}", "description": "That's it"} @@ -553,6 +632,17 @@ def test_run(self) -> None: self.assertEqual(response.status_code, HTTP_201_CREATED) + with self.subTest(f"[{message_name}] Finish Task Audit SUCCESS"): + run_task.run() + + self.check_last_audit_record( + operation_name=f"{action.display_name} action completed", + operation_type="update", + operation_result="success", + user__username=None, + **self.prepare_audit_object_arguments(expected_object=action_run_target), + ) + class TestActionHostGroupRBAC(CommonActionHostGroupTest): def setUp(self) -> None: @@ -845,3 +935,55 @@ def test_built_in_roles(self) -> None: self.assertEqual( sorted(entry["name"] for entry in response.json()), ["allowed_from_component", "allowed_in_group_1"] ) + + def test_audit_with_rbac(self) -> None: + expect_denied = partial( + self.check_last_audit_record, operation_result="denied", user__username=self.user.username + ) + + grant_view_role = self.grant_permissions(to=self.user, on=self.cluster, role_name="View action host groups") + + for subtest_name, context, expected_code in ( + ("View Only Permissions - Denied", grant_view_role, HTTP_403_FORBIDDEN), + ("No Permissions - Denied", nullcontext(), HTTP_404_NOT_FOUND), + ): + with context, self.subTest(subtest_name): + response = self.user_client.v2[self.cluster, ACTION_HOST_GROUPS].post() + self.assertEqual(response.status_code, expected_code) + + expect_denied( + operation_name="action host group created", + operation_type="create", + **self.prepare_audit_object_arguments(expected_object=self.cluster), + ) + + group = self.group_map[self.service] + response = self.user_client.v2[group, "hosts"].post() + self.assertEqual(response.status_code, expected_code) + + expect_denied( + operation_name=f"Host added to action host group {group.name}", + operation_type="update", + **self.prepare_audit_object_arguments(expected_object=group), + ) + + group = self.group_map[self.component] + response = self.user_client.v2[group, "hosts", self.host_2].delete() + self.assertEqual(response.status_code, expected_code) + + expect_denied( + operation_name=f"Host {self.host_2.fqdn} removed from action host group {group.name}", + operation_type="update", + **self.prepare_audit_object_arguments(expected_object=group), + ) + + action = Action.objects.get(prototype=self.service.prototype, name="allowed_in_group_1") + group = self.group_map[self.service] + response = self.user_client.v2[group, "actions", action, "run"].post() + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + + expect_denied( + operation_name=f"{action.display_name} action launched", + operation_type="update", + **self.prepare_audit_object_arguments(expected_object=group), + ) diff --git a/python/audit/cases/action_host_groups.py b/python/audit/cases/action_host_groups.py new file mode 100644 index 0000000000..0ec1680241 --- /dev/null +++ b/python/audit/cases/action_host_groups.py @@ -0,0 +1,66 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from cm.models import ActionHostGroup, Host +from rest_framework.response import Response + +from audit.cases.common import get_audit_cm_object_from_path_info +from audit.models import AuditLogOperationType, AuditOperation + + +def action_host_group_case(path: list[str], response: Response | None, deleted_obj: ActionHostGroup | None): + audit_operation = None + audit_object = None + + match path: + case [*_, owner_type, owner_pk, "action-host-groups"]: + audit_operation = AuditOperation( + name=f"{'' if not response else response.data.get('name', '')} action host group created".strip(), + operation_type=AuditLogOperationType.CREATE, + ) + audit_object = get_audit_cm_object_from_path_info( + object_type_from_path=owner_type, object_pk_from_path=owner_pk + ) + case [*_, owner_type, owner_pk, "action-host-groups", _group_pk]: + audit_operation = AuditOperation( + name=f"{'' if not deleted_obj else deleted_obj.name} action host group deleted".strip(), + operation_type=AuditLogOperationType.DELETE, + ) + audit_object = get_audit_cm_object_from_path_info( + object_type_from_path=owner_type, object_pk_from_path=owner_pk + ) + case [*_, _owner_type, _owner_pk, "action-host-groups", group_pk, "hosts"]: + group_name = ActionHostGroup.objects.values_list("name", flat=True).filter(pk=group_pk).first() or "" + host_info = "Host" + if response: + host_info = f"Host {response.data.get('name', '')}".strip() + + audit_operation = AuditOperation( + name=f"{host_info} added to action host group {group_name}".strip(), + operation_type=AuditLogOperationType.UPDATE, + ) + audit_object = get_audit_cm_object_from_path_info( + object_type_from_path="action-host-groups", object_pk_from_path=group_pk + ) + case [*_, _owner_type, _owner_pk, "action-host-groups", group_pk, "hosts", host_pk]: + group_name = ActionHostGroup.objects.values_list("name", flat=True).filter(pk=group_pk).first() or "" + host_info = f"Host {Host.objects.values_list('fqdn', flat=True).filter(pk=host_pk).first() or ''}" + + audit_operation = AuditOperation( + name=f"{host_info} removed from action host group {group_name}".strip(), + operation_type=AuditLogOperationType.UPDATE, + ) + audit_object = get_audit_cm_object_from_path_info( + object_type_from_path="action-host-groups", object_pk_from_path=group_pk + ) + + return audit_operation, audit_object diff --git a/python/audit/cases/cases.py b/python/audit/cases/cases.py index fc6c90b69e..523488c4ac 100644 --- a/python/audit/cases/cases.py +++ b/python/audit/cases/cases.py @@ -15,6 +15,7 @@ from rest_framework.generics import GenericAPIView from rest_framework.response import Response +from audit.cases.action_host_groups import action_host_group_case from audit.cases.adcm import adcm_case from audit.cases.bundle import bundle_case from audit.cases.cluster import cluster_case @@ -43,6 +44,8 @@ def get_audit_operation_and_object( audit_operation, audit_object = action_case(path=path, api_version=api_version) elif "upgrade" in path or "upgrades" in path: audit_operation, audit_object = upgrade_case(path=path) + elif "action-host-groups" in path: + audit_operation, audit_object = action_host_group_case(path=path, response=response, deleted_obj=deleted_obj) elif "stack" in path: audit_operation, audit_object = stack_case( path=path, diff --git a/python/audit/cases/common.py b/python/audit/cases/common.py index c58b002513..5fdf45e7a9 100644 --- a/python/audit/cases/common.py +++ b/python/audit/cases/common.py @@ -13,12 +13,14 @@ from cm.models import ( ADCM, Action, + ActionHostGroup, ADCMEntity, ClusterObject, JobLog, ServiceComponent, TaskLog, Upgrade, + get_cm_model_by_type, ) from rest_framework.response import Response @@ -108,7 +110,7 @@ def _job_case(job_pk: str, version=1) -> tuple[AuditOperation, AuditObject | Non return audit_operation, audit_object -def get_obj_name(obj: ClusterObject | ServiceComponent | ADCMEntity, obj_type: str) -> str: +def get_obj_name(obj: ClusterObject | ServiceComponent | ADCMEntity | ActionHostGroup, obj_type: str) -> str: if obj_type == "service": obj_name = obj.display_name cluster = obj.cluster @@ -122,6 +124,13 @@ def get_obj_name(obj: ClusterObject | ServiceComponent | ADCMEntity, obj_type: s cluster = service.cluster if cluster: obj_name = f"{cluster.name}/{obj_name}" + elif obj_type == AuditObjectType.ACTION_HOST_GROUP: + obj_name = obj.name + + parent = obj.object + parent_name = get_obj_name(parent, obj_type=MODEL_TO_AUDIT_OBJECT_TYPE_MAP[parent.__class__]) + if parent_name: + obj_name = f"{parent_name}/{obj_name}" else: obj_name = obj.name @@ -219,22 +228,13 @@ def action_case(path: list[str], api_version: int) -> tuple[AuditOperation, Audi audit_object = None match path: - case ( - [obj_type, obj_pk, "action" | "actions", action_pk, "run"] - | [_, _, obj_type, obj_pk, "action" | "actions", action_pk, "run"] - | [_, _, _, _, obj_type, obj_pk, "action" | "actions", action_pk, "run"] - ): + case [*_, obj_type, obj_pk, "action" | "actions", action_pk, "run"]: + action_display_name = Action.objects.values_list("display_name", flat=True).filter(pk=action_pk).first() audit_operation = AuditOperation( - name="{action_display_name} action launched", + name=f"{action_display_name or ''} action launched".strip(), operation_type=AuditLogOperationType.UPDATE, ) - action = Action.objects.filter(pk=action_pk).first() - if action: - audit_operation.name = audit_operation.name.format(action_display_name=action.display_name) - elif api_version != 1: - audit_operation.name = "action launched" - obj = PATH_STR_TO_OBJ_CLASS_MAP[obj_type].objects.filter(pk=obj_pk).first() if obj: object_type = MODEL_TO_AUDIT_OBJECT_TYPE_MAP[PATH_STR_TO_OBJ_CLASS_MAP[obj_type]] @@ -311,3 +311,30 @@ def task_job_case(path: list[str], version=1) -> tuple[AuditOperation, AuditObje audit_operation, audit_object = _job_case(job_pk=job_pk, version=version) return audit_operation, audit_object + + +def get_audit_cm_object_from_path_info(object_type_from_path: str, object_pk_from_path: str) -> AuditObject | None: + try: + model = get_cm_model_by_type(object_type=object_type_from_path) + except KeyError: + return None + + try: + object_ = model.objects.filter(pk=int(object_pk_from_path)).first() + except ValueError: + return None + + if not object_: + return None + + if object_type_from_path.startswith("hostprovider"): + single_form_of_type = "provider" + else: + # to convert clusters -> cluster, etc. + single_form_of_type = object_type_from_path.rstrip("s") + + return get_or_create_audit_obj( + object_id=object_.pk, + object_name=get_obj_name(obj=object_, obj_type=single_form_of_type), + object_type=single_form_of_type, + ) diff --git a/python/audit/cases/config.py b/python/audit/cases/config.py index 6ecb99689c..32a9fca16b 100644 --- a/python/audit/cases/config.py +++ b/python/audit/cases/config.py @@ -12,13 +12,13 @@ from contextlib import suppress -from cm.models import GroupConfig, Host, ObjectConfig, get_cm_model_by_type +from cm.models import GroupConfig, Host, ObjectConfig from cm.utils import get_obj_type from django.contrib.contenttypes.models import ContentType from rest_framework.response import Response from rest_framework.viewsets import ViewSet -from audit.cases.common import get_obj_name, get_or_create_audit_obj +from audit.cases.common import get_audit_cm_object_from_path_info, get_obj_name, get_or_create_audit_obj from audit.models import ( MODEL_TO_AUDIT_OBJECT_TYPE_MAP, PATH_STR_TO_OBJ_CLASS_MAP, @@ -103,7 +103,7 @@ def config_case( audit_object = None case [*_, owner_type, owner_pk, "config-groups"]: - audit_object = get_audit_object_from_path_info( + audit_object = get_audit_cm_object_from_path_info( object_type_from_path=owner_type, object_pk_from_path=owner_pk ) audit_operation = AuditOperation( @@ -116,7 +116,7 @@ def config_case( audit_operation.name = f"{name} configuration group created" case [*_, owner_type, owner_pk, "config-groups", group_config_pk]: - audit_object = get_audit_object_from_path_info( + audit_object = get_audit_cm_object_from_path_info( object_type_from_path=owner_type, object_pk_from_path=owner_pk ) if deleted_obj: @@ -229,7 +229,7 @@ def config_case( name=f"host added to {name_suffix}", operation_type=AuditLogOperationType.UPDATE, ) - audit_object = get_audit_object_from_path_info( + audit_object = get_audit_cm_object_from_path_info( object_type_from_path=owner_type, object_pk_from_path=owner_pk ) @@ -245,7 +245,7 @@ def config_case( name=f"host removed from {name_suffix}", operation_type=AuditLogOperationType.UPDATE, ) - audit_object = get_audit_object_from_path_info( + audit_object = get_audit_cm_object_from_path_info( object_type_from_path=owner_type, object_pk_from_path=owner_pk ) @@ -286,29 +286,3 @@ def config_case( ) return audit_operation, audit_object, operation_name - - -def get_audit_object_from_path_info(object_type_from_path: str, object_pk_from_path: str) -> AuditObject | None: - try: - model = get_cm_model_by_type(object_type=object_type_from_path) - except KeyError: - return None - - try: - object_ = model.objects.filter(pk=int(object_pk_from_path)).first() - except ValueError: - return None - - if not object_: - return None - - if object_type_from_path.startswith("hostprovider"): - single_form_of_type = "provider" - else: - # to convert clusters -> cluster, etc. - single_form_of_type = object_type_from_path.rstrip("s") - return get_or_create_audit_obj( - object_id=object_.pk, - object_name=get_obj_name(obj=object_, obj_type=single_form_of_type), - object_type=single_form_of_type, - ) diff --git a/python/audit/migrations/0007_action_host_group.py b/python/audit/migrations/0007_action_host_group.py index 64b870efc7..18f3bd5f70 100644 --- a/python/audit/migrations/0007_action_host_group.py +++ b/python/audit/migrations/0007_action_host_group.py @@ -38,7 +38,7 @@ class Migration(migrations.Migration): ("group", "group"), ("role", "role"), ("policy", "policy"), - ("actionhostgroup", "actionhostgroup"), + ("action-host-group", "action-host-group"), ], max_length=2000, ), diff --git a/python/audit/models.py b/python/audit/models.py index f3aa4d422d..887b9b4bd5 100644 --- a/python/audit/models.py +++ b/python/audit/models.py @@ -52,7 +52,7 @@ class AuditObjectType(TextChoices): GROUP = "group", "group" ROLE = "role", "role" POLICY = "policy", "policy" - ACTION_HOST_GROUP = "actionhostgroup", "actionhostgroup" + ACTION_HOST_GROUP = "action-host-group", "action-host-group" class AuditLogOperationType(TextChoices): diff --git a/python/audit/utils.py b/python/audit/utils.py index 60a349cada..4de73bfcc8 100644 --- a/python/audit/utils.py +++ b/python/audit/utils.py @@ -39,6 +39,7 @@ from cm.errors import AdcmEx from cm.models import ( Action, + ActionHostGroup, Cluster, ClusterBind, ClusterObject, @@ -439,6 +440,10 @@ def _all_child_objects_exist(path: list[str]) -> bool: match path: case ["configs", pk]: return _cm_object_exists(path_type="configs", pk=pk) + case ["action-host-groups", group_pk, "hosts", host_pk, *_]: + return ActionHostGroup.hosts.through.objects.filter(actionhostgroup_id=group_pk, host_id=host_pk).exists() + case ["action-host-groups", group_pk, *_]: + return ActionHostGroup.objects.filter(pk=group_pk).exists() case ["services" | "components" | "hosts" | "config-groups" | "actions" | "upgrades", pk, *rest]: if not _cm_object_exists(path_type=path[0], pk=pk): return False @@ -454,6 +459,7 @@ def _all_objects_in_path_exist(path: list[str]) -> bool: model = get_rbac_model_by_type(rbac_type) return model.objects.filter(pk=pk).exists() return False + case ["clusters" | "hostproviders" | "hosts" | "bundles" | "prototypes", pk, *rest]: if not _cm_object_exists(path_type=path[0], pk=pk): return False diff --git a/python/cm/models.py b/python/cm/models.py index 3415399cec..492ff98add 100644 --- a/python/cm/models.py +++ b/python/cm/models.py @@ -1670,6 +1670,7 @@ class ADCMEntityStatus(models.TextChoices): "prototypes": Prototype, "bundle": Bundle, "bundles": Bundle, + "action-host-groups": ActionHostGroup, } diff --git a/python/cm/services/job/run/_task_finalizers.py b/python/cm/services/job/run/_task_finalizers.py index 2b79d09052..e927d2545a 100644 --- a/python/cm/services/job/run/_task_finalizers.py +++ b/python/cm/services/job/run/_task_finalizers.py @@ -21,7 +21,16 @@ from cm.api import save_hc from cm.converters import core_type_to_model from cm.issue import unlock_affected_objects, update_hierarchy_issues -from cm.models import ClusterObject, Host, JobLog, MaintenanceMode, ServiceComponent, TaskLog, get_object_cluster +from cm.models import ( + ActionHostGroup, + ClusterObject, + Host, + JobLog, + MaintenanceMode, + ServiceComponent, + TaskLog, + get_object_cluster, +) from cm.services.concern.messages import ConcernMessage, PlaceholderObjectsDTO, build_concern_reason from cm.status_api import send_object_update_event @@ -37,10 +46,14 @@ class WithIDAndCoreType(Protocol): def set_job_lock(job_id: int) -> None: job = JobLog.objects.select_related("task").get(pk=job_id) - if job.task.lock and job.task.task_object: + object_ = job.task.task_object + if isinstance(object_, ActionHostGroup): + object_ = object_.object + + if job.task.lock and object_: job.task.lock.reason = build_concern_reason( ConcernMessage.LOCKED_BY_JOB.template, - placeholder_objects=PlaceholderObjectsDTO(job=job, target=job.task.task_object), + placeholder_objects=PlaceholderObjectsDTO(job=job, target=object_), ) job.task.lock.save(update_fields=["reason"]) From ce5af3e6a7276f4a09638499c95888e1cf84540f Mon Sep 17 00:00:00 2001 From: Dmitry Bezrukov Date: Mon, 17 Jun 2024 07:10:27 +0000 Subject: [PATCH 175/208] ADCM-5550: make admin not built-in need for removing admin user as it is not a user required for the operation of ADCM --- python/init_db.py | 6 +++- .../migrations/0015_make_admin_not_builtin.py | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 python/rbac/migrations/0015_make_admin_not_builtin.py diff --git a/python/init_db.py b/python/init_db.py index 253277ff80..6764cd4c05 100755 --- a/python/init_db.py +++ b/python/init_db.py @@ -104,13 +104,17 @@ def abort_all(): def init(adcm_conf_file: Path = Path(settings.BASE_DIR, "conf", "adcm", "config.yaml")): logger.info("Start initializing ADCM DB...") + if not User.objects.filter(username="admin").exists(): - User.objects.create_superuser("admin", "admin@example.com", "admin", built_in=True) + User.objects.create_superuser("admin", "admin@example.com", "admin", built_in=False) + status_user_username, status_user_password = create_status_user() prepare_secrets_json(status_user_username, status_user_password) + if not User.objects.filter(username="system").exists(): User.objects.create_superuser("system", "", None, built_in=True) logger.info("Create system user") + abort_all() clear_temp_tables() load_adcm(adcm_conf_file) diff --git a/python/rbac/migrations/0015_make_admin_not_builtin.py b/python/rbac/migrations/0015_make_admin_not_builtin.py new file mode 100644 index 0000000000..f58a0c6897 --- /dev/null +++ b/python/rbac/migrations/0015_make_admin_not_builtin.py @@ -0,0 +1,30 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Generated by Django 3.2.19 on 2024-05-14 09:57 + +from django.db import migrations + + +def update_admin_user(apps, schema_editor): + User = apps.get_model("rbac", "User") + User.objects.filter(username="admin").update(built_in=False) + + +class Migration(migrations.Migration): + dependencies = [ + ("rbac", "0014_alter_group_description"), + ] + + operations = [ + migrations.RunPython(update_admin_user), + ] From 19e1fb46736dbb540b23731bb51946f36b3fd437 Mon Sep 17 00:00:00 2001 From: Igor Kuzmin Date: Mon, 17 Jun 2024 07:29:59 +0000 Subject: [PATCH 176/208] feature/ADCM-5668 custom pattern keyword definition Task: https://tracker.yandex.ru/ADCM-5668 --- .../DynamicActionDialog.utils.ts | 2 +- .../ConfigurationMain/ConfigurationMain.tsx | 2 +- .../ConfigurationEditor.stories.constants.ts | 1 + .../ConfigurationEditor.stories.tsx | 2 +- .../ConfigurationEditor.utils.ts | 2 +- .../ConfigurationTree.utils.ts | 2 +- .../FieldNodeErrors/FieldNodeErrors.tsx | 2 +- .../StringControls/StringControls.utils.ts | 12 ++-- .../{ => jsonSchema}/jsonSchemaUtils.test.ts | 69 +++++++++++++++++++ .../utils/{ => jsonSchema}/jsonSchemaUtils.ts | 4 ++ .../src/utils/jsonSchema/patternKeyword.ts | 43 ++++++++++++ 11 files changed, 131 insertions(+), 10 deletions(-) rename adcm-web/app/src/utils/{ => jsonSchema}/jsonSchemaUtils.test.ts (77%) rename adcm-web/app/src/utils/{ => jsonSchema}/jsonSchemaUtils.ts (89%) create mode 100644 adcm-web/app/src/utils/jsonSchema/patternKeyword.ts diff --git a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionDialog.utils.ts b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionDialog.utils.ts index 5ac9683cfd..2d0be96246 100644 --- a/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionDialog.utils.ts +++ b/adcm-web/app/src/components/common/DynamicActionDialog/DynamicActionDialog.utils.ts @@ -1,7 +1,7 @@ import { DynamicActionType } from '@commonComponents/DynamicActionDialog/DynamicAction.types'; import { AdcmDynamicActionDetails, AdcmDynamicActionRunConfig } from '@models/adcm/dynamicAction'; import { AdcmConfiguration, ConfigurationData } from '@models/adcm'; -import { generateFromSchema } from '@utils/jsonSchemaUtils'; +import { generateFromSchema } from '@utils/jsonSchema/jsonSchemaUtils'; export const getDynamicActionTypes = (actionDetails: AdcmDynamicActionDetails): DynamicActionType[] => { const res = [] as DynamicActionType[]; diff --git a/adcm-web/app/src/components/common/configuration/ConfigurationMain/ConfigurationMain.tsx b/adcm-web/app/src/components/common/configuration/ConfigurationMain/ConfigurationMain.tsx index 3b201aa832..24e51c553e 100644 --- a/adcm-web/app/src/components/common/configuration/ConfigurationMain/ConfigurationMain.tsx +++ b/adcm-web/app/src/components/common/configuration/ConfigurationMain/ConfigurationMain.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { ConfigurationEditor } from '@uikit'; import { AdcmConfiguration, ConfigurationData, ConfigurationAttributes } from '@models/adcm'; import { useConfigurationFormContext } from '../ConfigurationFormContext/ConfigurationFormContext.context'; -import { generateFromSchema } from '@utils/jsonSchemaUtils'; +import { generateFromSchema } from '@utils/jsonSchema/jsonSchemaUtils'; interface ConfigurationMainProps { configuration: AdcmConfiguration | null; diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.stories.constants.ts b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.stories.constants.ts index 68c780b466..879373c03e 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.stories.constants.ts +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.stories.constants.ts @@ -77,6 +77,7 @@ export const clusterConfigurationSchema: ConfigurationSchema = { type: 'string', default: 'default cluster name', readOnly: false, + pattern: '[a-', adcmMeta: { isAdvanced: false, isInvisible: false, diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.stories.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.stories.tsx index 67e706a5f8..3b6c548b04 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.stories.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.stories.tsx @@ -13,7 +13,7 @@ import { import { ConfigurationAttributes, ConfigurationData, ConfigurationSchema } from '@models/adcm'; import { ConfigurationTreeFilter } from './ConfigurationEditor.types'; import { Checkbox, Input, Switch } from '@uikit'; -import { generateFromSchema } from '@utils/jsonSchemaUtils'; +import { generateFromSchema } from '@utils/jsonSchema/jsonSchemaUtils'; type Story = StoryObj; export default { diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.utils.ts b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.utils.ts index 0ead650f8a..216d4eb46c 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.utils.ts +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationEditor.utils.ts @@ -1,7 +1,7 @@ import type { ConfigurationData, SchemaDefinition } from '@models/adcm'; import type { JSONObject, JSONPrimitive, JSONValue } from '@models/json'; import type { ConfigurationNodePath } from './ConfigurationEditor.types'; -import { generateFromSchema } from '@utils/jsonSchemaUtils'; +import { generateFromSchema } from '@utils/jsonSchema/jsonSchemaUtils'; import { isObject } from '@utils/objectUtils'; export const editField = (configuration: ConfigurationData, path: ConfigurationNodePath, value: JSONValue) => { diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.utils.ts b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.utils.ts index 8b7843d9a3..cb9eea95f8 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.utils.ts +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/ConfigurationTree.utils.ts @@ -18,7 +18,7 @@ import { ConfigurationArray, ConfigurationNodeView, } from '../ConfigurationEditor.types'; -import { validate as validateJsonSchema } from '@utils/jsonSchemaUtils'; +import { validate as validateJsonSchema } from '@utils/jsonSchema/jsonSchemaUtils'; import { primitiveFieldTypes, rootNodeKey, diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeErrors/FieldNodeErrors.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeErrors/FieldNodeErrors.tsx index 7b4240af2f..bb775a1e9e 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeErrors/FieldNodeErrors.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeErrors/FieldNodeErrors.tsx @@ -15,7 +15,7 @@ const FieldNodeErrors = ({ fieldErrors }: FieldNodeErrorsProps) => { return null; } - return {error}; + return {error}; })} {hasOneOfKeywordError && ( diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/StringControls.utils.ts b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/StringControls.utils.ts index de738086ee..45962debfc 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/StringControls.utils.ts +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/Dialogs/FieldControls/StringControls/StringControls.utils.ts @@ -1,12 +1,16 @@ import { SingleSchemaDefinition } from '@models/adcm'; -import { getPatternErrorMessage } from '@utils/jsonSchemaUtils'; +import { getPatternErrorMessage } from '@utils/jsonSchema/jsonSchemaUtils'; export const validate = (value: string, fieldSchema: SingleSchemaDefinition): string | undefined => { let error = undefined; if (fieldSchema.pattern) { - const re = new RegExp(fieldSchema.pattern); - if (!re.test(value)) { - error = getPatternErrorMessage(fieldSchema.pattern); + try { + const re = new RegExp(fieldSchema.pattern); + if (!re.test(value)) { + error = getPatternErrorMessage(fieldSchema.pattern); + } + } catch (e) { + return 'invalid pattern'; } } diff --git a/adcm-web/app/src/utils/jsonSchemaUtils.test.ts b/adcm-web/app/src/utils/jsonSchema/jsonSchemaUtils.test.ts similarity index 77% rename from adcm-web/app/src/utils/jsonSchemaUtils.test.ts rename to adcm-web/app/src/utils/jsonSchema/jsonSchemaUtils.test.ts index 493387b00c..14ff3ff483 100644 --- a/adcm-web/app/src/utils/jsonSchemaUtils.test.ts +++ b/adcm-web/app/src/utils/jsonSchema/jsonSchemaUtils.test.ts @@ -220,4 +220,73 @@ describe('generateFromSchema', () => { const result = generateFromSchema(schema); expect(result).toStrictEqual(true); }); + + test('validate unsafe_pattern', () => { + const schema: Schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + required: ['cluster_config'], + properties: { + cluster_config: { + type: 'object', + required: ['cluster_name', 'cluster_description'], + properties: { + cluster_name: { + title: 'cluster_name', + type: 'string', + readOnly: false, + pattern: '[a-', + }, + cluster_description: { + title: 'cluster_name', + type: 'string', + readOnly: false, + pattern: '[a-*', + }, + }, + }, + }, + }; + + const object = { + cluster_config: { + cluster_name: '1', + cluster_description: 'aaaaaaa', + }, + }; + + const errors3 = validate(schema, object); + expect(errors3).not.toBe(null); + }); + + test('validate pattern', () => { + const schema: Schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + required: ['cluster_config'], + properties: { + cluster_config: { + type: 'object', + required: ['cluster_name'], + properties: { + cluster_name: { + title: 'cluster_name', + type: 'string', + readOnly: false, + pattern: '[a-z]', + }, + }, + }, + }, + }; + + const object = { + cluster_config: { + cluster_name: '1', + }, + }; + + const errors3 = validate(schema, object); + expect(errors3).not.toBe(null); + }); }); diff --git a/adcm-web/app/src/utils/jsonSchemaUtils.ts b/adcm-web/app/src/utils/jsonSchema/jsonSchemaUtils.ts similarity index 89% rename from adcm-web/app/src/utils/jsonSchemaUtils.ts rename to adcm-web/app/src/utils/jsonSchema/jsonSchemaUtils.ts index f4b27e089c..8485b6b491 100644 --- a/adcm-web/app/src/utils/jsonSchemaUtils.ts +++ b/adcm-web/app/src/utils/jsonSchema/jsonSchemaUtils.ts @@ -1,13 +1,17 @@ /* eslint-disable spellcheck/spell-checker */ +import { safePattern } from './patternKeyword'; import Ajv2020, { Schema } from 'ajv/dist/2020'; const ajv = new Ajv2020({ strictSchema: true, allErrors: true, verbose: true, + unicodeRegExp: false, }); ajv.addVocabulary(['adcmMeta']); +ajv.removeKeyword('pattern'); +ajv.addKeyword(safePattern); const ajvWithDefaults = new Ajv2020({ strictSchema: false, diff --git a/adcm-web/app/src/utils/jsonSchema/patternKeyword.ts b/adcm-web/app/src/utils/jsonSchema/patternKeyword.ts new file mode 100644 index 0000000000..896138f391 --- /dev/null +++ b/adcm-web/app/src/utils/jsonSchema/patternKeyword.ts @@ -0,0 +1,43 @@ +/* eslint-disable spellcheck/spell-checker */ +/* + Custom pattern keyword definition. + Based on https://github.com/ajv-validator/ajv/blob/master/lib/vocabularies/validation/pattern.ts +*/ + +import { CodeKeywordDefinition, KeywordCxt, KeywordErrorDefinition, _ } from 'ajv/dist/2020'; +import { KeywordErrorCxt } from 'ajv/dist/types'; +import { usePattern as generatePattern } from 'ajv/dist/vocabularies/code'; + +const error: KeywordErrorDefinition = { + message: (ctx: KeywordErrorCxt) => { + const isPatternValid = (ctx as SafePatternKeywordCtx).isPatternValid; + return isPatternValid ? `must match pattern ${ctx.schemaCode}` : `invalid pattern ${ctx.schemaCode}`; + }, + params: (ctx: KeywordErrorCxt) => { + return _`{pattern: ${ctx.schemaCode}}`; + }, +}; + +type SafePatternKeywordCtx = KeywordCxt & { + isPatternValid: boolean; +}; + +export const safePattern: CodeKeywordDefinition = { + keyword: 'pattern', + type: 'string', + schemaType: 'string', + $data: true, + error, + code(cxt: KeywordCxt) { + const { data, $data, schema, schemaCode, it } = cxt; + const u = it.opts.unicodeRegExp ? 'u' : ''; + try { + const regExp = $data ? _`(new RegExp(${schemaCode}, ${u}))` : generatePattern(cxt, schema); + (cxt as SafePatternKeywordCtx).isPatternValid = true; + cxt.fail$data(_`!${regExp}.test(${data})`); + } catch (e) { + (cxt as SafePatternKeywordCtx).isPatternValid = false; + cxt.fail(); + } + }, +}; From 945bf173edc7bbedc71a737a8e30695853430dc0 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Mon, 17 Jun 2024 08:08:43 +0000 Subject: [PATCH 177/208] ADCM-5667 Prettify API specification --- .../api_v2/action_host_group/serializers.py | 2 + python/api_v2/action_host_group/views.py | 91 ++++++++++++++++++- python/api_v2/api_schema.py | 5 + 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/python/api_v2/action_host_group/serializers.py b/python/api_v2/action_host_group/serializers.py index 45c9028ea7..871cd137f3 100644 --- a/python/api_v2/action_host_group/serializers.py +++ b/python/api_v2/action_host_group/serializers.py @@ -14,6 +14,7 @@ from adcm.serializers import EmptySerializer from cm.models import ActionHostGroup +from drf_spectacular.utils import extend_schema_field from rest_framework.fields import CharField, IntegerField, SerializerMethodField from rest_framework.serializers import ModelSerializer @@ -38,6 +39,7 @@ class ActionHostGroupSerializer(ModelSerializer): description = CharField(max_length=255) hosts = SerializerMethodField() + @extend_schema_field(field=ShortHostSerializer(many=UnicodeTranslateError)) def get_hosts(self, group: ActionHostGroup) -> list: # NOTE: # Here we return "unpaginated" list of hosts, so if there will be lots of them, there may be problems with: diff --git a/python/api_v2/action_host_group/views.py b/python/api_v2/action_host_group/views.py index 635f104fe2..00868a0c6e 100644 --- a/python/api_v2/action_host_group/views.py +++ b/python/api_v2/action_host_group/views.py @@ -36,6 +36,7 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import F, Model, QuerySet from django.db.transaction import atomic +from drf_spectacular.utils import extend_schema, extend_schema_view from guardian.shortcuts import get_objects_for_user from rbac.models import User from rest_framework.decorators import action @@ -43,8 +44,15 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import Serializer -from rest_framework.status import HTTP_201_CREATED, HTTP_204_NO_CONTENT +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_404_NOT_FOUND, + HTTP_409_CONFLICT, +) +from api_v2.action.serializers import ActionListSerializer, ActionRetrieveSerializer from api_v2.action.utils import has_run_perms from api_v2.action.views import ActionViewSet from api_v2.action_host_group.serializers import ( @@ -54,6 +62,8 @@ AddHostSerializer, ShortHostSerializer, ) +from api_v2.api_schema import DOCS_CLIENT_INPUT_ERROR_RESPONSES, DOCS_DEFAULT_ERROR_RESPONSES, ErrorSerializer +from api_v2.task.serializers import TaskListSerializer from api_v2.views import CamelCaseGenericViewSet, with_group_object, with_parent_object _PARENT_PERMISSION_MAP: dict[ADCMCoreType, tuple[str, type[Model]]] = { @@ -111,9 +121,46 @@ def check_has_group_permissions(user: User, parent: CoreObjectDescriptor, dto: P check_has_group_permissions_for_object(user=user, parent_object=parent_object, dto=dto) +@extend_schema_view( + create=extend_schema( + operation_id="postObjectActionHostGroup", + summary="POST object's Action Host Group", + description="Create a new object's action host group.", + responses={ + HTTP_201_CREATED: ActionHostGroupSerializer, + **DOCS_DEFAULT_ERROR_RESPONSES, + **DOCS_CLIENT_INPUT_ERROR_RESPONSES, + }, + ), + list=extend_schema( + operation_id="getObjectActionHostGroups", + summary="GET object's Action Host Groups", + description="Return list of object's action host groups.", + responses={HTTP_200_OK: ActionHostGroupSerializer(many=True), **DOCS_DEFAULT_ERROR_RESPONSES}, + ), + retrieve=extend_schema( + operation_id="getObjectActionHostGroup", + summary="GET object's Action Host Group", + description="Return information about specific object's action host group.", + responses={HTTP_200_OK: ActionHostGroupSerializer, HTTP_404_NOT_FOUND: ErrorSerializer}, + ), + destroy=extend_schema( + operation_id="deleteObjectActionHostGroup", + summary="DELETE object's Action Host Group", + description="Delete specific object's action host group.", + responses={HTTP_204_NO_CONTENT: None, HTTP_404_NOT_FOUND: ErrorSerializer, HTTP_409_CONFLICT: ErrorSerializer}, + ), + host_candidate=extend_schema( + operation_id="getObjectActionHostGroupCandidates", + summary="GET object's Action Host Group's host candidates", + description="Return list of object's hosts that can be added to action host group.", + responses={HTTP_200_OK: ShortHostSerializer(many=True), HTTP_404_NOT_FOUND: ErrorSerializer}, + ), +) class ActionHostGroupViewSet(CamelCaseGenericViewSet): queryset = ActionHostGroup.objects.prefetch_related("hosts").order_by("id") action_host_group_service = ActionHostGroupService(repository=ActionHostGroupRepo()) + filter_backends = [] def get_serializer_class(self) -> type[Serializer]: if self.action == "create": @@ -212,6 +259,24 @@ def get_parent_name(self, parent: CoreObjectDescriptor) -> str: ) +@extend_schema_view( + create=extend_schema( + operation_id="postObjectActionHostGroupHosts", + summary="POST object's Action Host Group hosts", + description="Add hosts to object's action host group.", + responses={ + HTTP_201_CREATED: ShortHostSerializer, + **DOCS_DEFAULT_ERROR_RESPONSES, + **DOCS_CLIENT_INPUT_ERROR_RESPONSES, + }, + ), + destroy=extend_schema( + operation_id="deleteObjectActionHostGroupHosts", + summary="DELETE object's Action Host Group hosts", + description="Delete specific host from object's action host group.", + responses={HTTP_204_NO_CONTENT: None, HTTP_404_NOT_FOUND: ErrorSerializer, HTTP_409_CONFLICT: ErrorSerializer}, + ), +) class HostActionHostGroupViewSet(CamelCaseGenericViewSet): serializer_class = AddHostSerializer action_host_group_service = ActionHostGroupService(repository=ActionHostGroupRepo()) @@ -270,6 +335,30 @@ def destroy( return Response(status=HTTP_204_NO_CONTENT) +@extend_schema_view( + run=extend_schema( + operation_id="postActionHostGroupAction", + summary="POST action host group's action", + description="Run action host group's action.", + responses={ + HTTP_200_OK: TaskListSerializer, + **DOCS_DEFAULT_ERROR_RESPONSES, + **DOCS_CLIENT_INPUT_ERROR_RESPONSES, + }, + ), + list=extend_schema( + operation_id="getActionHostGroupActions", + summary="GET action host group's actions", + description="Get a list of action host group's actions.", + responses={HTTP_200_OK: ActionListSerializer, HTTP_404_NOT_FOUND: ErrorSerializer}, + ), + retrieve=extend_schema( + operation_id="getActionHostGroupAction", + summary="GET action host group's action", + description="Get information about a specific action host group's action.", + responses={HTTP_200_OK: ActionRetrieveSerializer, HTTP_404_NOT_FOUND: ErrorSerializer}, + ), +) class ActionHostGroupActionViewSet(ActionViewSet): def get_parent_object(self) -> ActionHostGroup | None: if "action_host_group_pk" not in self.kwargs: diff --git a/python/api_v2/api_schema.py b/python/api_v2/api_schema.py index e61c777acb..e647ab540c 100644 --- a/python/api_v2/api_schema.py +++ b/python/api_v2/api_schema.py @@ -13,6 +13,7 @@ from adcm.serializers import EmptySerializer from drf_spectacular.utils import OpenApiParameter from rest_framework.fields import CharField +from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT class ErrorSerializer(EmptySerializer): @@ -21,6 +22,10 @@ class ErrorSerializer(EmptySerializer): desc = CharField() +DOCS_DEFAULT_ERROR_RESPONSES = {HTTP_403_FORBIDDEN: ErrorSerializer, HTTP_404_NOT_FOUND: ErrorSerializer} +DOCS_CLIENT_INPUT_ERROR_RESPONSES = {HTTP_400_BAD_REQUEST: ErrorSerializer, HTTP_409_CONFLICT: ErrorSerializer} + + class DefaultParams: LIMIT = OpenApiParameter(name="limit", description="Number of records included in the selection.", type=int) OFFSET = OpenApiParameter(name="offset", description="Record number from which the selection starts.", type=int) From b28e6372dfbb1f3193e6e1a4ce95e707a153a9fc Mon Sep 17 00:00:00 2001 From: Egor Araslanov Date: Tue, 18 Jun 2024 09:02:13 +0500 Subject: [PATCH 178/208] ADCM-3477 Send LDAP queries info to file instead of stdout --- python/adcm/settings.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/python/adcm/settings.py b/python/adcm/settings.py index 7259917b5a..edba856318 100644 --- a/python/adcm/settings.py +++ b/python/adcm/settings.py @@ -285,10 +285,10 @@ def get_db_options() -> dict: "formatter": "adcm", "stream": "ext://sys.stderr", }, - "ldap_stdout_handler": { - "class": "logging.StreamHandler", - "formatter": "ldap", - "stream": "ext://sys.stdout", + "ldap_file_handler": { + "class": "logging.FileHandler", + "formatter": "adcm", + "filename": LOG_DIR / "ldap.log", }, }, "loggers": { @@ -321,10 +321,7 @@ def get_db_options() -> dict: "handlers": ["stream_stdout_handler", "stream_stderr_handler"], "level": LOG_LEVEL, }, - "django_auth_ldap": { - "handlers": ["ldap_stdout_handler"], - "level": LOG_LEVEL, - }, + "django_auth_ldap": {"handlers": ["ldap_file_handler"], "level": LOG_LEVEL, "propagate": True}, }, } From ed6ac7f03eb088b096bfea0c9cecd270991559b1 Mon Sep 17 00:00:00 2001 From: Egor Araslanov Date: Tue, 18 Jun 2024 12:26:11 +0500 Subject: [PATCH 179/208] ADCM-5519 Move `jinja_config` to `cm.services` --- python/adcm/settings.py | 1 - python/api/action/serializers.py | 10 +- python/api_v2/action/utils.py | 4 +- python/api_v2/action/views.py | 4 +- python/cm/adcm_config/config.py | 4 +- python/cm/services/cluster.py | 12 ++ .../services/config/jinja.py} | 163 +++++++++--------- python/cm/services/job/jinja_scripts.py | 13 +- python/cm/tests/test_jinja_config.py | 4 +- 9 files changed, 106 insertions(+), 109 deletions(-) rename python/{jinja_config.py => cm/services/config/jinja.py} (83%) diff --git a/python/adcm/settings.py b/python/adcm/settings.py index 7259917b5a..853fce2ad6 100644 --- a/python/adcm/settings.py +++ b/python/adcm/settings.py @@ -357,7 +357,6 @@ def get_db_options() -> dict: STACK_FILE_FIELD_TYPES = {"file", "secretfile"} STACK_NUMERIC_FIELD_TYPES = {"integer", "float"} SECURE_PARAM_TYPES = {"password", "secrettext"} -TEMPLATE_CONFIG_DELETE_FIELDS = {"yspec", "option", "activatable", "active", "read_only", "writable", "subs", "source"} EMPTY_REQUEST_STATUS_CODE = 32 VALUE_ERROR_STATUS_CODE = 8 diff --git a/python/api/action/serializers.py b/python/api/action/serializers.py index e348d49efe..faca015037 100644 --- a/python/api/action/serializers.py +++ b/python/api/action/serializers.py @@ -13,7 +13,7 @@ from adcm.serializers import EmptySerializer from cm.adcm_config.config import get_action_variant, get_prototype_config from cm.models import Action, PrototypeConfig, SubAction -from jinja_config import get_jinja_config +from cm.services.config.jinja import get_jinja_config from rest_framework.reverse import reverse from rest_framework.serializers import ( BooleanField, @@ -155,7 +155,9 @@ def get_config(self, action: Action) -> dict: if not self.context.get("objects"): return {} - action_config, attr = get_jinja_config(action=action, obj=self.context["objects"][action.prototype_type]) + action_config, attr = get_jinja_config( + action=action, cluster_relative_object=self.context["objects"][action.prototype_type] + ) else: action_config = PrototypeConfig.objects.filter(prototype=action.prototype, action=action).order_by("id") _, _, _, attr = get_prototype_config(prototype=action.prototype, action=action) @@ -179,7 +181,9 @@ class ActionDetailSerializer(StackActionDetailSerializer): class ActionUISerializer(ActionDetailSerializer): def get_config(self, action: Action) -> dict: if action.config_jinja: - action_config, attr = get_jinja_config(action=action, obj=self.context["objects"][action.prototype_type]) + action_config, attr = get_jinja_config( + action=action, cluster_relative_object=self.context["objects"][action.prototype_type] + ) else: action_config = PrototypeConfig.objects.filter(prototype=action.prototype, action=action).order_by("id") _, _, _, attr = get_prototype_config(prototype=action.prototype, action=action) diff --git a/python/api_v2/action/utils.py b/python/api_v2/action/utils.py index e67804f1fc..ea56875e4c 100644 --- a/python/api_v2/action/utils.py +++ b/python/api_v2/action/utils.py @@ -29,8 +29,8 @@ ServiceComponent, ) from cm.services.bundle import ADCMBundlePathResolver, BundlePathResolver +from cm.services.config.jinja import get_jinja_config from django.conf import settings -from jinja_config import get_jinja_config from rbac.models import User from api_v2.config.utils import convert_attr_to_adcm_meta, get_config_schema @@ -81,7 +81,7 @@ def get_action_configuration( action_: Action, object_: Cluster | ClusterObject | ServiceComponent | HostProvider | Host ) -> tuple[dict | None, dict | None, dict | None]: if action_.config_jinja: - prototype_configs, _ = get_jinja_config(action=action_, obj=object_) + prototype_configs, _ = get_jinja_config(action=action_, cluster_relative_object=object_) else: prototype_configs = PrototypeConfig.objects.filter(prototype=action_.prototype, action=action_).order_by("id") diff --git a/python/api_v2/action/views.py b/python/api_v2/action/views.py index 6d493eb6b4..12d4f91e11 100644 --- a/python/api_v2/action/views.py +++ b/python/api_v2/action/views.py @@ -16,13 +16,13 @@ from audit.utils import audit from cm.errors import AdcmEx from cm.models import ADCM, Action, ConcernType, Host, HostComponent, PrototypeConfig +from cm.services.config.jinja import get_jinja_config from cm.services.job.action import ActionRunPayload, run_action from cm.stack import check_hostcomponents_objects_exist from django.conf import settings from django.db.models import Q from django_filters.rest_framework.backends import DjangoFilterBackend from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view -from jinja_config import get_jinja_config from rest_framework.decorators import action from rest_framework.exceptions import NotFound from rest_framework.mixins import ListModelMixin, RetrieveModelMixin @@ -244,7 +244,7 @@ def run(self, request: Request, *args, **kwargs) -> Response: # noqa: ARG001, A adcm_meta = configuration["adcm_meta"] if target_action.config_jinja: - prototype_configs, _ = get_jinja_config(action=target_action, obj=self.parent_object) + prototype_configs, _ = get_jinja_config(action=target_action, cluster_relative_object=self.parent_object) prototype_configs = [ prototype_config for prototype_config in prototype_configs if prototype_config.type == "json" ] diff --git a/python/cm/adcm_config/config.py b/python/cm/adcm_config/config.py index fcf9527dab..73d4813941 100644 --- a/python/cm/adcm_config/config.py +++ b/python/cm/adcm_config/config.py @@ -21,7 +21,6 @@ from ansible.errors import AnsibleError from django.conf import settings from django.db.models import QuerySet -from jinja_config import get_jinja_config from cm.adcm_config.ansible import ansible_decrypt, ansible_encrypt_and_format from cm.adcm_config.checks import check_attr, check_config_type @@ -51,6 +50,7 @@ ServiceComponent, ) from cm.services.bundle import ADCMBundlePathResolver, BundlePathResolver, PathResolver +from cm.services.config.jinja import get_jinja_config from cm.utils import deep_merge, dict_to_obj, obj_to_dict from cm.variant import get_variant, process_variant @@ -88,7 +88,7 @@ def get_prototype_config( flist = ("default", "required", "type", "limits") if action is not None and obj is not None and action.config_jinja: - proto_conf, _ = get_jinja_config(action=action, obj=obj) + proto_conf, _ = get_jinja_config(action=action, cluster_relative_object=obj) proto_conf_group = [config for config in proto_conf if config.type == "group"] else: proto_conf = PrototypeConfig.objects.filter(prototype=prototype, action=action).order_by("id") diff --git a/python/cm/services/cluster.py b/python/cm/services/cluster.py index f6173f3296..750a18faaa 100644 --- a/python/cm/services/cluster.py +++ b/python/cm/services/cluster.py @@ -111,6 +111,18 @@ def retrieve_clusters_topology(cluster_ids: Iterable[ClusterID]) -> Generator[Cl return build_clusters_topology(cluster_ids=cluster_ids, db=ClusterDB) +def retrieve_related_cluster_topology(orm_object: Cluster | ClusterObject | ServiceComponent | Host) -> ClusterTopology: + if isinstance(orm_object, Cluster): + cluster_id = orm_object.id + elif isinstance(orm_object, (ClusterObject, ServiceComponent, Host)) and orm_object.cluster_id: + cluster_id = orm_object.cluster_id + else: + message = f"Can't detect cluster variables for {orm_object}" + raise RuntimeError(message) + + return next(retrieve_clusters_topology([cluster_id])) + + def retrieve_clusters_objects_maintenance_mode(cluster_ids: Iterable[ClusterID]) -> MaintenanceModeOfObjects: return MaintenanceModeOfObjects( hosts={ diff --git a/python/jinja_config.py b/python/cm/services/config/jinja.py similarity index 83% rename from python/jinja_config.py rename to python/cm/services/config/jinja.py index 3a83175eda..70bcc3bb6d 100644 --- a/python/jinja_config.py +++ b/python/cm/services/config/jinja.py @@ -11,10 +11,13 @@ # limitations under the License. from pathlib import Path +from typing import Any + +from django.conf import settings +from yaml import safe_load from cm.models import ( Action, - ADCMEntity, Cluster, ClusterObject, Host, @@ -22,29 +25,88 @@ ServiceComponent, ) from cm.services.bundle import BundlePathResolver, detect_relative_path_to_bundle_root -from cm.services.cluster import retrieve_clusters_topology +from cm.services.cluster import retrieve_related_cluster_topology from cm.services.config.patterns import Pattern from cm.services.job.inventory import get_cluster_vars from cm.services.job.jinja_scripts import get_action_info from cm.services.template import TemplateBuilder -from django.conf import settings -from yaml import safe_load +_TEMPLATE_CONFIG_DELETE_FIELDS = {"yspec", "option", "activatable", "active", "read_only", "writable", "subs", "source"} + + +def get_jinja_config( + action: Action, cluster_relative_object: Cluster | ClusterObject | ServiceComponent | Host +) -> tuple[list[PrototypeConfig], dict[str, Any]]: + resolver = BundlePathResolver(bundle_hash=action.prototype.bundle.hash) + jinja_conf_file = resolver.resolve(action.config_jinja) + + template_builder = TemplateBuilder( + template_path=jinja_conf_file, + context={ + **get_cluster_vars(topology=retrieve_related_cluster_topology(orm_object=cluster_relative_object)).dict( + by_alias=True, exclude_defaults=True + ), + "action": get_action_info(action=action), + }, + ) -def _get_attr(config: dict) -> dict: + configs = [] attr = {} + for config in template_builder.data: + for normalized_config in _normalize_config( + config=config, dir_with_config=jinja_conf_file.parent.relative_to(resolver.bundle_root), resolver=resolver + ): + configs.append(PrototypeConfig(prototype=action.prototype, action=action, **normalized_config)) - if all( - ( - "activatable" in config["limits"], - "active" in config["limits"], - config["type"] == "group", - config.get("name"), - ), - ): - attr[config["name"]] = config["limits"] + if ( + normalized_config["type"] == "group" + and "activatable" in normalized_config["limits"] + and "active" in normalized_config["limits"] + and normalized_config.get("name") + ): + attr[normalized_config["name"]] = normalized_config["limits"] + + return configs, attr + + +def _normalize_config( + config: dict, dir_with_config: Path, resolver: BundlePathResolver, name: str = "", subname: str = "" +) -> list[dict]: + """`dir_with_config` should be relative to bundle root""" + config_list = [config] + + name = name or config["name"] + config["name"] = name + if subname: + config["subname"] = subname + + if config.get("display_name") is None: + config["display_name"] = subname or name + + config["limits"] = _get_limits(config=config, dir_with_config=dir_with_config, resolver=resolver) + + if config["type"] in settings.STACK_FILE_FIELD_TYPES and config.get("default"): + config["default"] = detect_relative_path_to_bundle_root( + source_file_dir=dir_with_config, raw_path=config["default"] + ) + + if "subs" in config: + for subconf in config["subs"]: + config_list.extend( + _normalize_config( + config=subconf, + dir_with_config=dir_with_config, + resolver=resolver, + name=name, + subname=subconf["name"], + ), + ) + + for field in _TEMPLATE_CONFIG_DELETE_FIELDS: + if field in config: + del config[field] - return attr + return config_list def _get_limits(config: dict, dir_with_config: Path, resolver: BundlePathResolver) -> dict: @@ -113,74 +175,3 @@ def _get_limits(config: dict, dir_with_config: Path, resolver: BundlePathResolve limits[label] = config[label] return limits - - -def _normalize_config( - config: dict, dir_with_config: Path, resolver: BundlePathResolver, name: str = "", subname: str = "" -) -> list[dict]: - """`dir_with_config` should be relative to bundle root""" - config_list = [config] - - name = name or config["name"] - config["name"] = name - if subname: - config["subname"] = subname - - if config.get("display_name") is None: - config["display_name"] = subname or name - - config["limits"] = _get_limits(config=config, dir_with_config=dir_with_config, resolver=resolver) - - if config["type"] in settings.STACK_FILE_FIELD_TYPES and config.get("default"): - config["default"] = detect_relative_path_to_bundle_root( - source_file_dir=dir_with_config, raw_path=config["default"] - ) - - if "subs" in config: - for subconf in config["subs"]: - config_list.extend( - _normalize_config( - config=subconf, - dir_with_config=dir_with_config, - resolver=resolver, - name=name, - subname=subconf["name"], - ), - ) - - for field in settings.TEMPLATE_CONFIG_DELETE_FIELDS: - if field in config: - del config[field] - - return config_list - - -def get_jinja_config(action: Action, obj: ADCMEntity) -> tuple[list[PrototypeConfig], dict]: - if isinstance(obj, Cluster): - cluster_topology = next(retrieve_clusters_topology([obj.pk])) - elif isinstance(obj, (ClusterObject, ServiceComponent, Host)) and obj.cluster_id: - cluster_topology = next(retrieve_clusters_topology([obj.cluster_id])) - else: - message = f"Can't detect cluster variables for {obj}" - raise RuntimeError(message) - - resolver = BundlePathResolver(bundle_hash=action.prototype.bundle.hash) - jinja_conf_file = resolver.resolve(action.config_jinja) - template_builder = TemplateBuilder( - template_path=jinja_conf_file, - context={ - **get_cluster_vars(topology=cluster_topology).dict(by_alias=True, exclude_defaults=True), - "action": get_action_info(action=action), - }, - ) - - configs = [] - attr = {} - for config in template_builder.data: - for normalized_config in _normalize_config( - config=config, dir_with_config=jinja_conf_file.parent.relative_to(resolver.bundle_root), resolver=resolver - ): - configs.append(PrototypeConfig(prototype=action.prototype, action=action, **normalized_config)) - attr.update(**_get_attr(config=normalized_config)) - - return configs, attr diff --git a/python/cm/services/job/jinja_scripts.py b/python/cm/services/job/jinja_scripts.py index 9bc8975766..dc7e6b6a5f 100755 --- a/python/cm/services/job/jinja_scripts.py +++ b/python/cm/services/job/jinja_scripts.py @@ -18,17 +18,14 @@ from cm.errors import AdcmEx from cm.models import ( Action, - Cluster, - ClusterObject, Host, MaintenanceMode, ObjectType, Prototype, - ServiceComponent, TaskLog, ) from cm.services.bundle import BundlePathResolver, detect_relative_path_to_bundle_root -from cm.services.cluster import retrieve_clusters_topology +from cm.services.cluster import retrieve_related_cluster_topology from cm.services.job.inventory import ( ClusterNode, ServiceNode, @@ -61,13 +58,7 @@ class JinjaScriptsEnvironment(TypedDict): def get_env(task: TaskLog, delta: dict | None = None) -> JinjaScriptsEnvironment: target_object = task.task_object - if isinstance(target_object, Cluster): - cluster_topology = next(retrieve_clusters_topology([target_object.pk])) - elif isinstance(target_object, (ClusterObject, ServiceComponent, Host)): - cluster_topology = next(retrieve_clusters_topology([target_object.cluster_id])) - else: - message = f"Can't detect cluster variables for {target_object}" - raise RuntimeError(message) # noqa: TRY004 + cluster_topology = retrieve_related_cluster_topology(orm_object=target_object) cluster_vars = get_cluster_vars(topology=cluster_topology) diff --git a/python/cm/tests/test_jinja_config.py b/python/cm/tests/test_jinja_config.py index 9ddbdb6b58..88f04af4d8 100644 --- a/python/cm/tests/test_jinja_config.py +++ b/python/cm/tests/test_jinja_config.py @@ -13,9 +13,9 @@ from pathlib import Path from adcm.tests.base import BaseTestCase, BusinessLogicMixin, TaskTestMixin -from jinja_config import get_jinja_config from cm.models import Action +from cm.services.config.jinja import get_jinja_config class TestJinjaConfigBugs(BusinessLogicMixin, TaskTestMixin, BaseTestCase): @@ -47,7 +47,7 @@ def test_adcm_5556_incorrect_path_bug(self) -> None: config_prototypes = { proto.name: {"limits": proto.limits, "default": str(proto.default)} - for proto in get_jinja_config(obj=cluster, action=action)[0] + for proto in get_jinja_config(cluster_relative_object=cluster, action=action)[0] } self.assertDictEqual( From fd9d75fc508a975b77e1746b1205a7dbf53311fc Mon Sep 17 00:00:00 2001 From: Artem Starovoitov Date: Tue, 18 Jun 2024 07:49:03 +0000 Subject: [PATCH 180/208] ADCM-5666: Fix message for group --- python/ansible_plugin/executors/check.py | 22 ++-------------- .../ansible_plugin/tests/test_adcm_check.py | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/python/ansible_plugin/executors/check.py b/python/ansible_plugin/executors/check.py index 5de4046bd5..4552052841 100644 --- a/python/ansible_plugin/executors/check.py +++ b/python/ansible_plugin/executors/check.py @@ -42,8 +42,8 @@ class CheckArguments(BaseStrictModel): fail_msg: str | None = None success_msg: str | None = None group_title: str | None = None - group_success_msg: str | None = None - group_fail_msg: str | None = None + group_success_msg: str = "" + group_fail_msg: str = "" @model_validator(mode="after") def check_msg_is_specified_if_no_fail_success_msg(self) -> Self: @@ -64,24 +64,6 @@ def check_success_msg_and_fail_msg_are_specified_if_no_msg(self) -> Self: return self - @model_validator(mode="after") - def check_group_msg_if_group_is_specified_if_no_msg(self) -> Self: - if self.msg: - return self - - if ( - self.group_title is not None - and self.group_success_msg is None - and self.group_title is not None - and self.group_fail_msg is None - ): - message = ( - "either 'group_fail_msg' or 'group_success_msg' or msg must be specified if 'group_title' is specified" - ) - raise ValueError(message) - - return self - class JSONLogReturnValue(TypedDict): check: dict diff --git a/python/ansible_plugin/tests/test_adcm_check.py b/python/ansible_plugin/tests/test_adcm_check.py index 20f129ac88..b0055caf8a 100644 --- a/python/ansible_plugin/tests/test_adcm_check.py +++ b/python/ansible_plugin/tests/test_adcm_check.py @@ -336,3 +336,29 @@ def test_adcm_check_double_call_fail(self) -> None: self.assertEqual(GroupCheckLog.objects.all().count(), 0) self.assertEqual(CheckLog.objects.all().count(), 0) self.assertEqual(LogStorage.objects.all().count(), 2) + + def test_adcm_check_group_msg_cant_be_null_fail(self) -> None: + task = self.prepare_task(owner=self.cluster, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=ADCMCheckPluginExecutor, + call_arguments=""" + title: title + result: true + msg: test_message + group_title: group + group_success_msg: null + """, + call_context=job, + ) + result = executor.execute() + + self.assertIsInstance(result.error, PluginValidationError) + self.assertIn("Arguments doesn't match expected schema", result.error.message) + self.assertDictEqual(result.value, {}) + self.assertFalse(result.changed) + + self.assertEqual(GroupCheckLog.objects.all().count(), 0) + self.assertEqual(CheckLog.objects.all().count(), 0) + self.assertEqual(LogStorage.objects.all().count(), 2) From 72d45058f290b2205a4d1c81eb395e47241bebde Mon Sep 17 00:00:00 2001 From: Egor Araslanov Date: Tue, 18 Jun 2024 14:50:42 +0500 Subject: [PATCH 181/208] ADCM-5661 Make `remove_host_from_cluster` plugin be available from component context --- python/ansible_plugin/executors/remove_host_from_cluster.py | 4 +++- .../tests/test_adcm_remove_host_from_cluster.py | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/python/ansible_plugin/executors/remove_host_from_cluster.py b/python/ansible_plugin/executors/remove_host_from_cluster.py index 47ad41e1b9..32147e7efa 100644 --- a/python/ansible_plugin/executors/remove_host_from_cluster.py +++ b/python/ansible_plugin/executors/remove_host_from_cluster.py @@ -50,7 +50,9 @@ def check_either_is_specified(self) -> Self: class ADCMRemoveHostFromClusterPluginExecutor(ADCMAnsiblePluginExecutor[RemoveHostFromClusterArguments, None]): _config = PluginExecutorConfig( arguments=ArgumentsConfig(represent_as=RemoveHostFromClusterArguments), - context=ContextConfig(allow_only=frozenset((ADCMCoreType.CLUSTER, ADCMCoreType.SERVICE))), + context=ContextConfig( + allow_only=frozenset((ADCMCoreType.CLUSTER, ADCMCoreType.SERVICE, ADCMCoreType.COMPONENT)) + ), ) def __call__( diff --git a/python/ansible_plugin/tests/test_adcm_remove_host_from_cluster.py b/python/ansible_plugin/tests/test_adcm_remove_host_from_cluster.py index f0564bb62f..35e599302b 100644 --- a/python/ansible_plugin/tests/test_adcm_remove_host_from_cluster.py +++ b/python/ansible_plugin/tests/test_adcm_remove_host_from_cluster.py @@ -276,7 +276,7 @@ def test_remove_host_from_cluster_with_component_on_host_success(self) -> None: (self.host_2, ServiceComponent.objects.get(service=self.service_1, prototype__name="component_1")), ), ) - task = self.prepare_task(owner=self.cluster, name="dummy") + task = self.prepare_task(owner=self.component_1, name="dummy") job, *_ = JobRepoImpl.get_task_jobs(task.id) executor = self.prepare_executor( @@ -295,7 +295,7 @@ def test_remove_host_from_cluster_with_component_on_host_success(self) -> None: self.assertIsNone(self.host_1.cluster_id) def test_incorrect_context_call_fail(self) -> None: - for object_ in (self.host_1, self.component_1, self.provider): + for object_ in (self.host_1, self.provider): name = object_.__class__.__name__ with self.subTest(name): task = self.prepare_task(owner=object_, name="dummy") @@ -311,7 +311,7 @@ def test_incorrect_context_call_fail(self) -> None: self.assertIsInstance(result.error, PluginContextError) self.assertIn( - "Plugin should be called only in context of cluster or service, " + "Plugin should be called only in context of cluster or component or service, " f"not {orm_object_to_core_type(object_).value}", result.error.message, ) From 8433356f07970fc9cf8b1ac190ddef1b7d8227b1 Mon Sep 17 00:00:00 2001 From: Artem Starovoitov Date: Tue, 18 Jun 2024 14:47:24 +0000 Subject: [PATCH 182/208] ADCM-5642: Add RBAC roles for AnsibleConfig --- python/api_v2/cluster/permissions.py | 4 +- python/api_v2/cluster/serializers.py | 2 +- python/api_v2/cluster/views.py | 13 +- .../api_v2/tests/test_audit/test_cluster.py | 152 ++++++++++++++++++ python/api_v2/tests/test_cluster.py | 57 +++++++ python/api_v2/tests/test_role.py | 2 +- python/api_v2/tests/test_upgrade.py | 22 ++- python/api_v2/upgrade/views.py | 9 +- python/audit/cases/cases.py | 1 + python/audit/cases/cluster.py | 10 ++ python/audit/utils.py | 14 +- .../test_policy/test_cluster_admin_role.py | 6 + python/rbac/upgrade/role_spec.yaml | 95 ++++++++++- 13 files changed, 374 insertions(+), 13 deletions(-) diff --git a/python/api_v2/cluster/permissions.py b/python/api_v2/cluster/permissions.py index e0cb3c9757..c4b382ec7b 100644 --- a/python/api_v2/cluster/permissions.py +++ b/python/api_v2/cluster/permissions.py @@ -28,7 +28,7 @@ class ClusterPermissions(DjangoObjectPermissions): @audit def has_permission(self, request, view) -> bool: if ( - view.action in ["destroy", "update", "partial_update"] + view.action in ["destroy", "update", "partial_update", "ansible_config"] or view.action == "mapping" and request.method == "POST" ): @@ -39,6 +39,8 @@ def has_permission(self, request, view) -> bool: def has_object_permission(self, request, view, obj) -> bool: if view.action == "mapping" and request.method == "POST": self.perms_map["POST"] = [] + elif view.action == "ansible_config" and request.method == "POST": + self.perms_map["POST"] = ["%(app_label)s.change_%(model_name)s"] else: self.perms_map["POST"] = ["%(app_label)s.add_%(model_name)s"] diff --git a/python/api_v2/cluster/serializers.py b/python/api_v2/cluster/serializers.py index 22ea3da735..7ff972ccc7 100644 --- a/python/api_v2/cluster/serializers.py +++ b/python/api_v2/cluster/serializers.py @@ -209,7 +209,7 @@ def validate_config(value: dict) -> dict: defaults = value["defaults"] - if set(defaults) != {"forks"}: + if set(defaults or ()) != {"forks"}: raise ValidationError("Only `defaults.forks` parameter can be modified") if not isinstance(defaults["forks"], int) or defaults["forks"] < 1: diff --git a/python/api_v2/cluster/views.py b/python/api_v2/cluster/views.py index e530294003..2713c5307e 100644 --- a/python/api_v2/cluster/views.py +++ b/python/api_v2/cluster/views.py @@ -448,6 +448,7 @@ def mapping_components(self, request: Request, *args, **kwargs): # noqa: ARG002 HTTP_409_CONFLICT: ErrorSerializer, }, ) + @audit @action(methods=["get", "post"], detail=True, pagination_class=None, filter_backends=[], url_path="ansible-config") def ansible_config(self, request: Request, *args, **kwargs): # noqa: ARG002 cluster = self.get_object() @@ -456,13 +457,17 @@ def ansible_config(self, request: Request, *args, **kwargs): # noqa: ARG002 ) if request.method.lower() == "get": - # TODO: uncomment/refactor after ADCM-5642 - # check_custom_perm(user=request.user, action_type="view_ansible_config_of", model="cluster", obj=cluster) + check_custom_perm( + user=request.user, + action_type="view_ansible_config_of", + model="cluster", + obj=cluster, + second_perm="view_ansible_config_of_cluster", + ) return Response(status=HTTP_200_OK, data=AnsibleConfigRetrieveSerializer(instance=ansible_config).data) - # TODO: uncomment/refactor after ADCM-5642 - # check_custom_perm(user=request.user, action_type="change_ansible_config_of", model="cluster", obj=cluster) + check_custom_perm(user=request.user, action_type="change_ansible_config_of", model="cluster", obj=cluster) serializer = AnsibleConfigUpdateSerializer(data=request.data) serializer.is_valid(raise_exception=True) diff --git a/python/api_v2/tests/test_audit/test_cluster.py b/python/api_v2/tests/test_audit/test_cluster.py index 79b380fc9a..2eee4a998b 100644 --- a/python/api_v2/tests/test_audit/test_cluster.py +++ b/python/api_v2/tests/test_audit/test_cluster.py @@ -13,6 +13,7 @@ from audit.models import AuditObject from cm.models import ( Action, + AnsibleConfig, Cluster, ClusterObject, Host, @@ -21,6 +22,7 @@ ServiceComponent, Upgrade, ) +from django.contrib.contenttypes.models import ContentType from rest_framework.reverse import reverse from rest_framework.status import ( HTTP_200_OK, @@ -1245,3 +1247,153 @@ def test_cluster_object_changes_all_fields_success(self): expect_object_changes_=True, object_changes=expected_object_changes, ) + + def test_update_ansible_config_success(self): + self.client.login(**self.test_user_credentials) + with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="Cluster Administrator"): + response = self.client.v2[self.cluster_1, "ansible-config"].get() + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertDictEqual({"config": {"defaults": {"forks": 5}}, "adcm_meta": {}}, response.data) + + response = self.client.v2[self.cluster_1, "ansible-config"].post( + data={"config": {"defaults": {"forks": 13}}} + ) + + self.assertEqual(response.status_code, HTTP_201_CREATED) + + ansible_config = AnsibleConfig.objects.get( + object_id=self.cluster_1.pk, + object_type=ContentType.objects.get_for_model(model=self.cluster_1), + ) + self.assertDictEqual(ansible_config.value, {"defaults": {"forks": "13"}}) + + expected_object_changes = { + "current": {"defaults": {"forks": "13"}}, + "previous": {}, + } + + self.check_last_audit_record( + operation_name="Ansible configuration updated", + operation_type="update", + operation_result="success", + user__username="test_user_username", + expect_object_changes_=expected_object_changes, + ) + + def test_update_ansible_config_denied(self): + self.client.login(**self.test_user_credentials) + with self.grant_permissions(to=self.test_user, on=[], role_name="ADCM User"): + response = self.client.v2[self.cluster_1, "ansible-config"].get() + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertDictEqual({"config": {"defaults": {"forks": 5}}, "adcm_meta": {}}, response.data) + + response = self.client.v2[self.cluster_1, "ansible-config"].post( + data={"config": {"defaults": {"forks": 13}}} + ) + + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + + self.check_last_audit_record( + operation_name="Ansible configuration updated", + operation_type="update", + operation_result="denied", + user__username="test_user_username", + ) + + def test_update_ansible_config_parent_denied(self): + self.client.login(**self.test_user_credentials) + + response = self.client.v2[self.cluster_1, "ansible-config"].post(data={"config": {"defaults": {"forks": 13}}}) + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + + self.check_last_audit_record( + operation_name="Ansible configuration updated", + operation_type="update", + operation_result="denied", + user__username="test_user_username", + ) + + def test_update_ansible_config_denied_view_only_success(self): + self.client.login(**self.test_user_credentials) + with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="View object ansible config"): + response = self.client.v2[self.cluster_1, "ansible-config"].get() + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertDictEqual({"config": {"defaults": {"forks": 5}}, "adcm_meta": {}}, response.data) + + response = self.client.v2[self.cluster_1, "ansible-config"].post( + data={"config": {"defaults": {"forks": 13}}} + ) + + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + + self.check_last_audit_record( + operation_name="Ansible configuration updated", + operation_type="update", + operation_result="denied", + user__username="test_user_username", + ) + + def test_update_ansible_config_clustaer_administrator_success(self): + self.client.login(**self.test_user_credentials) + with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="Cluster Administrator"): + response = self.client.v2[self.cluster_1, "ansible-config"].post( + data={"config": {"defaults": {"forks": 13}}} + ) + + self.assertEqual(response.status_code, HTTP_201_CREATED) + + ansible_config = AnsibleConfig.objects.get( + object_id=self.cluster_1.pk, + object_type=ContentType.objects.get_for_model(model=self.cluster_1), + ) + self.assertDictEqual(ansible_config.value, {"defaults": {"forks": "13"}}) + + expected_object_changes = { + "current": {"defaults": {"forks": "13"}}, + "previous": {}, + } + + self.check_last_audit_record( + operation_name="Ansible configuration updated", + operation_type="update", + operation_result="success", + user__username="test_user_username", + expect_object_changes_=expected_object_changes, + ) + + def test_update_ansible_config_not_found_fail(self): + response = (self.client.v2 / "clusters" / 1000 / "ansible-config").post( + data={"config": {"defaults": {"forks": 13}}} + ) + + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + + self.check_last_audit_record( + operation_name="Ansible configuration updated", + operation_type="update", + operation_result="fail", + ) + + def test_update_ansible_config_wrong_request_fail(self): + wrong_requests = ( + {"config": {"defaults": {"forks": -1}}}, + {"config": {"defaults": {"forks": 0}}}, + {"config": 1}, + {"config": {"defaults": None}}, + {}, + {"config": {"defaults": {"forks": "wrong format"}}}, + ) + for request_data in wrong_requests: + with self.subTest(f"incorrect request: {request_data}"): + response = self.client.v2[self.cluster_1, "ansible-config"].post(data=request_data) + + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + + self.check_last_audit_record( + operation_name="Ansible configuration updated", + operation_type="update", + operation_result="fail", + ) diff --git a/python/api_v2/tests/test_cluster.py b/python/api_v2/tests/test_cluster.py index 049b3af572..fc1a9d22e8 100644 --- a/python/api_v2/tests/test_cluster.py +++ b/python/api_v2/tests/test_cluster.py @@ -36,6 +36,7 @@ HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, + HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT, ) @@ -50,6 +51,9 @@ def setUp(self) -> None: self.cluster_action = Action.objects.get(prototype=self.cluster_1.prototype, name="action") + self.test_user_credentials = {"username": "test_user_username", "password": "test_user_password"} + self.test_user = self.create_user(**self.test_user_credentials) + def test_list_success(self): with patch("cm.services.status.client.api_request") as patched_request: response = (self.client.v2 / "clusters").get() @@ -352,6 +356,35 @@ def test_retrieve_ansible_config_success(self): self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.json(), expected_response) + def test_retrieve_ansible_config_as_cluster_administrator_success(self): + self.client.login(**self.test_user_credentials) + with self.grant_permissions(to=self.test_user, on=[self.cluster_1], role_name="Cluster Administrator"): + expected_response = {"adcmMeta": {}, "config": {"defaults": {"forks": 5}}} + + response = self.client.v2[self.cluster_1, "ansible-config"].get() + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.json(), expected_response) + + def test_retrieve_ansible_config_denied(self): + self.client.login(**self.test_user_credentials) + with self.grant_permissions(to=self.test_user, on=[self.cluster_1], role_name="View cluster configurations"): + response = self.client.v2[self.cluster_1, "ansible-config"].get() + + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + + def test_retrieve_ansible_config_parent_not_found_denied(self): + self.client.login(**self.test_user_credentials) + response = self.client.v2[self.cluster_1, "ansible-config"].get() + + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) + + def test_retrieve_ansible_config_schema_success(self): + response = self.client.v2[self.cluster_1, "ansible-config-schema"].get() + + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual("Ansible configuration", response.json()["title"]) + def test_retrieve_ansible_config_fail(self): response = (self.client.v2 / "clusters" / str(self.get_non_existent_pk(model=Cluster)) / "ansible-config").get() @@ -368,6 +401,30 @@ def test_update_ansible_config_success(self): ) self.assertDictEqual(ansible_config.value, {"defaults": {"forks": "13"}}) + def test_update_ansible_config_as_cluster_administrator_success(self): + self.client.login(**self.test_user_credentials) + with self.grant_permissions(to=self.test_user, on=self.cluster_1, role_name="Cluster Administrator"): + response = self.client.v2[self.cluster_1, "ansible-config"].post( + data={"config": {"defaults": {"forks": 13}}} + ) + + self.assertEqual(response.status_code, HTTP_201_CREATED) + + ansible_config = AnsibleConfig.objects.get( + object_id=self.cluster_1.pk, + object_type=ContentType.objects.get_for_model(model=self.cluster_1), + ) + self.assertDictEqual(ansible_config.value, {"defaults": {"forks": "13"}}) + + def test_update_ansible_config_denied(self): + self.client.login(**self.test_user_credentials) + with self.grant_permissions(to=self.test_user, on=[], role_name="ADCM User"): + response = self.client.v2[self.cluster_1, "ansible-config"].post( + data={"config": {"defaults": {"forks": 13}}} + ) + + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + def test_update_ansible_config_fail(self): ansible_config = AnsibleConfig.objects.get( object_id=self.cluster_1.pk, diff --git a/python/api_v2/tests/test_role.py b/python/api_v2/tests/test_role.py index eac8bfec80..ba34d5ded9 100644 --- a/python/api_v2/tests/test_role.py +++ b/python/api_v2/tests/test_role.py @@ -206,7 +206,7 @@ def test_filtering_by_categories_success(self): response = (self.client.v2 / "rbac" / "roles").get(query={"categories": "cluster_one"}) self.assertEqual(response.status_code, HTTP_200_OK) - self.assertEqual(response.json()["count"], 34) + self.assertEqual(response.json()["count"], 36) def test_list_object_candidates_success(self): response = self.client.v2[self.cluster_config_role, "object-candidates"].get() diff --git a/python/api_v2/tests/test_upgrade.py b/python/api_v2/tests/test_upgrade.py index 11fda8d993..61941c942d 100644 --- a/python/api_v2/tests/test_upgrade.py +++ b/python/api_v2/tests/test_upgrade.py @@ -72,7 +72,7 @@ def setUp(self) -> None: name="upgrade_via_action_complex", bundle=provider_bundle_upgrade ) - self.create_user() + self.user = self.create_user() self.unauthorized_client = self.client_class() self.unauthorized_client.login(username="test_user_username", password="test_user_password") @@ -515,6 +515,26 @@ def test_adcm_4703_retrieve_upgrade_with_variant_without_cluster_config_500(self schema["properties"]["grouped"]["properties"]["pick_host"]["enum"], ["first_host", "second_host", None] ) + def test_list_cluster_upgrades_success(self): + response = self.client.v2[self.cluster_1, "upgrades"].get() + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(len(response.json()), 6) + + def test_list_upgrades_permission_success(self): + permission_cases = ( + (self.cluster_1, self.cluster_1, "Upgrade cluster bundle", 6), + (self.cluster_1, [], "ADCM User", 6), + (self.provider, [], "ADCM User", 3), + (self.provider, self.provider, "Upgrade provider bundle", 3), + ) + + for api_object, role_object, role, upgrades_count in permission_cases: + with self.subTest(msg=f"list upgrades on {role_object} with role {role}"): + with self.grant_permissions(to=self.user, on=role_object, role_name=role): + response = self.unauthorized_client.v2[api_object, "upgrades"].get() + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(len(response.json()), upgrades_count) + class TestAdcmUpgrade(APITestCase): @classmethod diff --git a/python/api_v2/upgrade/views.py b/python/api_v2/upgrade/views.py index e2d88e6369..2f427338bd 100644 --- a/python/api_v2/upgrade/views.py +++ b/python/api_v2/upgrade/views.py @@ -73,6 +73,7 @@ def get_object(self): action_type="view_upgrade_of", model=parent_object.__class__.__name__.lower(), obj=parent_object, + second_perm=f"view_upgrade_of_{parent_object.__class__.__name__.lower()}", ) if self.action == "run": @@ -96,13 +97,17 @@ def get_parent_object_for_user(self, user: User) -> Cluster | HostProvider: if isinstance(parent, Cluster): cluster = get_object_for_user(user=user, perms=VIEW_CLUSTER_PERM, klass=Cluster, id=parent.pk) - if not user.has_perm(perm=VIEW_CLUSTER_UPGRADE_PERM, obj=cluster): + if not user.has_perm(perm=VIEW_CLUSTER_UPGRADE_PERM, obj=cluster) and not user.has_perm( + perm=VIEW_CLUSTER_UPGRADE_PERM + ): raise PermissionDenied(f"You can't view upgrades of {cluster}") return cluster if isinstance(parent, HostProvider): hostprovider = get_object_for_user(user=user, perms=VIEW_PROVIDER_PERM, klass=HostProvider, id=parent.pk) - if not user.has_perm(perm=VIEW_PROVIDER_UPGRADE_PERM, obj=hostprovider): + if not user.has_perm(perm=VIEW_PROVIDER_UPGRADE_PERM, obj=hostprovider) and not user.has_perm( + perm=VIEW_PROVIDER_UPGRADE_PERM + ): raise PermissionDenied(f"You can't view upgrades of {hostprovider}") return hostprovider diff --git a/python/audit/cases/cases.py b/python/audit/cases/cases.py index fc6c90b69e..419558f16f 100644 --- a/python/audit/cases/cases.py +++ b/python/audit/cases/cases.py @@ -55,6 +55,7 @@ def get_audit_operation_and_object( or "component" in path or ("host" in path and "config" in path) or ("service" in path and ("import" in path or "config" in path)) + or "ansible-config" in path ): audit_operation, audit_object = cluster_case( path=path, view=view, response=response, deleted_obj=deleted_obj, api_version=api_version diff --git a/python/audit/cases/cluster.py b/python/audit/cases/cluster.py index 7cb082af67..38c8a9a4f7 100644 --- a/python/audit/cases/cluster.py +++ b/python/audit/cases/cluster.py @@ -83,6 +83,16 @@ def cluster_case( audit_object = None match path: + case ["clusters", obj_pk, "ansible-config"]: + audit_operation = AuditOperation( + name="Ansible configuration updated", + operation_type=AuditLogOperationType.UPDATE, + ) + cluster_name = Cluster.objects.values_list("name", flat=True).first() + audit_object = get_or_create_audit_obj( + object_id=obj_pk, object_type=AuditObjectType.CLUSTER, object_name=cluster_name + ) + case ["cluster"] | ["clusters"]: audit_operation, audit_object = response_case( obj_type=AuditObjectType.CLUSTER, diff --git a/python/audit/utils.py b/python/audit/utils.py index a6e54807d7..b75c3a7d43 100644 --- a/python/audit/utils.py +++ b/python/audit/utils.py @@ -22,6 +22,9 @@ from api.rbac.role.serializers import RoleAuditSerializer from api.rbac.user.serializers import UserAuditSerializer from api.service.serializers import ServiceAuditSerializer +from api_v2.cluster.serializers import ( + AnsibleConfigRetrieveSerializer, +) from api_v2.cluster.serializers import ( ClusterAuditSerializer as ClusterAuditSerializerV2, ) @@ -38,6 +41,7 @@ from cm.errors import AdcmEx from cm.models import ( Action, + AnsibleConfig, Cluster, ClusterBind, ClusterObject, @@ -303,7 +307,10 @@ def _get_obj_changes_data(view: GenericAPIView | ModelViewSet) -> tuple[dict | N serializer_class = UserAuditSerializer pk = view.request.user.id elif view.request.method == "POST": - if view.__class__.__name__ == "ServiceMaintenanceModeView": + if isinstance(view, ModelViewSet) and view.action == "ansible_config": + serializer_class = AnsibleConfigRetrieveSerializer + pk = view.kwargs["pk"] + elif view.__class__.__name__ == "ServiceMaintenanceModeView": serializer_class = ServiceAuditSerializer pk = view.kwargs["service_id"] elif view.__class__.__name__ == "HostMaintenanceModeView": @@ -330,7 +337,10 @@ def _get_obj_changes_data(view: GenericAPIView | ModelViewSet) -> tuple[dict | N if serializer_class: # for cases when get_queryset() raises error - model = view.audit_model_hint if hasattr(view, "audit_model_hint") else view.get_queryset().model + if isinstance(view, ModelViewSet) and view.action == "ansible_config": + model = AnsibleConfig + else: + model = view.audit_model_hint if hasattr(view, "audit_model_hint") else view.get_queryset().model try: current_obj = model.objects.filter(pk=pk).first() diff --git a/python/rbac/tests/test_policy/test_cluster_admin_role.py b/python/rbac/tests/test_policy/test_cluster_admin_role.py index 9ed5805f0a..ee9ec5d511 100644 --- a/python/rbac/tests/test_policy/test_cluster_admin_role.py +++ b/python/rbac/tests/test_policy/test_cluster_admin_role.py @@ -65,6 +65,7 @@ def test_policy_with_cluster_admin_role(self): "change_maintenance_mode_host", "change_maintenance_mode_servicecomponent", "change_objectconfig", + "change_ansible_config_of_cluster", "delete_bundle", "delete_clusterobject", "delete_groupconfig", @@ -91,6 +92,7 @@ def test_policy_with_cluster_admin_role(self): "view_objectconfig", "view_servicecomponent", "view_upgrade_of_cluster", + "view_ansible_config_of_cluster", }, ) @@ -428,6 +430,7 @@ def test_adding_new_policy_keeps_previous_permission(self): "change_maintenance_mode_host", "change_maintenance_mode_servicecomponent", "change_objectconfig", + "change_ansible_config_of_cluster", "delete_bundle", "delete_clusterobject", "delete_groupconfig", @@ -454,6 +457,7 @@ def test_adding_new_policy_keeps_previous_permission(self): "view_objectconfig", "view_servicecomponent", "view_upgrade_of_cluster", + "view_ansible_config_of_cluster", }, ) @@ -493,6 +497,7 @@ def test_adding_new_policy_keeps_previous_permission(self): "change_maintenance_mode_host", "change_maintenance_mode_servicecomponent", "change_objectconfig", + "change_ansible_config_of_cluster", "delete_bundle", "delete_clusterobject", "delete_groupconfig", @@ -522,5 +527,6 @@ def test_adding_new_policy_keeps_previous_permission(self): "view_servicecomponent", "view_upgrade_of_cluster", "view_upgrade_of_hostprovider", + "view_ansible_config_of_cluster", }, ) diff --git a/python/rbac/upgrade/role_spec.yaml b/python/rbac/upgrade/role_spec.yaml index c96c1b2b43..666650cd34 100644 --- a/python/rbac/upgrade/role_spec.yaml +++ b/python/rbac/upgrade/role_spec.yaml @@ -1,6 +1,6 @@ --- -version: 11 +version: 12 roles: - name: Add host @@ -339,6 +339,19 @@ roles: codenames: - view + - name: View any object ansible config + description: The ability to view ansible config page of all objects except ADCM settings + type: hidden + module_name: rbac.roles + class_name: ModelRole + any_category: false + apps: + - label: cm + models: + - name: cluster + codenames: + - view_ansible_config_of + - name: Edit config description: The ability to change and add configuration type: hidden @@ -599,6 +612,24 @@ roles: codenames: - view + - name: View any object upgrade + description: The ability to view all upgrades + type: hidden + module_name: rbac.roles + class_name: ModelRole + any_category: false + apps: + - label: cm + models: + - name: cluster + codenames: + - view + - view_upgrade_of + - name: hostprovider + codenames: + - view + - view_upgrade_of + - name: Manage cluster imports description: The ability to change cluster imports type: hidden @@ -1405,6 +1436,8 @@ roles: - View any object host-components - View any object configuration - View any object import + - View any object ansible config + - View any object upgrade - name: Service Administrator type: role @@ -1468,6 +1501,7 @@ roles: - Edit host config - Edit host-components hidden - Edit service config + - Edit object ansible config - Get cluster object - Get component object - Get host @@ -1624,3 +1658,62 @@ roles: child: - View audit logins - View audit operations + + - name: Edit cluster ansible config + description: The ability to change and add new ansible configuration + type: hidden + module_name: rbac.roles + class_name: ObjectRole + parametrized_by: + - cluster + apps: + - label: cm + models: + - name: cluster + codenames: + - change_ansible_config_of + + - name: View cluster ansible config + description: The ability to view the cluster ansible configuration + type: hidden + parametrized_by: + - cluster + module_name: rbac.roles + class_name: ObjectRole + apps: + - label: cm + models: + - name: cluster + codenames: + - view_ansible_config_of + + - name: View object ansible config + description: The ability to view object ansible configuration + type: business + parametrized_by: + - cluster + module_name: rbac.roles + class_name: ParentRole + any_category: true + init_params: + app_name: cm + model: Cluster + child: + - Get cluster object + - View cluster ansible config + + - name: Edit object ansible config + description: The ability to change and add new cluster ansible configuration + type: business + parametrized_by: + - cluster + module_name: rbac.roles + class_name: ParentRole + any_category: true + init_params: + app_name: cm + model: Cluster + child: + - Get cluster object + - View object ansible config + - Edit cluster ansible config From 55b0a0ab96e23e14749c588b3ea562719d57c000 Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Wed, 19 Jun 2024 09:21:26 +0000 Subject: [PATCH 183/208] ADCM-5662: Performance degradation while creating host ADCM with 1 provider, 50 consecutive add-host requests Old:\ ![old_overall](/uploads/0ab0369ad432b297bbec50c9207441db/old_overall.png) ![old_update_issues](/uploads/98dbb3a95ecba4f328e18327ba08d4ff/old_update_issues.png)\ New:\ ![image](/uploads/202d6bc81160fc38d8e6f5c070523472/image.png) --- python/api_v2/host/utils.py | 59 +++++++++++++++++++---------- python/api_v2/host/views.py | 12 ++++-- python/api_v2/tests/test_cluster.py | 13 +------ python/rbac/tests/test_role.py | 9 ++--- 4 files changed, 51 insertions(+), 42 deletions(-) diff --git a/python/api_v2/host/utils.py b/python/api_v2/host/utils.py index 357f16863f..4622a07418 100644 --- a/python/api_v2/host/utils.py +++ b/python/api_v2/host/utils.py @@ -14,12 +14,15 @@ from cm.adcm_config.config import init_object_config from cm.api import check_license from cm.api_context import CTX -from cm.issue import add_concern_to_object, update_hierarchy_issues +from cm.issue import ( + _prototype_issue_map, + add_concern_to_object, + recheck_issues, +) from cm.logger import logger -from cm.models import Cluster, Host, HostProvider, Prototype +from cm.models import Cluster, Host, HostProvider, ObjectType, Prototype from cm.services.maintenance_mode import get_maintenance_mode_response from cm.services.status.notify import reset_hc_map -from django.db.transaction import atomic from rbac.models import re_apply_object_policy from rest_framework.request import Request from rest_framework.response import Response @@ -28,30 +31,44 @@ from api_v2.host.serializers import HostChangeMaintenanceModeSerializer -def add_new_host_and_map_it(provider: HostProvider, fqdn: str, cluster: Cluster | None = None) -> Host: - host_proto = Prototype.objects.get(type="host", bundle=provider.prototype.bundle) - check_license(prototype=host_proto) - with atomic(): - host = Host.objects.create(prototype=host_proto, provider=provider, fqdn=fqdn) - obj_conf = init_object_config(proto=host_proto, obj=host) - host.config = obj_conf - if cluster: - host.cluster = cluster +def create_host(provider: HostProvider, fqdn: str, cluster: Cluster | None) -> Host: + host_prototype = Prototype.objects.get(type=ObjectType.HOST, bundle=provider.prototype.bundle) + check_license(prototype=host_prototype) - host.save() - add_concern_to_object(object_=host, concern=CTX.lock) + return Host.objects.create(prototype=host_prototype, provider=provider, fqdn=fqdn, cluster=cluster) - update_hierarchy_issues(obj=host.provider) - re_apply_object_policy(apply_object=provider) - if cluster: - re_apply_object_policy(apply_object=cluster) + +def _recheck_new_host_issues(host: Host): + """ + Copy-pasted parts of update_hierarchy_issues() from cm.issue for the sake of number of queries optimization + Works only on newly created hosts (without mapping to components) + """ + + recheck_issues(obj=host) # only host itself is directly affected + + # propagate issues from provider only to this host + for issue_cause in _prototype_issue_map.get(ObjectType.PROVIDER, []): + add_concern_to_object(object_=host, concern=host.provider.get_own_issue(cause=issue_cause)) + + +def process_config_issues_policies_hc(host: Host) -> None: + obj_conf = init_object_config(proto=host.prototype, obj=host) + host.config = obj_conf + host.save(update_fields=["config"]) + + add_concern_to_object(object_=host, concern=CTX.lock) + _recheck_new_host_issues(host=host) + re_apply_object_policy(apply_object=host.provider) + + if cluster := host.cluster: + re_apply_object_policy(apply_object=cluster) reset_hc_map() - logger.info("host #%s %s is added", host.pk, host.fqdn) + if cluster: logger.info("host #%s %s is added to cluster #%s %s", host.pk, host.fqdn, cluster.pk, cluster.name) - - return host + else: + logger.info("host #%s %s is added", host.pk, host.fqdn) def maintenance_mode(request: Request, host: Host) -> Response: diff --git a/python/api_v2/host/views.py b/python/api_v2/host/views.py index 31276e9f35..1213c25da9 100644 --- a/python/api_v2/host/views.py +++ b/python/api_v2/host/views.py @@ -33,6 +33,7 @@ HostBelongsToAnotherClusterError, HostDoesNotExistError, ) +from django.db.transaction import atomic from django_filters.rest_framework.backends import DjangoFilterBackend from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from guardian.mixins import PermissionListMixin @@ -68,7 +69,7 @@ HostSerializer, HostUpdateSerializer, ) -from api_v2.host.utils import add_new_host_and_map_it, maintenance_mode +from api_v2.host.utils import create_host, maintenance_mode, process_config_issues_policies_hc from api_v2.views import ( CamelCaseModelViewSet, CamelCaseReadOnlyModelViewSet, @@ -214,15 +215,18 @@ def create(self, request, *args, **kwargs): # noqa: ARG002 klass=HostProvider, id=serializer.validated_data["hostprovider_id"], ) + request_cluster = None if serializer.validated_data.get("cluster_id"): request_cluster = get_object_for_user( user=request.user, perms=VIEW_CLUSTER_PERM, klass=Cluster, id=serializer.validated_data["cluster_id"] ) - host = add_new_host_and_map_it( - provider=request_hostprovider, fqdn=serializer.validated_data["fqdn"], cluster=request_cluster - ) + with atomic(): + host = create_host( + provider=request_hostprovider, fqdn=serializer.validated_data["fqdn"], cluster=request_cluster + ) + process_config_issues_policies_hc(host=host) return Response( data=HostSerializer(instance=host, context=self.get_serializer_context()).data, status=HTTP_201_CREATED diff --git a/python/api_v2/tests/test_cluster.py b/python/api_v2/tests/test_cluster.py index f96172d498..74b89310a9 100644 --- a/python/api_v2/tests/test_cluster.py +++ b/python/api_v2/tests/test_cluster.py @@ -18,7 +18,6 @@ ADCMEntityStatus, Cluster, ClusterObject, - Host, Prototype, ServiceComponent, ) @@ -496,16 +495,8 @@ def setUp(self) -> None: cluster=self.cluster_2, service=self.service_2, ) - self.host_1 = Host.objects.create( - fqdn="test-host", - prototype=Prototype.objects.create(bundle=self.bundle_1, type="host"), - ) - self.host_2 = Host.objects.create( - fqdn="test-host-2", - prototype=Prototype.objects.create(bundle=self.bundle_2, type="host"), - ) - self.add_host_to_cluster(cluster=self.cluster_1, host=self.host_1) - self.add_host_to_cluster(cluster=self.cluster_2, host=self.host_2) + self.host_1 = self.add_host(provider=self.provider, fqdn="test-host", cluster=self.cluster_1) + self.host_2 = self.add_host(provider=self.provider, fqdn="test-host-2", cluster=self.cluster_2) self.test_user_credentials = {"username": "test_user_username", "password": "test_user_password"} self.test_user = User.objects.create_user(**self.test_user_credentials) diff --git a/python/rbac/tests/test_role.py b/python/rbac/tests/test_role.py index 38951a3ef0..551d35f743 100644 --- a/python/rbac/tests/test_role.py +++ b/python/rbac/tests/test_role.py @@ -14,8 +14,7 @@ import hashlib from adcm.permissions import check_custom_perm -from adcm.tests.base import APPLICATION_JSON, BaseTestCase -from cm.api import add_host_to_cluster +from adcm.tests.base import APPLICATION_JSON, BaseTestCase, BusinessLogicMixin from cm.errors import AdcmEx from cm.models import ( Action, @@ -23,7 +22,6 @@ Bundle, Cluster, ClusterObject, - Host, HostProvider, ProductCategory, Prototype, @@ -557,7 +555,7 @@ def check_roles(self): self.assertEqual(sa_role_count, 6, "Roles missing from base roles") -class TestMMRoles(RBACBaseTestCase): +class TestMMRoles(RBACBaseTestCase, BusinessLogicMixin): def setUp(self) -> None: super().setUp() @@ -568,8 +566,7 @@ def setUp(self) -> None: name="test_provider", prototype=self.provider_prototype, ) - self.host = Host.objects.create(fqdn="testhost", prototype=self.host_prototype) - add_host_to_cluster(self.cluster, self.host) + self.host = self.add_host(provider=self.provider, fqdn="testhost", cluster=self.cluster) self.service = ClusterObject.objects.create(cluster=self.cluster, prototype=self.sp_1) self.component = ServiceComponent.objects.create( cluster=self.cluster, From c500c2703bd5506cdc2a6b5f36b122a8b5a12bbd Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Wed, 19 Jun 2024 11:32:57 +0000 Subject: [PATCH 184/208] ADCM-5685 Convert `ObjectDoesNotExist` exceptions to readable `PluginTargetDetectionError` --- python/ansible_plugin/base.py | 29 +++++++++++++++++-- .../tests/test_targets_extraction.py | 18 ++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/python/ansible_plugin/base.py b/python/ansible_plugin/base.py index d3d04cf9a1..c944283c20 100644 --- a/python/ansible_plugin/base.py +++ b/python/ansible_plugin/base.py @@ -12,7 +12,8 @@ from abc import abstractmethod from dataclasses import dataclass -from typing import Any, Collection, Generic, Literal, Mapping, Protocol, TypeAlias, TypeVar +from functools import wraps +from typing import Any, Callable, Collection, Generic, Literal, Mapping, ParamSpec, Protocol, TypeAlias, TypeVar import fcntl import traceback @@ -40,6 +41,9 @@ compose_validation_error_details_message, ) +P = ParamSpec("P") +T = TypeVar("T") + class BaseStrictModel(BaseModel): model_config = ConfigDict(extra="forbid") @@ -140,7 +144,7 @@ def validate_args_allowed_for_type(self) -> Self: return self def __str__(self) -> str: - return ", ".join(f"{key}='value'" for key, value in self.model_dump(exclude_none=True)) + return ", ".join(f"{key}='{value}'" for key, value in self.model_dump(exclude_none=True).items()) class BaseTypedArguments(CoreObjectTargetDescription): @@ -194,6 +198,27 @@ def from_context( return (context_owner,) +def raise_not_found_as_target_detection_error(func: Callable[P, T]) -> Callable[P, T]: + @wraps(func) + def wrapped(target_description: CoreObjectTargetDescription, context: VarsContextSection) -> CoreObjectDescriptor: + try: + return func(target_description=target_description, context=context) + except ObjectDoesNotExist: + parameters = ", ".join( + f"{key}={value}" + for key, value in target_description.model_dump(exclude_unset=True, exclude_none=True).items() + ) + message = ( + "Target detection has failed due to absence of object in ADCM DB.\n" + f"Parameters used for object search: {parameters}.\n" + 'Ensure objects requested with "*_name" parameters exist.' + ) + raise PluginTargetDetectionError(message=message) from None + + return wrapped + + +@raise_not_found_as_target_detection_error def _from_target_description( target_description: CoreObjectTargetDescription, context: VarsContextSection ) -> CoreObjectDescriptor: diff --git a/python/ansible_plugin/tests/test_targets_extraction.py b/python/ansible_plugin/tests/test_targets_extraction.py index ed5964df30..0e128abfc4 100644 --- a/python/ansible_plugin/tests/test_targets_extraction.py +++ b/python/ansible_plugin/tests/test_targets_extraction.py @@ -26,6 +26,7 @@ TargetConfig, from_objects, ) +from ansible_plugin.errors import PluginTargetDetectionError class EmptyArguments(BaseArgumentsWithTypedObjects): @@ -216,6 +217,23 @@ def test_component_host_action_context_success(self): ], ) + def test_adcm_5685_non_existent_target_detection(self): + arguments = {"objects": [{"type": "component", "component_name": "component_not_exist"}]} + + parent_cluster = self.cluster_2 + context_service = ClusterObject.objects.get(prototype__name="service_2", cluster=parent_cluster) + + task = self.prepare_task(owner=context_service, name="dummy") + job, *_ = JobRepoImpl.get_task_jobs(task.id) + + executor = self.prepare_executor( + executor_type=self.targets_from_objects_executor, call_arguments=arguments, call_context=job + ) + + result = executor.execute() + self.assertIsInstance(result.error, PluginTargetDetectionError) + self.assertIn('Ensure objects requested with "*_name" parameters exist.', result.error.message) + def check_target_detection( self, task: Task, arguments: dict | str, expected_targets: list[CoreObjectDescriptor] ) -> None: From 0513ea64bdc9d05bd62bfeed9f2d86ef2aeb0f45 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Wed, 19 Jun 2024 11:35:31 +0000 Subject: [PATCH 185/208] ADCM-5683 Fix error returned on incorrect ansible config change --- python/api_v2/cluster/serializers.py | 29 +++++++++++++------ .../api_v2/tests/test_audit/test_cluster.py | 18 ++++++------ python/api_v2/tests/test_cluster.py | 2 +- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/python/api_v2/cluster/serializers.py b/python/api_v2/cluster/serializers.py index 7ff972ccc7..146c75edf7 100644 --- a/python/api_v2/cluster/serializers.py +++ b/python/api_v2/cluster/serializers.py @@ -12,6 +12,7 @@ from adcm.serializers import EmptySerializer from cm.adcm_config.config import get_main_info +from cm.errors import AdcmEx from cm.models import ( AnsibleConfig, Cluster, @@ -26,12 +27,8 @@ from django.conf import settings from drf_spectacular.utils import extend_schema_field from rest_framework.fields import CharField, DictField, IntegerField -from rest_framework.serializers import ( - BooleanField, - ModelSerializer, - SerializerMethodField, - ValidationError, -) +from rest_framework.serializers import BooleanField, ModelSerializer, SerializerMethodField +from rest_framework.status import HTTP_409_CONFLICT from api_v2.cluster.utils import get_depend_on from api_v2.concern.serializers import ConcernSerializer @@ -204,16 +201,30 @@ class AnsibleConfigUpdateSerializer(EmptySerializer): @staticmethod def validate_config(value: dict) -> dict: + # required to raise here AdcmEx directly, + # because subclassing ValidationError with status_code override is no help + # because Serializer handler will raise vanila ValidationError anyway + if set(value) != {"defaults"}: - raise ValidationError("Only `defaults` section can be modified") + raise AdcmEx( + code="CONFIG_KEY_ERROR", msg="Only `defaults` section can be modified", http_code=HTTP_409_CONFLICT + ) defaults = value["defaults"] if set(defaults or ()) != {"forks"}: - raise ValidationError("Only `defaults.forks` parameter can be modified") + raise AdcmEx( + code="CONFIG_KEY_ERROR", + msg="Only `defaults.forks` parameter can be modified", + http_code=HTTP_409_CONFLICT, + ) if not isinstance(defaults["forks"], int) or defaults["forks"] < 1: - raise ValidationError("`defaults.forks` parameter must be an integer greater than 0") + raise AdcmEx( + code="CONFIG_VALUE_ERROR", + msg="`defaults.forks` parameter must be an integer greater than 0", + http_code=HTTP_409_CONFLICT, + ) defaults["forks"] = str(defaults["forks"]) value["defaults"] = defaults diff --git a/python/api_v2/tests/test_audit/test_cluster.py b/python/api_v2/tests/test_audit/test_cluster.py index 2eee4a998b..10263d84f8 100644 --- a/python/api_v2/tests/test_audit/test_cluster.py +++ b/python/api_v2/tests/test_audit/test_cluster.py @@ -1379,18 +1379,18 @@ def test_update_ansible_config_not_found_fail(self): def test_update_ansible_config_wrong_request_fail(self): wrong_requests = ( - {"config": {"defaults": {"forks": -1}}}, - {"config": {"defaults": {"forks": 0}}}, - {"config": 1}, - {"config": {"defaults": None}}, - {}, - {"config": {"defaults": {"forks": "wrong format"}}}, - ) - for request_data in wrong_requests: + ({"config": {"defaults": {"forks": -1}}}, HTTP_409_CONFLICT), + ({"config": {"defaults": {"forks": 0}}}, HTTP_409_CONFLICT), + ({"config": 1}, HTTP_400_BAD_REQUEST), + ({"config": {"defaults": None}}, HTTP_409_CONFLICT), + ({}, HTTP_400_BAD_REQUEST), + ({"config": {"defaults": {"forks": "wrong format"}}}, HTTP_409_CONFLICT), + ) + for request_data, expected_code in wrong_requests: with self.subTest(f"incorrect request: {request_data}"): response = self.client.v2[self.cluster_1, "ansible-config"].post(data=request_data) - self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, expected_code) self.check_last_audit_record( operation_name="Ansible configuration updated", diff --git a/python/api_v2/tests/test_cluster.py b/python/api_v2/tests/test_cluster.py index fc1a9d22e8..1c4669dc57 100644 --- a/python/api_v2/tests/test_cluster.py +++ b/python/api_v2/tests/test_cluster.py @@ -443,7 +443,7 @@ def test_update_ansible_config_fail(self): with self.subTest(value=value): response = self.client.v2[self.cluster_1, "ansible-config"].post(data={"config": value}) - self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, HTTP_409_CONFLICT) ansible_config.refresh_from_db() self.assertDictEqual(ansible_config.value, {"defaults": {"forks": "5"}}) From 14b2a1f6c6f3453170b3b9eceb5274c9cae11410 Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Wed, 19 Jun 2024 13:24:53 +0000 Subject: [PATCH 186/208] ADCM-5677: update issues on host to cluster map; add test --- python/api_v2/tests/test_concerns.py | 96 ++++++++++++++++++++++++++++ python/cm/api.py | 1 + python/cm/services/cluster.py | 7 +- python/cm/tests/test_issue.py | 8 +-- 4 files changed, 106 insertions(+), 6 deletions(-) diff --git a/python/api_v2/tests/test_concerns.py b/python/api_v2/tests/test_concerns.py index ecbde5e895..213bfe7748 100644 --- a/python/api_v2/tests/test_concerns.py +++ b/python/api_v2/tests/test_concerns.py @@ -16,6 +16,7 @@ ObjectType, Prototype, PrototypeImport, + ServiceComponent, ) from cm.services.concern.messages import ConcernMessage from cm.tests.mocks.task_runner import RunTaskMock @@ -210,6 +211,12 @@ def setUp(self) -> None: bundle_dir = self.test_bundles_dir / "cluster_with_service_requirements" self.service_requirements_bundle = self.add_bundle(source_dir=bundle_dir) + bundle_dir = self.test_bundles_dir / "hc_mapping_constraints" + self.hc_mapping_constraints_bundle = self.add_bundle(source_dir=bundle_dir) + + bundle_dir = self.test_bundles_dir / "provider_no_config" + self.provider_no_config_bundle = self.add_bundle(source_dir=bundle_dir) + def test_import_concern_resolved_after_saving_import(self): import_cluster = self.add_cluster(bundle=self.required_import_bundle, name="required_import_cluster") export_cluster = self.cluster_1 @@ -249,3 +256,92 @@ def test_concern_owner_service(self): self.assertEqual(len(response.json()["concerns"]), 1) self.assertEqual(response.json()["concerns"][0]["owner"]["id"], service.pk) self.assertEqual(response.json()["concerns"][0]["owner"]["type"], "service") + + def test_adcm_5677_hc_issue_on_link_host_to_cluster_with_plus_constraint(self): + cluster = self.add_cluster(bundle=self.hc_mapping_constraints_bundle, name="hc_mapping_constraints_cluster") + service = self.add_services_to_cluster( + service_names=["service_with_plus_component_constraint"], cluster=cluster + ).get() + component = ServiceComponent.objects.get(prototype__name="plus", service=service, cluster=cluster) + + expected_concern_part = { + "type": "issue", + "reason": { + "message": "${source} has an issue with host-component mapping", + "placeholder": { + "source": { + "type": "cluster_mapping", + "name": "hc_mapping_constraints_cluster", + "params": {"clusterId": cluster.pk}, + } + }, + }, + "isBlocking": True, + "cause": "host-component", + "owner": {"id": cluster.pk, "type": "cluster"}, + } + + # initial hc concern (from component's constraint) + response: Response = self.client.v2[cluster].get() + self.assertEqual(len(response.json()["concerns"]), 1) + actual_concern = response.json()["concerns"][0] + del actual_concern["id"] + self.assertDictEqual(actual_concern, expected_concern_part) + + # add host to cluster and map it to `plus` component. Should be no concerns + provider = self.add_provider(bundle=self.provider_no_config_bundle, name="provider_no_config") + host_1 = self.add_host(provider=provider, fqdn="host_1", cluster=cluster) + self.set_hostcomponent(cluster=cluster, entries=((host_1, component),)) + + response: Response = self.client.v2[cluster].get() + self.assertEqual(len(response.json()["concerns"]), 0) + + response: Response = self.client.v2[host_1].get() + self.assertEqual(len(response.json()["concerns"]), 0) + + # add second host to cluster. Concerns should be on cluster and mapped host (host_1) + host_2 = self.add_host(provider=provider, fqdn="host_2", cluster=cluster) + + response: Response = self.client.v2[cluster].get() + self.assertEqual(len(response.json()["concerns"]), 1) + actual_concern = response.json()["concerns"][0] + del actual_concern["id"] + self.assertDictEqual(actual_concern, expected_concern_part) + + response: Response = self.client.v2[host_1].get() + self.assertEqual(len(response.json()["concerns"]), 1) + actual_concern = response.json()["concerns"][0] + del actual_concern["id"] + self.assertDictEqual(actual_concern, expected_concern_part) + + # not mapped host has no concerns + response: Response = self.client.v2[host_2].get() + self.assertEqual(len(response.json()["concerns"]), 0) + + # unlink host_2 from cluster, 0 concerns on cluster and host_1 + response: Response = self.client.v2[cluster, "hosts", str(host_2.pk)].delete() + + response: Response = self.client.v2[cluster].get() + self.assertEqual(len(response.json()["concerns"]), 0) + + response: Response = self.client.v2[host_1].get() + self.assertEqual(len(response.json()["concerns"]), 0) + + # link host_2 to cluster. Concerns should appear again + response: Response = self.client.v2[cluster, "hosts"].post(data={"hostId": host_2.pk}) + + response: Response = self.client.v2[cluster].get() + self.assertEqual(len(response.json()["concerns"]), 1) + actual_concern = response.json()["concerns"][0] + del actual_concern["id"] + self.assertDictEqual(actual_concern, expected_concern_part) + + response: Response = self.client.v2[host_1].get() + self.assertEqual(len(response.json()["concerns"]), 1) + actual_concern = response.json()["concerns"][0] + del actual_concern["id"] + self.assertDictEqual(actual_concern, expected_concern_part) + + # not mapped host has no concerns + response: Response = self.client.v2[host_2].get() + self.assertEqual(len(response.json()["concerns"]), 0) diff --git a/python/cm/api.py b/python/cm/api.py index 5317394a9d..ba9deddb90 100644 --- a/python/cm/api.py +++ b/python/cm/api.py @@ -918,6 +918,7 @@ def add_host_to_cluster(cluster: Cluster, host: Host) -> Host: host.save() add_concern_to_object(object_=host, concern=CTX.lock) update_hierarchy_issues(host) + update_hierarchy_issues(cluster) re_apply_object_policy(cluster) reset_hc_map() diff --git a/python/cm/services/cluster.py b/python/cm/services/cluster.py index f6173f3296..8821de0190 100644 --- a/python/cm/services/cluster.py +++ b/python/cm/services/cluster.py @@ -97,10 +97,13 @@ def reset_hc_map(self) -> None: def perform_host_to_cluster_map( cluster_id: int, hosts: Collection[int], status_service: _StatusServerService ) -> Collection[int]: + from cm.issue import update_hierarchy_issues # avoiding circular imports + with atomic(): add_hosts_to_cluster(cluster_id=cluster_id, hosts=hosts, db=ClusterDB) - - re_apply_object_policy(Cluster.objects.get(id=cluster_id)) + cluster = Cluster.objects.get(id=cluster_id) + update_hierarchy_issues(obj=cluster) + re_apply_object_policy(apply_object=cluster) status_service.reset_hc_map() diff --git a/python/cm/tests/test_issue.py b/python/cm/tests/test_issue.py index 5ffbc9fa3f..e5efb59c6f 100644 --- a/python/cm/tests/test_issue.py +++ b/python/cm/tests/test_issue.py @@ -317,12 +317,12 @@ def test_issue_service_imported(self): class TestConcernsRedistribution(BaseTestCase): - MOCK_ISSUE_CHECK_MAP_ALL_FALSE = { + MOCK_ISSUE_CHECK_MAP_FOR_HOST_TO_CLUSTER_MAPPING = { ConcernCause.CONFIG: lambda x: False, ConcernCause.IMPORT: lambda x: False, - ConcernCause.SERVICE: lambda x: False, + ConcernCause.SERVICE: lambda x: True, ConcernCause.HOSTCOMPONENT: lambda x: False, - ConcernCause.REQUIREMENT: lambda x: False, + ConcernCause.REQUIREMENT: lambda x: True, } def setUp(self) -> None: @@ -358,7 +358,7 @@ def test_map_host_to_cluster(self) -> None: {self.hostprovider.content_type, self.host.content_type}, ) - with patch("cm.issue._issue_check_map", self.MOCK_ISSUE_CHECK_MAP_ALL_FALSE): + with patch("cm.issue._issue_check_map", self.MOCK_ISSUE_CHECK_MAP_FOR_HOST_TO_CLUSTER_MAPPING): perform_host_to_cluster_map(self.cluster.id, [self.host.id], status_service=notify) self.host.refresh_from_db() From c4f6630b6b07427094826869129f6fa47fc6b275 Mon Sep 17 00:00:00 2001 From: Kirill Fedorenko Date: Thu, 20 Jun 2024 14:49:01 +0000 Subject: [PATCH 187/208] ADCM-5673 [UI] Fix path to the hosts page from the cluster overview page https://tracker.yandex.ru/ADCM-5673 --- .../ClusterOverviewHosts/ClusterOverviewHostsTable.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/adcm-web/app/src/components/pages/cluster/ClusterOverview/ClusterOverviewHosts/ClusterOverviewHostsTable.tsx b/adcm-web/app/src/components/pages/cluster/ClusterOverview/ClusterOverviewHosts/ClusterOverviewHostsTable.tsx index e48656c9f7..ec42cce2c7 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterOverview/ClusterOverviewHosts/ClusterOverviewHostsTable.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterOverview/ClusterOverviewHosts/ClusterOverviewHostsTable.tsx @@ -1,13 +1,16 @@ import { Statusable, Table, TableCell, TableRow, Tooltip } from '@uikit'; import { AdcmClusterOverviewStatusHost, AdcmClusterStatus } from '@models/adcm'; import s from './ClusterOverviewHosts.module.scss'; -import { Link } from 'react-router-dom'; +import { Link, useParams } from 'react-router-dom'; interface clusterOverviewHostsTableProps { hosts: AdcmClusterOverviewStatusHost[]; } const ClusterOverviewHostsTable = ({ hosts }: clusterOverviewHostsTableProps) => { + const { clusterId: clusterIdFromUrl } = useParams(); + const clusterId = Number(clusterIdFromUrl); + return ( {hosts.map((host) => { @@ -18,7 +21,9 @@ const ClusterOverviewHostsTable = ({ hosts }: clusterOverviewHostsTableProps) => - {host.name} + + {host.name} + From c404235f754d533afe894e2f07406ea78d9db830 Mon Sep 17 00:00:00 2001 From: Egor Araslanov Date: Fri, 21 Jun 2024 09:09:00 +0500 Subject: [PATCH 188/208] ADCM-5688 Allow creation of `ActionHostGroup` via API without `description` field --- python/api_v2/action_host_group/serializers.py | 4 ++-- python/api_v2/tests/test_action_host_group.py | 13 +++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/python/api_v2/action_host_group/serializers.py b/python/api_v2/action_host_group/serializers.py index 871cd137f3..0aaa79d12d 100644 --- a/python/api_v2/action_host_group/serializers.py +++ b/python/api_v2/action_host_group/serializers.py @@ -30,7 +30,7 @@ class AddHostSerializer(EmptySerializer): class ActionHostGroupCreateSerializer(EmptySerializer): name = CharField(max_length=150) - description = CharField(max_length=255, allow_blank=True) + description = CharField(max_length=255, allow_blank=True, default="") class ActionHostGroupSerializer(ModelSerializer): @@ -39,7 +39,7 @@ class ActionHostGroupSerializer(ModelSerializer): description = CharField(max_length=255) hosts = SerializerMethodField() - @extend_schema_field(field=ShortHostSerializer(many=UnicodeTranslateError)) + @extend_schema_field(field=ShortHostSerializer(many=True)) def get_hosts(self, group: ActionHostGroup) -> list: # NOTE: # Here we return "unpaginated" list of hosts, so if there will be lots of them, there may be problems with: diff --git a/python/api_v2/tests/test_action_host_group.py b/python/api_v2/tests/test_action_host_group.py index 5966c645cb..b98bbb707b 100644 --- a/python/api_v2/tests/test_action_host_group.py +++ b/python/api_v2/tests/test_action_host_group.py @@ -83,7 +83,7 @@ def test_create_group_success(self) -> None: response = self.client.v2[target, ACTION_HOST_GROUPS].post(data=data) - with self.subTest(f"[{type_.name}] CREATE SUCCESS"): + with self.subTest(f"[{type_.name}] CREATED"): self.assertEqual(response.status_code, HTTP_201_CREATED) self.assertEqual(ActionHostGroup.objects.count(), group_counter) created_group = ActionHostGroup.objects.filter( @@ -92,7 +92,7 @@ def test_create_group_success(self) -> None: self.assertIsNotNone(created_group) self.assertEqual(response.json(), {"id": created_group.id, **data, "hosts": []}) - with self.subTest(f"[{type_.name}] CREATE AUDITED"): + with self.subTest(f"[{type_.name}] AUDITED"): self.check_last_audit_record( operation_name=f"{data['name']} action host group created", operation_type="create", @@ -100,6 +100,15 @@ def test_create_group_success(self) -> None: **self.prepare_audit_object_arguments(expected_object=target), ) + with self.subTest("[SERVICE] Without Description"): + another_name = "whoah" + response = self.client.v2[self.service, ACTION_HOST_GROUPS].post(data={"name": another_name}) + + self.assertEqual(response.status_code, HTTP_201_CREATED) + data = response.json() + self.assertEqual(data["name"], another_name) + self.assertEqual(data["description"], "") + def test_create_multiple_groups(self) -> None: with self.subTest("[COMPONENT] Same Object + Different Names SUCCESS"): endpoint = self.client.v2[self.component, ACTION_HOST_GROUPS] From c0ffdbbd3ea28036f96e2917b693cbcccc910c7d Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Fri, 21 Jun 2024 07:51:33 +0000 Subject: [PATCH 189/208] ADCM-5687: Add host filtering by components --- python/api_v2/host/filters.py | 11 +++++++++-- python/api_v2/host/views.py | 1 + python/api_v2/tests/test_host.py | 26 ++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/python/api_v2/host/filters.py b/python/api_v2/host/filters.py index 78c8674476..d91a78c6d4 100644 --- a/python/api_v2/host/filters.py +++ b/python/api_v2/host/filters.py @@ -11,7 +11,13 @@ # limitations under the License. from cm.models import Host -from django_filters.rest_framework import BooleanFilter, CharFilter, FilterSet, OrderingFilter +from django_filters.rest_framework import ( + BooleanFilter, + CharFilter, + FilterSet, + NumberFilter, + OrderingFilter, +) class HostFilter(FilterSet): @@ -35,10 +41,11 @@ def filter_is_in_cluster(queryset, _, value): class HostClusterFilter(FilterSet): name = CharFilter(label="Host name", field_name="fqdn", lookup_expr="icontains") hostprovider_name = CharFilter(label="Hostprovider name", field_name="provider__name") + component_id = NumberFilter(label="Component id", field_name="hostcomponent__component_id") ordering = OrderingFilter( fields={"fqdn": "name", "id": "id"}, field_labels={"name": "Name", "id": "Id"}, label="ordering" ) class Meta: model = Host - fields = ["name", "hostprovider_name", "ordering"] + fields = ["name", "hostprovider_name", "component_id", "ordering"] diff --git a/python/api_v2/host/views.py b/python/api_v2/host/views.py index 1213c25da9..3350688967 100644 --- a/python/api_v2/host/views.py +++ b/python/api_v2/host/views.py @@ -277,6 +277,7 @@ def maintenance_mode(self, request: Request, *args, **kwargs) -> Response: # no summary="GET cluster hosts", parameters=[ OpenApiParameter(name="name", description="Case insensitive and partial filter by host name."), + OpenApiParameter(name="componentId", description="Id of component."), DefaultParams.LIMIT, DefaultParams.OFFSET, OpenApiParameter( diff --git a/python/api_v2/tests/test_host.py b/python/api_v2/tests/test_host.py index 1056cc595b..26ce6ab552 100644 --- a/python/api_v2/tests/test_host.py +++ b/python/api_v2/tests/test_host.py @@ -513,6 +513,32 @@ def test_ordering_by_id_desc_success(self): [host["id"] for host in response.json()["results"]], ) + def test_adcm_5687_filtering_by_component_id(self): + service = self.add_services_to_cluster(service_names=["service_1"], cluster=self.cluster_1).get() + component_1 = service.servicecomponent_set.get(prototype__name="component_1") + component_2 = service.servicecomponent_set.get(prototype__name="component_2") + + self.add_host_to_cluster(cluster=self.cluster_1, host=self.host) + self.add_host_to_cluster(cluster=self.cluster_1, host=self.host_2) + + self.set_hostcomponent( + cluster=self.cluster_1, + entries=((self.host, component_1), (self.host_2, component_2)), + ) + + for query, expected_ids in ( + ({"componentId": component_1.pk}, {self.host.pk}), + ({"componentId": component_2.pk}, {self.host_2.pk}), + (None, {self.host.pk, self.host_2.pk, self.control_host_same_cluster.pk}), + ({"componentId": self.get_non_existent_pk(model=ServiceComponent)}, set()), + ): + with self.subTest(query=query, expected_ids=expected_ids): + response = self.client.v2[self.cluster_1, "hosts"].get(query=query) + self.assertEqual(response.status_code, HTTP_200_OK) + + host_ids = {host["id"] for host in response.json()["results"]} + self.assertSetEqual(host_ids, expected_ids) + class TestHostActions(BaseAPITestCase): def setUp(self) -> None: From efacb8593ef1ecf808d4fe99bf966fbfaefbf638 Mon Sep 17 00:00:00 2001 From: Daniil Skrynnik Date: Fri, 21 Jun 2024 09:04:37 +0000 Subject: [PATCH 190/208] Statistics: fix data format --- python/cm/management/commands/collect_statistics.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/cm/management/commands/collect_statistics.py b/python/cm/management/commands/collect_statistics.py index 9ae904d310..01b354d0ed 100644 --- a/python/cm/management/commands/collect_statistics.py +++ b/python/cm/management/commands/collect_statistics.py @@ -107,7 +107,7 @@ def handle(self, *_, mode: str, **__): "version": settings.ADCM_VERSION, "is_internal": is_internal(), }, - "format_version": "0.2", + "format_version": 0.2, } logger.debug(msg="Statistics collector: RBAC data preparation") rbac_entries_data: dict = RBACCollector(date_format=DATE_TIME_FORMAT)().model_dump() @@ -128,7 +128,7 @@ def handle(self, *_, mode: str, **__): storage.add( JSONFile( filename=f"{timezone.now().strftime(DATE_FORMAT)}_statistics.json", - data={**statistics_data, **rbac_entries_data, **bundle_data.model_dump()}, + data={**statistics_data, "data": {**rbac_entries_data, **bundle_data.model_dump()}}, ) ) logger.debug(msg="Statistics collector: archive preparation") @@ -153,7 +153,7 @@ def handle(self, *_, mode: str, **__): storage.add( JSONFile( filename=f"{timezone.now().strftime(DATE_FORMAT)}_statistics.json", - data={**statistics_data, **rbac_entries_data, **bundle_data.model_dump()}, + data={**statistics_data, "data": {**rbac_entries_data, **bundle_data.model_dump()}}, ) ) logger.debug(msg="Statistics collector: archive preparation") From 294e2942d8a2ce6434e197deb793be979585b79e Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Fri, 21 Jun 2024 09:38:59 +0000 Subject: [PATCH 191/208] ADCM-5685 Add special handling for `AdcmEx` in ansible plugins AND redundant issues recheck removed from cluster's name/desc update --- python/ansible_plugin/base.py | 12 ++++++++++++ python/api_v2/cluster/views.py | 2 -- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/python/ansible_plugin/base.py b/python/ansible_plugin/base.py index c944283c20..e1464ed140 100644 --- a/python/ansible_plugin/base.py +++ b/python/ansible_plugin/base.py @@ -17,6 +17,8 @@ import fcntl import traceback +from cm.errors import AdcmEx + try: # TODO: refactor when python >= 3.11 from typing import Self except ImportError: @@ -464,6 +466,16 @@ def execute(self) -> CallResult[ReturnValue]: ) except ADCMPluginError as err: return CallResult(value={}, changed=False, error=err) + except AdcmEx as err: + message = "\n".join( + ( + f"ADCM operation exception occurred during {self.__class__.__name__} call", + f"Code: {err.code}", + f"Message: {err.msg}", + f"Traceback:\n{traceback.format_exc()}", + ) + ) + return CallResult(value={}, changed=False, error=PluginRuntimeError(message=message)) except Exception as err: # noqa: BLE001 message = "\n".join( ( diff --git a/python/api_v2/cluster/views.py b/python/api_v2/cluster/views.py index 2713c5307e..dad98ee5df 100644 --- a/python/api_v2/cluster/views.py +++ b/python/api_v2/cluster/views.py @@ -21,7 +21,6 @@ from audit.utils import audit from cm.api import add_cluster, delete_cluster from cm.errors import AdcmEx -from cm.issue import update_hierarchy_issues from cm.models import ( AnsibleConfig, Cluster, @@ -227,7 +226,6 @@ def partial_update(self, request, *args, **kwargs): # noqa: ARG002 instance.name = valid_data.get("name", instance.name) instance.description = valid_data.get("description", instance.description) instance.save(update_fields=["name", "description"]) - update_hierarchy_issues(obj=instance) return Response( status=HTTP_200_OK, data=ClusterSerializer(instance, context=self.get_serializer_context()).data From 5d70e0ca9a0030796ff82222d5981bce3c450f99 Mon Sep 17 00:00:00 2001 From: Dmitriy Bardin Date: Fri, 21 Jun 2024 20:04:54 +0000 Subject: [PATCH 192/208] ADCM-5670 - [UI] Can't remove hosts in Remove NodeManagers modal window https://tracker.yandex.ru/ADCM-5670 --- .../pages/cluster/ClusterMapping/ClusterMapping.utils.ts | 7 ++++--- .../ComponentContainer/ComponentContainer.tsx | 8 +------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ClusterMapping.utils.ts b/adcm-web/app/src/components/pages/cluster/ClusterMapping/ClusterMapping.utils.ts index 48062f1c91..4754848aea 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ClusterMapping.utils.ts +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/ClusterMapping.utils.ts @@ -388,9 +388,10 @@ export const checkComponentMappingAvailability = ( component.service.state === AdcmEntitySystemState.Created && component.maintenanceMode === AdcmMaintenanceMode.Off; const result: ComponentAvailabilityErrors = { - componentNotAvailableError: !isAvailable - ? 'Service of this component must have "Created" state. Maintenance mode on the components must be Off' - : undefined, + componentNotAvailableError: + !isAvailable && !allowActions + ? 'Service of this component must have "Created" state. Maintenance mode on the components must be Off' + : undefined, }; if (allowActions) { diff --git a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/ComponentContainer/ComponentContainer.tsx b/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/ComponentContainer/ComponentContainer.tsx index 77546f3fd5..998956938a 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/ComponentContainer/ComponentContainer.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterMapping/ComponentsMapping/ComponentContainer/ComponentContainer.tsx @@ -33,11 +33,6 @@ export interface ComponentContainerProps { denyRemoveHostReason?: React.ReactNode; } -const defaultAllowActions = new Set([ - AdcmHostComponentMapRuleAction.Add, - AdcmHostComponentMapRuleAction.Remove, -]); - const ComponentContainer = ({ componentMapping, mappingErrors, @@ -47,12 +42,11 @@ const ComponentContainer = ({ onUnmap, onMap, onInstallServices, - allowActions = defaultAllowActions, + allowActions, }: ComponentContainerProps) => { const [isSelectOpen, setIsSelectOpen] = useState(false); const addIconRef = useRef(null); const { component, hosts } = componentMapping; - const { componentNotAvailableError, addingHostsNotAllowedError } = checkComponentMappingAvailability( component, allowActions, From fecf02daf8de13b9841f17abaa3dfabb557cf35d Mon Sep 17 00:00:00 2001 From: Egor Araslanov Date: Mon, 24 Jun 2024 14:44:07 +0500 Subject: [PATCH 193/208] ADCM-5678 Fix migration that adjusts filepaths --- python/cm/migrations/0120_adjust_paths.py | 2 +- python/cm/tests/test_migrations/test_0120.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/python/cm/migrations/0120_adjust_paths.py b/python/cm/migrations/0120_adjust_paths.py index 8b614758de..83f6024740 100644 --- a/python/cm/migrations/0120_adjust_paths.py +++ b/python/cm/migrations/0120_adjust_paths.py @@ -43,7 +43,7 @@ def adjust_paths(apps, schema_editor): objects_to_update = [] for entry in PrototypeConfig.objects.select_related("prototype").filter( - type__in=("text", "secrettext"), default__startswith="./" + type__in=("file", "secretfile"), default__startswith="./" ): entry.default = Path(entry.prototype.path) / entry.default objects_to_update.append(entry) diff --git a/python/cm/tests/test_migrations/test_0120.py b/python/cm/tests/test_migrations/test_0120.py index f82d491658..e9bb981c28 100644 --- a/python/cm/tests/test_migrations/test_0120.py +++ b/python/cm/tests/test_migrations/test_0120.py @@ -36,28 +36,28 @@ def prepare(self): ) PrototypeConfig.objects.create( - prototype=self.prototype_1, name="relative_root", type="text", default="./some.txt" + prototype=self.prototype_1, name="relative_root", type="file", default="./some.txt" ) - PrototypeConfig.objects.create(prototype=self.prototype_1, name="full_root", type="text", default="some.txt") + PrototypeConfig.objects.create(prototype=self.prototype_1, name="full_root", type="file", default="some.txt") PrototypeConfig.objects.create( prototype=self.prototype_1, name="control_root", type="string", default="./some.txt" ) PrototypeConfig.objects.create( - prototype=self.prototype_2, name="relative_inner", type="text", default="./some.txt" + prototype=self.prototype_2, name="relative_inner", type="file", default="./some.txt" ) PrototypeConfig.objects.create( - prototype=self.prototype_2, name="full_inner", type="secrettext", default="some.txt" + prototype=self.prototype_2, name="full_inner", type="secretfile", default="some.txt" ) PrototypeConfig.objects.create( prototype=self.prototype_2, name="control_inner", type="string", default="./some.txt" ) PrototypeConfig.objects.create( - prototype=self.prototype_3, name="relative_more_inner", type="secrettext", default="./some.txt" + prototype=self.prototype_3, name="relative_more_inner", type="secretfile", default="./some.txt" ) PrototypeConfig.objects.create( - prototype=self.prototype_3, name="full_more_inner", type="text", default="some.txt" + prototype=self.prototype_3, name="full_more_inner", type="file", default="some.txt" ) PrototypeConfig.objects.create( prototype=self.prototype_3, name="control_more_inner", type="string", default="./some.txt" @@ -93,7 +93,7 @@ def prepare(self): action=self.action_5, name="relative_inner", script="./over/there.yaml", script_type="ansible" ) - def test_migration_0118_0119_move_data(self): + def test_migration_0119_0120_move_data(self): Action = self.new_state.apps.get_model("cm", "Action") SubAction = self.new_state.apps.get_model("cm", "SubAction") Prototype = self.new_state.apps.get_model("cm", "Prototype") From 79e7beaa2166b2cc87ac5c871015f5e10e3733b1 Mon Sep 17 00:00:00 2001 From: Skrynnik Daniil Date: Mon, 24 Jun 2024 13:01:19 +0300 Subject: [PATCH 194/208] ADCM-5534: add `taskId` field to SS event payload --- go/adcm/status/status.go | 1 + 1 file changed, 1 insertion(+) diff --git a/go/adcm/status/status.go b/go/adcm/status/status.go index 2cd39a059c..5561ae38ea 100644 --- a/go/adcm/status/status.go +++ b/go/adcm/status/status.go @@ -92,6 +92,7 @@ type statusChangeParamsPayload struct { HostId int `json:"hostId,omitempty"` ActionId int `json:"actionId,omitempty"` JobId int `json:"jobId,omitempty"` + TaskId int `json:"taskId,omitempty"` PrototypeId int `json:"prototypeId,omitempty"` } From f0db9c11eb35f69fe9f8a56d89d0bcc493304a01 Mon Sep 17 00:00:00 2001 From: Dmitriy Bardin Date: Mon, 24 Jun 2024 15:37:47 +0000 Subject: [PATCH 195/208] ADCM-5674 - [UI] Show component's hosts on the click "N hosts" of component https://tracker.yandex.ru/ADCM-5674 --- .../ClusterHostsTableFilters.tsx | 27 ++++++++++- .../ClusterHosts/useGetFilterFromUrl.ts | 39 +++++++++++++++ .../usePersistClusterHostsTableSettings.ts | 12 ++++- .../ClusterHosts/useRequestClusterHosts.ts | 5 +- .../ServiceComponentsTable.tsx | 2 +- adcm-web/app/src/models/adcm/clusterHosts.ts | 7 +-- .../adcm/cluster/hosts/hostsTableSlice.ts | 47 ++++++++++++++++--- 7 files changed, 122 insertions(+), 17 deletions(-) create mode 100644 adcm-web/app/src/components/pages/cluster/ClusterHosts/useGetFilterFromUrl.ts diff --git a/adcm-web/app/src/components/pages/cluster/ClusterHosts/ClusterHostsTableToolbar/ClusterHostsTableFilters.tsx b/adcm-web/app/src/components/pages/cluster/ClusterHosts/ClusterHostsTableToolbar/ClusterHostsTableFilters.tsx index c34402738b..d66080d8b2 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterHosts/ClusterHostsTableToolbar/ClusterHostsTableFilters.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterHosts/ClusterHostsTableToolbar/ClusterHostsTableFilters.tsx @@ -9,6 +9,7 @@ const ClusterHostsTableFilters = () => { const filter = useStore(({ adcm }) => adcm.clusterHostsTable.filter); const hostProviders = useStore(({ adcm }) => adcm.clusterHostsTable.relatedData.hostProviders); + const hostComponents = useStore(({ adcm }) => adcm.clusterHostsTable.relatedData.hostComponents); const hostProviderOptions = useMemo(() => { return hostProviders.map(({ name }) => ({ @@ -17,6 +18,13 @@ const ClusterHostsTableFilters = () => { })); }, [hostProviders]); + const hostComponentOptions = useMemo(() => { + return hostComponents.map(({ id, displayName }) => ({ + value: id.toString(), + label: displayName, + })); + }, [hostComponents]); + const handleResetClick = () => { dispatch(resetFilter()); dispatch(resetSortParams()); @@ -27,7 +35,11 @@ const ClusterHostsTableFilters = () => { }; const handleHostProviderChange = (value: string | null) => { - dispatch(setFilter({ hostprovider: value ?? undefined })); + dispatch(setFilter({ hostproviderName: value ?? undefined })); + }; + + const handleHostComponentChange = (value: string | null) => { + dispatch(setFilter({ componentId: value ?? undefined })); }; return ( @@ -42,12 +54,23 @@ const ClusterHostsTableFilters = () => { + + + + + ); +}; + +export default ClusterAnsibleSettingsToolbar; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterAnsibleSettings/useClusterAnsibleSettings.ts b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterAnsibleSettings/useClusterAnsibleSettings.ts new file mode 100644 index 0000000000..09f679d68d --- /dev/null +++ b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterAnsibleSettings/useClusterAnsibleSettings.ts @@ -0,0 +1,70 @@ +import { useDispatch, useStore } from '@hooks'; +import { useCallback, useEffect, useState } from 'react'; +import { + cleanup, + createWithUpdateAnsibleSettings, + getConfiguration, +} from '@store/adcm/entityConfiguration/configurationSlice'; +import type { AdcmConfiguration } from '@models/adcm'; + +export const useClusterAnsibleSettings = () => { + const dispatch = useDispatch(); + const cluster = useStore(({ adcm }) => adcm.cluster.cluster); + const loadedConfiguration = useStore(({ adcm }) => adcm.entityConfiguration.loadedConfiguration); + const isConfigurationLoading = useStore(({ adcm }) => adcm.entityConfiguration.isConfigurationLoading); + const accessCheckStatus = useStore(({ adcm }) => adcm.entityConfiguration.accessCheckStatus); + + const [draftConfiguration, setDraftConfiguration] = useState(null); + + const selectedConfiguration = draftConfiguration || loadedConfiguration; + + useEffect(() => { + return () => { + dispatch(cleanup()); + }; + }, [cluster, dispatch]); + + useEffect(() => { + if (cluster) { + dispatch( + getConfiguration({ + entityType: 'cluster-ansible-settings', + args: { clusterId: cluster.id }, + }), + ); + } + }, [dispatch, cluster]); + + const onReset = useCallback(() => { + setDraftConfiguration(null); + }, [setDraftConfiguration]); + + const onSave = useCallback(() => { + if (cluster?.id) { + dispatch( + createWithUpdateAnsibleSettings({ + entityType: 'cluster-ansible-settings', + args: { + clusterId: cluster.id, + config: { config: selectedConfiguration?.configurationData, adcmMeta: {} }, + }, + }), + ) + .unwrap() + .then(() => { + onReset(); + }); + } + }, [dispatch, cluster, selectedConfiguration, onReset]); + + return { + setDraftConfiguration, + draftConfiguration, + onSave, + onReset, + selectedConfiguration, + isConfigurationLoading, + cluster, + accessCheckStatus, + }; +}; diff --git a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigurationsNavigation/ClusterConfigurationsNavigation.tsx b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigurationsNavigation/ClusterConfigurationsNavigation.tsx index eced6b2ff2..809145e139 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigurationsNavigation/ClusterConfigurationsNavigation.tsx +++ b/adcm-web/app/src/components/pages/cluster/ClusterConfiguration/ClusterConfigurationsNavigation/ClusterConfigurationsNavigation.tsx @@ -9,6 +9,7 @@ const ClusterConfigurationsNavigation: React.FC = () => { Primary configuration Configuration groups + Ansible settings diff --git a/adcm-web/app/src/components/pages/cluster/ClusterHosts/usePersistClusterHostsTableSettings.ts b/adcm-web/app/src/components/pages/cluster/ClusterHosts/usePersistClusterHostsTableSettings.ts index e8098d535b..dd0d296607 100644 --- a/adcm-web/app/src/components/pages/cluster/ClusterHosts/usePersistClusterHostsTableSettings.ts +++ b/adcm-web/app/src/components/pages/cluster/ClusterHosts/usePersistClusterHostsTableSettings.ts @@ -8,7 +8,7 @@ import { setSortParams, } from '@store/adcm/cluster/hosts/hostsTableSlice'; import { mergePaginationParams } from '@hooks/usePersistSettings'; -import { useGetFilterFromUrl } from '@pages/cluster/ClusterHosts/useGetFilterFromUrl.ts'; +import { useGetFilterFromUrl } from '@pages/cluster/ClusterHosts/useGetFilterFromUrl'; const mergeFilters = ( filterFromStorage: AdcmHostsFilter, diff --git a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeContent.tsx b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeContent.tsx index 322684d8c3..66c91e6338 100644 --- a/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeContent.tsx +++ b/adcm-web/app/src/components/uikit/ConfigurationEditor/ConfigurationTree/NodeContent/FieldNodeContent.tsx @@ -10,7 +10,7 @@ import FieldNodeErrors from './FieldNodeErrors/FieldNodeErrors'; import type { ChangeConfigurationNodeHandler, ChangeFieldAttributesHandler } from '../ConfigurationTree.types'; import { isPrimitiveValueSet } from '@models/json'; import type { FieldErrors } from '@models/adcm'; -import { isWhiteSpaceOnly } from '@utils/validationsUtils.ts'; +import { isWhiteSpaceOnly } from '@utils/validationsUtils'; interface FieldNodeContentProps { node: ConfigurationNodeView; diff --git a/adcm-web/app/src/routes/routes.ts b/adcm-web/app/src/routes/routes.ts index 167992e58b..b259d1e637 100644 --- a/adcm-web/app/src/routes/routes.ts +++ b/adcm-web/app/src/routes/routes.ts @@ -375,6 +375,25 @@ const routes: RoutesConfigs = { }, ], }, + '/clusters/:clusterId/configuration/ansible-settings': { + pageTitle: 'Clusters', + breadcrumbs: [ + { + href: '/clusters', + label: 'Clusters', + }, + { + href: '/clusters/:clusterId', + label: ':clusterId', + }, + { + label: 'Configuration', + }, + { + label: 'Ansible settings', + }, + ], + }, '/clusters/:clusterId/configuration/config-groups/:groupId': { pageTitle: 'Clusters', breadcrumbs: [ diff --git a/adcm-web/app/src/store/adcm/cluster/hosts/hostsTableSlice.ts b/adcm-web/app/src/store/adcm/cluster/hosts/hostsTableSlice.ts index 687564b986..ed740d8940 100644 --- a/adcm-web/app/src/store/adcm/cluster/hosts/hostsTableSlice.ts +++ b/adcm-web/app/src/store/adcm/cluster/hosts/hostsTableSlice.ts @@ -3,8 +3,8 @@ import { createAsyncThunk, createListSlice } from '@store/redux'; import type { AdcmClusterHostsFilter } from '@models/adcm/clusterHosts'; import type { AdcmHostProvider, AdcmMappingComponent } from '@models/adcm'; import { AdcmClusterMappingApi, AdcmHostProvidersApi, RequestError } from '@api'; -import { showError } from '@store/notificationsSlice.ts'; -import { getErrorMessage } from '@utils/httpResponseUtils.ts'; +import { showError } from '@store/notificationsSlice'; +import { getErrorMessage } from '@utils/httpResponseUtils'; interface ClusterHostComponentsPayload { clusterId: number; diff --git a/adcm-web/app/src/store/adcm/entityConfiguration/configurationSlice.ts b/adcm-web/app/src/store/adcm/entityConfiguration/configurationSlice.ts index e2c5c34692..7041864e19 100644 --- a/adcm-web/app/src/store/adcm/entityConfiguration/configurationSlice.ts +++ b/adcm-web/app/src/store/adcm/entityConfiguration/configurationSlice.ts @@ -14,6 +14,7 @@ import { } from './entityConfiguration.types'; import { RequestState } from '@models/loadState'; import { processErrorResponse } from '@utils/responseUtils'; +import type { Batch } from '@models/adcm'; type AdcmEntityConfigurationState = { isConfigurationLoading: boolean; @@ -41,7 +42,15 @@ const createWithUpdateConfigurations = createAsyncThunk( 'adcm/entityConfiguration/createWithUpdateConfigurations', async (args: CreateEntityConfigurationArgs, thunkAPI) => { await thunkAPI.dispatch(createConfiguration(args)).unwrap(); - await thunkAPI.dispatch(getConfigurationsVersions(args)); + await thunkAPI.dispatch(getConfigurationsVersions(args as LoadEntityConfigurationVersionsArgs)); + }, +); + +const createWithUpdateAnsibleSettings = createAsyncThunk( + 'adcm/entityConfiguration/createWithUpdateAnsibleSettings', + async (args: CreateEntityConfigurationArgs, thunkAPI) => { + await thunkAPI.dispatch(createConfiguration(args)).unwrap(); + await thunkAPI.dispatch(getConfiguration(args as LoadEntityConfigurationArgs)); }, ); @@ -78,8 +87,8 @@ const getConfigurationsVersions = createAsyncThunk( try { const requests = ApiRequests[entityType]; - const versions = await requests.getConfigVersions(args); - return versions; + const versions = requests.getConfigVersions && (await requests.getConfigVersions(args)); + return versions as Batch; } catch (error) { thunkAPI.dispatch(showError({ message: getErrorMessage(error as RequestError) })); return thunkAPI.rejectWithValue(error); @@ -158,5 +167,11 @@ const entityConfigurationSlice = createSlice({ }); const { cleanup, setIsConfigurationLoading, setIsVersionsLoading } = entityConfigurationSlice.actions; -export { getConfiguration, getConfigurationsVersions, cleanup, createWithUpdateConfigurations }; +export { + getConfiguration, + getConfigurationsVersions, + cleanup, + createWithUpdateConfigurations, + createWithUpdateAnsibleSettings, +}; export default entityConfigurationSlice.reducer; diff --git a/adcm-web/app/src/store/adcm/entityConfiguration/entityConfiguration.constants.ts b/adcm-web/app/src/store/adcm/entityConfiguration/entityConfiguration.constants.ts index 9e6218c3b5..d712d6a503 100644 --- a/adcm-web/app/src/store/adcm/entityConfiguration/entityConfiguration.constants.ts +++ b/adcm-web/app/src/store/adcm/entityConfiguration/entityConfiguration.constants.ts @@ -9,6 +9,7 @@ import { AdcmClusterServiceConfigGroupConfigsApi, AdcmClusterServiceComponentsConfigsApi, AdcmClusterServiceComponentGroupConfigConfigsApi, + AdcmClusterAnsibleSettingsApi, } from '@api'; import { ApiRequestsDictionary, @@ -34,6 +35,10 @@ import { LoadClusterGroupConfigurationVersionsArgs, LoadClusterGroupConfigurationArgs, SaveClusterGroupConfigurationArgs, + // Cluster ansible settings + LoadClusterAnsibleSettingsArgs, + LoadClusterAnsibleSettingsSchemaArgs, + SaveClusterAnsibleSettingsArgs, // Host LoadHostConfigurationVersionsArgs, LoadHostConfigurationArgs, @@ -103,6 +108,14 @@ export const ApiRequests: ApiRequestsDictionary = { createConfig: (args: SaveConfigurationArgs) => AdcmClusterGroupConfigsConfigsApi.createConfiguration(args as SaveClusterGroupConfigurationArgs), }, + 'cluster-ansible-settings': { + getConfig: (args: LoadConfigurationArgs) => + AdcmClusterAnsibleSettingsApi.getConfig(args as LoadClusterAnsibleSettingsArgs), + getConfigSchema: (args: LoadConfigurationArgs) => + AdcmClusterAnsibleSettingsApi.getConfigSchema(args as LoadClusterAnsibleSettingsSchemaArgs), + createConfig: (args: SaveConfigurationArgs) => + AdcmClusterAnsibleSettingsApi.createConfiguration(args as SaveClusterAnsibleSettingsArgs), + }, host: { getConfigVersions: (args: LoadConfigurationVersionsArgs) => AdcmHostConfigsApi.getConfigs(args as LoadHostConfigurationVersionsArgs), diff --git a/adcm-web/app/src/store/adcm/entityConfiguration/entityConfiguration.types.ts b/adcm-web/app/src/store/adcm/entityConfiguration/entityConfiguration.types.ts index ac4ebc9b6f..8c3d5d833f 100644 --- a/adcm-web/app/src/store/adcm/entityConfiguration/entityConfiguration.types.ts +++ b/adcm-web/app/src/store/adcm/entityConfiguration/entityConfiguration.types.ts @@ -38,6 +38,11 @@ import { GetClusterServiceComponentGroupConfigArgs, CreateClusterServiceComponentGroupConfigArgs, } from '@api/adcm/serviceComponentGroupConfigConfigs'; +import { + CreateClusterAnsibleSettingsArgs, + GetClusterAnsibleSettingsArgs, + GetClusterAnsibleSettingsSchemaArgs, +} from '@api/adcm/clusterAnsibleSettings'; export type EntityType = | 'settings' @@ -45,6 +50,7 @@ export type EntityType = | 'host-provider-config-group' | 'cluster' | 'cluster-config-group' + | 'cluster-ansible-settings' | 'host' | 'service' | 'service-config-group' @@ -88,6 +94,13 @@ export interface LoadClusterGroupConfigurationVersionsArgs export interface LoadClusterGroupConfigurationArgs extends LoadConfigurationArgs, GetClusterGroupConfigArgs {} export interface SaveClusterGroupConfigurationArgs extends SaveConfigurationArgs, CreateClusterGroupConfigArgs {} +/* Ansible settings */ +export interface LoadClusterAnsibleSettingsArgs extends LoadConfigurationArgs, GetClusterAnsibleSettingsArgs {} +export interface LoadClusterAnsibleSettingsSchemaArgs + extends LoadConfigurationArgs, + GetClusterAnsibleSettingsSchemaArgs {} +export interface SaveClusterAnsibleSettingsArgs extends SaveConfigurationArgs, CreateClusterAnsibleSettingsArgs {} + /* Host */ export interface LoadHostConfigurationVersionsArgs extends LoadConfigurationVersionsArgs, GetHostConfigsArgs {} export interface LoadHostConfigurationArgs extends LoadConfigurationArgs, GetHostConfigArgs {} @@ -196,6 +209,10 @@ export type LoadEntityConfigurationArgs = entityType: 'cluster-config-group'; args: LoadClusterGroupConfigurationArgs; } + | { + entityType: 'cluster-ansible-settings'; + args: LoadClusterAnsibleSettingsArgs; + } | { entityType: 'host'; args: LoadHostConfigurationArgs; @@ -238,6 +255,10 @@ export type CreateEntityConfigurationArgs = entityType: 'cluster-config-group'; args: SaveClusterGroupConfigurationArgs; } + | { + entityType: 'cluster-ansible-settings'; + args: SaveClusterAnsibleSettingsArgs; + } | { entityType: 'host'; args: SaveHostConfigurationArgs; @@ -261,7 +282,7 @@ export type CreateEntityConfigurationArgs = export type ApiRequestsDictionary = { [key in EntityType]: { - getConfigVersions: (args: LoadConfigurationVersionsArgs) => Promise>; + getConfigVersions?: (args: LoadConfigurationVersionsArgs) => Promise>; getConfig: (args: LoadConfigurationArgs) => Promise; getConfigSchema: (args: LoadConfigurationArgs) => Promise; createConfig: (args: SaveConfigurationArgs) => Promise; From 3c271b31d8c38114c9f50cecf81ac1a1c59a64fa Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Tue, 2 Jul 2024 08:19:44 +0000 Subject: [PATCH 204/208] ADCM-5747 Fix job launch on service after its deletion --- python/cm/services/job/inventory/_base.py | 71 ++++++++++++++----- .../cm/services/job/run/_target_factories.py | 7 +- .../test_inventory/test_cluster_hosts.py | 34 +++++++++ 3 files changed, 92 insertions(+), 20 deletions(-) diff --git a/python/cm/services/job/inventory/_base.py b/python/cm/services/job/inventory/_base.py index 1e66450457..748d5d8e4d 100644 --- a/python/cm/services/job/inventory/_base.py +++ b/python/cm/services/job/inventory/_base.py @@ -16,6 +16,7 @@ from core.cluster.operations import calculate_maintenance_mode_for_cluster_objects from core.cluster.types import ClusterTopology, MaintenanceModeOfObjects, ObjectMaintenanceModeState +from core.job.types import RelatedObjects from core.types import ( ActionTargetDescriptor, ADCMCoreType, @@ -61,29 +62,69 @@ ) -def get_inventory_data(target: ActionTargetDescriptor, is_host_action: bool, delta: dict | None = None) -> dict: +def get_inventory_data( + target: ActionTargetDescriptor, + is_host_action: bool, + delta: dict | None = None, + related_objects: RelatedObjects | None = None, +) -> dict: if target.type == ExtraActionTargetType.ACTION_HOST_GROUP: + # Some time ago `_get_inventory_for_action_from_cluster_bundle` required full ORM object to proceed, + # now it's not the case, so you can optimize this call if you want to. group = ActionHostGroup.objects.prefetch_related("hosts", "object").get(id=target.id) + + # It is possible that `object` does not exist at that point (deleted via `delete_service` in previous jobs), + # but it's inadequate situation and in "context of action target group" such mutations aren't expected. return _get_inventory_for_action_from_cluster_bundle( - object_=group.object, + cluster_id=group.object.id if isinstance(group.object, Cluster) else group.object.cluster_id, delta=delta or {}, target_hosts=tuple((host.pk, host.fqdn) for host in group.hosts.all()), ) - target_object = core_type_to_model(target.type).objects.get(id=target.id) - if isinstance(target_object, HostProvider) or (isinstance(target_object, Host) and not is_host_action): - return _get_inventory_for_action_from_hostprovider_bundle(object_=target_object) + if target.type == ADCMCoreType.HOSTPROVIDER or (target.type == ADCMCoreType.HOST and not is_host_action): + return _get_inventory_for_action_from_hostprovider_bundle( + object_=core_type_to_model(target.type).objects.get(id=target.id) + ) - target_hosts = () - if isinstance(target_object, Host): + # Retrieval of full object was changed to `cluster_id` only for cluster-related action inventory building, + # because target deletion cases exists. + # For example, action is defined on service and previous job calls `delete_service` ansible plugin, + # then there will be no service at this point. + # And we don't actually need the full object, just `cluster_id` to detect the topology. + # We also has information about `owner` (not `target`) related objects with possible presence of `cluster_id`. + # It is still possible that `cluster_id` will be undetected, then this approach should be reworked + # by storing required info at point when object has to exist (e.g. at task start). + cluster_id: int | None = None + target_hosts: tuple[tuple[int, str], ...] = () + if target.type == ADCMCoreType.HOST: if not is_host_action: message = "Only actions with `host_action: true` can be launched on host" raise RuntimeError(message) - target_hosts = ((target_object.pk, target_object.fqdn),) + host_id, host_name, cluster_id = Host.objects.filter(id=target.id).values_list("id", "fqdn", "cluster_id").get() + target_hosts = ((host_id, host_name),) + + if not cluster_id: + if target.type == ADCMCoreType.CLUSTER: + cluster_id = target.id + elif related_objects and related_objects.cluster: + cluster_id = related_objects.cluster.id + elif target.type in (ADCMCoreType.SERVICE, ADCMCoreType.COMPONENT): + # In real scenarios it's unlikely situation, but since we can get it that way, we should. + # It also easies testing when we don't want to specify `related_object` due to object existence. + # We don't catch non-existence errors in here, since it'll be descriptive enough. + cluster_id = core_type_to_model(target.type).objects.values_list("cluster_id", flat=True).get(id=target.id) + + if not cluster_id: + message = ( + f"Failed to detect cluster id based on target and related objects.\n" + f"Target: {target}\n" + f"Related objects: {related_objects}" + ) + raise RuntimeError(message) return _get_inventory_for_action_from_cluster_bundle( - object_=target_object, delta=delta or {}, target_hosts=target_hosts + cluster_id=cluster_id, delta=delta or {}, target_hosts=target_hosts ) @@ -110,22 +151,14 @@ def get_cluster_vars(topology: ClusterTopology) -> ClusterVars: def _get_inventory_for_action_from_cluster_bundle( - object_: Cluster | ClusterObject | ServiceComponent | Host | ActionHostGroup, - delta: dict, - target_hosts: Iterable[tuple[HostID, HostName]], + cluster_id: int, delta: dict, target_hosts: Iterable[tuple[HostID, HostName]] ) -> dict: host_groups: dict[HostGroupName, set[tuple[HostID, HostName]]] = {} if target_hosts: host_groups["target"] = set(target_hosts) - if isinstance(object_, Cluster): - cluster_topology = next(retrieve_clusters_topology([object_.pk])) - elif object_.cluster_id is not None: - cluster_topology = next(retrieve_clusters_topology([object_.cluster_id])) - else: - message = f"Cluster is unbound to {object_}, can't generate inventory" - raise RuntimeError(message) + cluster_topology = next(retrieve_clusters_topology([cluster_id])) hosts_in_maintenance_mode: set[int] = set( Host.objects.filter(maintenance_mode=MaintenanceMode.ON).values_list("id", flat=True) diff --git a/python/cm/services/job/run/_target_factories.py b/python/cm/services/job/run/_target_factories.py index a5bc62cfd2..444ea5c99b 100644 --- a/python/cm/services/job/run/_target_factories.py +++ b/python/cm/services/job/run/_target_factories.py @@ -237,7 +237,12 @@ def prepare_ansible_inventory(task: Task) -> dict[str, Any]: old=get_old_hc(saved_hostcomponent=task.hostcomponent.saved), ) - return get_inventory_data(target=task.target, is_host_action=task.action.is_host_action, delta=delta) + return get_inventory_data( + target=task.target, + is_host_action=task.action.is_host_action, + delta=delta, + related_objects=task.owner.related_objects, + ) def prepare_ansible_job_config(task: Task, job: Job, configuration: ExternalSettings) -> dict[str, Any]: diff --git a/python/cm/tests/test_inventory/test_cluster_hosts.py b/python/cm/tests/test_inventory/test_cluster_hosts.py index 20c9d90565..1a5916e8b3 100644 --- a/python/cm/tests/test_inventory/test_cluster_hosts.py +++ b/python/cm/tests/test_inventory/test_cluster_hosts.py @@ -13,7 +13,13 @@ from pathlib import Path +from core.job.dto import TaskPayloadDTO +from core.types import ADCMCoreType, CoreObjectDescriptor +from django.core.exceptions import ObjectDoesNotExist + from cm.models import Action +from cm.services.job.inventory import get_inventory_data +from cm.services.job.prepare import prepare_task_for_action from cm.tests.test_inventory.base import BaseInventoryTestCase @@ -189,3 +195,31 @@ def test_add_2_hosts_on_cluster_actions(self): ): with self.subTest(msg=f"Object: {obj.prototype.type} #{obj.pk} {obj.name}, action: {action.name}"): self.assert_inventory(obj=obj, action=action, expected_topology=topology, expected_data=data) + + def test_adcm_5747_delete_service(self) -> None: + service = self.add_services_to_cluster(["service_one_component"], cluster=self.cluster_1).get() + host = self.add_host(bundle=self.provider_bundle, provider=self.provider, fqdn="host_1", cluster=self.cluster_1) + self.set_hostcomponent(cluster=self.cluster_1, entries=[(host, service.servicecomponent_set.first())]) + + action = Action.objects.get(prototype=service.prototype, name="action_on_service") + target = owner_descriptor = CoreObjectDescriptor(id=service.id, type=ADCMCoreType.SERVICE) + task = prepare_task_for_action( + target=target, owner=owner_descriptor, action=action.id, payload=TaskPayloadDTO() + ) + + # imitate service deletion during task run (prev job deleted service) + service.delete() + + # without related objects it fails + with self.assertRaises(ObjectDoesNotExist) as err_context: + get_inventory_data(target=task.target, is_host_action=False) + + self.assertIn("ClusterObject matching query does not exist.", str(err_context.exception)) + + # with those inventory is generated + data = get_inventory_data(target=task.target, is_host_action=False, related_objects=task.owner.related_objects) + + self.assertSetEqual(set(data["all"]["vars"]), {"cluster", "services"}) + self.assertDictEqual(data["all"]["vars"]["services"], {}) + self.assertSetEqual(set(data["all"]["children"]), {"CLUSTER"}) + self.assertIn("host_1", data["all"]["children"]["CLUSTER"]["hosts"]) From e75f2021fd9cdf2415e31e08d1a138437c085be5 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Tue, 2 Jul 2024 09:38:34 +0000 Subject: [PATCH 205/208] ADCM-4929 Return old behavior on providing unchanged HC for action run --- python/cm/services/job/action.py | 8 -------- python/cm/tests/test_hc.py | 11 +++++------ 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/python/cm/services/job/action.py b/python/cm/services/job/action.py index 269d61a197..7b161be548 100644 --- a/python/cm/services/job/action.py +++ b/python/cm/services/job/action.py @@ -19,7 +19,6 @@ from django.conf import settings from django.db.transaction import atomic, on_commit from rbac.roles import re_apply_policy_for_jobs -from rest_framework.status import HTTP_409_CONFLICT from cm.adcm_config.checks import check_attr from cm.adcm_config.config import check_config_spec, get_prototype_config, process_config_spec, process_file_type @@ -241,13 +240,6 @@ def _process_hostcomponent( check_constraints_for_upgrade(cluster=cluster, upgrade=action.upgrade, host_comp_list=new_hc) host_map, post_upgrade_hc, delta = check_hostcomponentmap(cluster=cluster, action=action, new_hc=new_hostcomponent) - if action.hostcomponentmap and not (delta.get("add") or delta.get("remove")): - # means empty delta, shouldn't be like that - raise AdcmEx( - code="WRONG_ACTION_HC", - msg="Host-component is expected to be changed for this action", - http_code=HTTP_409_CONFLICT, - ) return host_map, post_upgrade_hc, delta, is_upgrade_action diff --git a/python/cm/tests/test_hc.py b/python/cm/tests/test_hc.py index 9bc78ab9f7..51dfca614e 100644 --- a/python/cm/tests/test_hc.py +++ b/python/cm/tests/test_hc.py @@ -16,7 +16,7 @@ from django.conf import settings from django.urls import reverse from rest_framework.response import Response -from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_409_CONFLICT +from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED from cm.api import add_host_to_cluster, save_hc from cm.errors import AdcmEx @@ -177,7 +177,7 @@ def test_empty_hostcomponent(self): self.assertEqual(response.status_code, HTTP_200_OK) - def test_adcm_4929_run_same_hc_bug(self) -> None: + def test_adcm_4929_run_same_hc_success(self) -> None: bundles_dir = Path(__file__).parent / "bundles" bundle = self.add_bundle(bundles_dir / "cluster_1") cluster = self.add_cluster(bundle=bundle, name="Cool") @@ -207,7 +207,7 @@ def test_adcm_4929_run_same_hc_bug(self) -> None: ) action = Action.objects.get(prototype=service_with_action.prototype, name="with_hc") - with RunTaskMock() as run_task: + with RunTaskMock(): response = self.client.post( path=f"/api/v2/clusters/{cluster.id}/services/{service_with_action.id}/actions/{action.id}/run/", data={ @@ -216,6 +216,5 @@ def test_adcm_4929_run_same_hc_bug(self) -> None: content_type=APPLICATION_JSON, ) - self.assertEqual(response.status_code, HTTP_409_CONFLICT) - self.assertEqual(response.json()["desc"], "Host-component is expected to be changed for this action") - self.assertIsNone(run_task.target_task) + # expectations changed due to existing behavior in bundles + self.assertEqual(response.status_code, HTTP_200_OK) From d5dc430cd125681357386af4a7ef07a4d2048ecc Mon Sep 17 00:00:00 2001 From: Aleksandr Alferov Date: Tue, 2 Jul 2024 16:52:59 +0300 Subject: [PATCH 206/208] Bump version --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 285fcc237a..8f07f82272 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ APP_IMAGE ?= hub.adsw.io/adcm/adcm APP_TAG ?= $(subst /,_,$(BRANCH_NAME)) SELENOID_HOST ?= 10.92.2.65 SELENOID_PORT ?= 4444 -ADCM_VERSION = "2.2.0-dev" +ADCM_VERSION = "2.2.0" PY_FILES = python dev/linters conf/adcm/python_scripts .PHONY: help From b79ea86a21deba468d8011e85cc5828930b156fb Mon Sep 17 00:00:00 2001 From: Vladimir Remizov Date: Fri, 5 Jul 2024 07:26:02 +0000 Subject: [PATCH 207/208] [hotfix] Fix/adcm 5752 Single Job Page autoscroll https://tracker.yandex.ru/ADCM-5752 --- .../pages/JobsPage/JobPage/JobPage.tsx | 4 +- .../pages/JobsPage/JobPage/SingleJob.tsx | 30 +++++++++++ .../JobsPage/JobPage/useJobLogAutoScroll.ts | 52 +++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 adcm-web/app/src/components/pages/JobsPage/JobPage/SingleJob.tsx create mode 100644 adcm-web/app/src/components/pages/JobsPage/JobPage/useJobLogAutoScroll.ts diff --git a/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPage.tsx b/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPage.tsx index d322ab83b6..dd6bf797b2 100644 --- a/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPage.tsx +++ b/adcm-web/app/src/components/pages/JobsPage/JobPage/JobPage.tsx @@ -5,8 +5,8 @@ import JobsSubPageStopHeader from './JobPageHeader/JobPageHeader'; import { useEffect } from 'react'; import { setBreadcrumbs } from '@store/adcm/breadcrumbs/breadcrumbsSlice'; import JobPageChildJobsTable from './JobPageChildJobsTable/JobPageChildJobsTable'; -import JobPageLog from './JobPageLog/JobPageLog'; import JobPageStopJobDialog from './Dialogs/JobPageStopJobDialog'; +import SingleJob from '@pages/JobsPage/JobPage/SingleJob'; const JobPage: React.FC = () => { const { task, dispatch } = useRequestJobPage(); @@ -24,7 +24,7 @@ const JobPage: React.FC = () => { - {task.childJobs?.length === 1 && } + {task.childJobs?.length === 1 && } {task.childJobs && task.childJobs.length > 1 && ( diff --git a/adcm-web/app/src/components/pages/JobsPage/JobPage/SingleJob.tsx b/adcm-web/app/src/components/pages/JobsPage/JobPage/SingleJob.tsx new file mode 100644 index 0000000000..a0f9ed38d3 --- /dev/null +++ b/adcm-web/app/src/components/pages/JobsPage/JobPage/SingleJob.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import s from '@pages/JobsPage/JobPage/JobPageChildJobsTable/JobPageChildJobsTable.module.scss'; +import ExpandableSwitch from '@uikit/Switch/ExpandableSwitch'; +import JobPageLog from '@pages/JobsPage/JobPage/JobPageLog/JobPageLog'; +import { useJobLogAutoScroll } from '@pages/JobsPage/JobPage/useJobLogAutoScroll'; +import { AdcmTask } from '@models/adcm'; + +interface SingleJobProps { + task: AdcmTask; +} + +const SingleJob = ({ task }: SingleJobProps) => { + const { isUserScrollRef, isAutoScrollState, setIsAutoScroll, toggleIsAutoScroll } = useJobLogAutoScroll(task); + + return ( +
+ +
+ +
+
+ ); +}; + +export default SingleJob; diff --git a/adcm-web/app/src/components/pages/JobsPage/JobPage/useJobLogAutoScroll.ts b/adcm-web/app/src/components/pages/JobsPage/JobPage/useJobLogAutoScroll.ts new file mode 100644 index 0000000000..1dcf46b5b4 --- /dev/null +++ b/adcm-web/app/src/components/pages/JobsPage/JobPage/useJobLogAutoScroll.ts @@ -0,0 +1,52 @@ +import { AdcmJobStatus, AdcmTask } from '@models/adcm'; +import { useParams } from 'react-router-dom'; +import { useEffect, useRef, useState } from 'react'; + +export const useJobLogAutoScroll = (task: AdcmTask) => { + const isUserScrollRef = useRef(false); + const isAutoScrollRef = useRef(true); + const [isAutoScrollState, setIsAutoScrollState] = useState(true); + const props = useParams(); + const { withAutoStop } = props; + + const setIsAutoScroll = (state: boolean) => { + if (!withAutoStop) return; + setIsAutoScrollState(state); + isAutoScrollRef.current = state; + }; + + const toggleIsAutoScroll = () => { + isAutoScrollRef.current = !isAutoScrollRef.current; + setIsAutoScrollState((prev) => !prev); + }; + + useEffect(() => { + const onUserScrollHandler = () => { + if (isUserScrollRef.current) { + setIsAutoScroll(false); + } + }; + + const onEndHandler = () => { + isUserScrollRef.current = true; + }; + + if (task.status === AdcmJobStatus.Running && isAutoScrollState) { + window.addEventListener('scroll', onUserScrollHandler); + window.addEventListener('scrollend', onEndHandler); + } + + return () => { + window.removeEventListener('scroll', onUserScrollHandler); + window.removeEventListener('scrollend', onEndHandler); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [task.status, isAutoScrollRef, isAutoScrollState]); + + return { + isAutoScrollState, + toggleIsAutoScroll, + isUserScrollRef, + setIsAutoScroll, + }; +}; From ce2028b48b9fed66e302b29852a22af453f33813 Mon Sep 17 00:00:00 2001 From: Araslanov Egor Date: Fri, 5 Jul 2024 09:32:51 +0000 Subject: [PATCH 208/208] ADCM-5754 Restrict caller to cluster when `service` argument is specified in... ADCM-5754 Restrict caller to cluster when `service` argument is specified in `adcm_delete_service` plugin call --- python/ansible_plugin/executors/delete_service.py | 11 +++++++++-- .../ansible_plugin/tests/test_adcm_delete_service.py | 9 +++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/python/ansible_plugin/executors/delete_service.py b/python/ansible_plugin/executors/delete_service.py index e67e5db0de..505e93da5d 100644 --- a/python/ansible_plugin/executors/delete_service.py +++ b/python/ansible_plugin/executors/delete_service.py @@ -26,7 +26,7 @@ PluginExecutorConfig, RuntimeEnvironment, ) -from ansible_plugin.errors import PluginRuntimeError, PluginTargetDetectionError +from ansible_plugin.errors import PluginIncorrectCallError, PluginRuntimeError, PluginTargetDetectionError class DeleteServiceArguments(BaseStrictModel): @@ -44,7 +44,14 @@ def __call__( ) -> CallResult[None]: _ = targets - if arguments.service: + if arguments.service is not None: + if runtime.context_owner.type == ADCMCoreType.SERVICE: + message = ( + "Service can be deleted by name only from cluster's context. " + "To delete caller-service don't specify `service` argument." + ) + raise PluginIncorrectCallError(message) + search_kwargs = {"cluster_id": runtime.vars.context.cluster_id, "prototype__name": arguments.service} elif runtime.context_owner.type == ADCMCoreType.SERVICE: search_kwargs = {"id": runtime.context_owner.id} diff --git a/python/ansible_plugin/tests/test_adcm_delete_service.py b/python/ansible_plugin/tests/test_adcm_delete_service.py index ae72c842ed..f0c7ddb1ea 100644 --- a/python/ansible_plugin/tests/test_adcm_delete_service.py +++ b/python/ansible_plugin/tests/test_adcm_delete_service.py @@ -14,7 +14,7 @@ from cm.models import ClusterObject, HostComponent, ServiceComponent from cm.services.job.run.repo import JobRepoImpl -from ansible_plugin.errors import PluginContextError, PluginTargetDetectionError +from ansible_plugin.errors import PluginContextError, PluginIncorrectCallError, PluginTargetDetectionError from ansible_plugin.executors.delete_service import ADCMDeleteServicePluginExecutor from ansible_plugin.tests.base import BaseTestEffectsOfADCMAnsiblePlugins @@ -90,7 +90,7 @@ def test_delete_service_from_own_context(self) -> None: expected = [(entry.host_id, entry.component_id) for entry in self.initial_hc] self.assertEqual(actual, expected) - def test_delete_service_by_name_from_service_context(self) -> None: + def test_delete_service_by_name_from_service_context_fail(self) -> None: task = self.prepare_task(owner=self.service_1, name="dummy") job, *_ = JobRepoImpl.get_task_jobs(task.id) executor = self.prepare_executor( @@ -101,9 +101,10 @@ def test_delete_service_by_name_from_service_context(self) -> None: result = executor.execute() - self.assertIsNone(result.error) + self.assertIsInstance(result.error, PluginIncorrectCallError) + self.assertIn("Service can be deleted by name only from cluster's context.", result.error.message) self.assertTrue(ClusterObject.objects.filter(pk=self.service_1.pk).exists()) - self.assertFalse(ClusterObject.objects.filter(pk=self.service_2.pk).exists()) + self.assertTrue(ClusterObject.objects.filter(pk=self.service_2.pk).exists()) def test_delete_non_existing_service_fail(self) -> None: task = self.prepare_task(owner=self.cluster, name="dummy")