mirror of https://github.com/jumpserver/jumpserver
feat: 改密记录 推送记录可单独执行
parent
ee586954f8
commit
bc54685a31
|
@ -1,16 +1,19 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
|
from rest_framework import status, mixins
|
||||||
from rest_framework import mixins
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from accounts import serializers
|
from accounts import serializers
|
||||||
from accounts.const import AutomationTypes
|
from accounts.const import AutomationTypes
|
||||||
from accounts.models import ChangeSecretAutomation, ChangeSecretRecord
|
from accounts.models import ChangeSecretAutomation, ChangeSecretRecord
|
||||||
|
from accounts.tasks import execute_automation_record_task
|
||||||
from orgs.mixins.api import OrgBulkModelViewSet, OrgGenericViewSet
|
from orgs.mixins.api import OrgBulkModelViewSet, OrgGenericViewSet
|
||||||
from .base import (
|
from .base import (
|
||||||
AutomationAssetsListApi, AutomationRemoveAssetApi, AutomationAddAssetApi,
|
AutomationAssetsListApi, AutomationRemoveAssetApi, AutomationAddAssetApi,
|
||||||
AutomationNodeAddRemoveApi, AutomationExecutionViewSet
|
AutomationNodeAddRemoveApi, AutomationExecutionViewSet
|
||||||
)
|
)
|
||||||
|
from ...filters import ChangeSecretRecordFilterSet
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'ChangeSecretAutomationViewSet', 'ChangeSecretRecordViewSet',
|
'ChangeSecretAutomationViewSet', 'ChangeSecretRecordViewSet',
|
||||||
|
@ -29,18 +32,27 @@ class ChangeSecretAutomationViewSet(OrgBulkModelViewSet):
|
||||||
|
|
||||||
class ChangeSecretRecordViewSet(mixins.ListModelMixin, OrgGenericViewSet):
|
class ChangeSecretRecordViewSet(mixins.ListModelMixin, OrgGenericViewSet):
|
||||||
serializer_class = serializers.ChangeSecretRecordSerializer
|
serializer_class = serializers.ChangeSecretRecordSerializer
|
||||||
filter_fields = ('asset', 'execution_id')
|
filterset_class = ChangeSecretRecordFilterSet
|
||||||
search_fields = ('asset__address',)
|
search_fields = ('asset__address',)
|
||||||
|
tp = AutomationTypes.change_secret
|
||||||
|
rbac_perms = {
|
||||||
|
'execute': 'accounts.add_changesecretexecution',
|
||||||
|
}
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return ChangeSecretRecord.objects.filter(
|
return ChangeSecretRecord.objects.all()
|
||||||
execution__automation__type=AutomationTypes.change_secret
|
|
||||||
)
|
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
@action(methods=['post'], detail=False, url_path='execute')
|
||||||
queryset = super().filter_queryset(queryset)
|
def execute(self, request, *args, **kwargs):
|
||||||
eid = self.request.query_params.get('execution_id')
|
record_id = request.data.get('record_id')
|
||||||
return queryset.filter(execution_id=eid)
|
record = self.get_queryset().filter(pk=record_id)
|
||||||
|
if not record:
|
||||||
|
return Response(
|
||||||
|
{'detail': 'record not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
task = execute_automation_record_task.delay(record_id, self.tp)
|
||||||
|
return Response({'task': task.id}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
class ChangSecretExecutionViewSet(AutomationExecutionViewSet):
|
class ChangSecretExecutionViewSet(AutomationExecutionViewSet):
|
||||||
|
|
|
@ -42,6 +42,7 @@ class PushAccountExecutionViewSet(AutomationExecutionViewSet):
|
||||||
|
|
||||||
class PushAccountRecordViewSet(ChangeSecretRecordViewSet):
|
class PushAccountRecordViewSet(ChangeSecretRecordViewSet):
|
||||||
serializer_class = serializers.ChangeSecretRecordSerializer
|
serializer_class = serializers.ChangeSecretRecordSerializer
|
||||||
|
tp = AutomationTypes.push_account
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return ChangeSecretRecord.objects.filter(
|
return ChangeSecretRecord.objects.filter(
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
@ -27,7 +26,7 @@ class ChangeSecretManager(AccountBasePlaybookManager):
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.method_hosts_mapper = defaultdict(list)
|
self.record_id = self.execution.snapshot.get('record_id')
|
||||||
self.secret_type = self.execution.snapshot.get('secret_type')
|
self.secret_type = self.execution.snapshot.get('secret_type')
|
||||||
self.secret_strategy = self.execution.snapshot.get(
|
self.secret_strategy = self.execution.snapshot.get(
|
||||||
'secret_strategy', SecretStrategy.custom
|
'secret_strategy', SecretStrategy.custom
|
||||||
|
@ -96,17 +95,13 @@ class ChangeSecretManager(AccountBasePlaybookManager):
|
||||||
|
|
||||||
accounts = self.get_accounts(account)
|
accounts = self.get_accounts(account)
|
||||||
if not accounts:
|
if not accounts:
|
||||||
print('没有发现待改密账号: %s 用户ID: %s 类型: %s' % (
|
print('没有发现待处理的账号: %s 用户ID: %s 类型: %s' % (
|
||||||
asset.name, self.account_ids, self.secret_type
|
asset.name, self.account_ids, self.secret_type
|
||||||
))
|
))
|
||||||
return []
|
return []
|
||||||
|
|
||||||
method_attr = getattr(automation, self.method_type() + '_method')
|
|
||||||
method_hosts = self.method_hosts_mapper[method_attr]
|
|
||||||
method_hosts = [h for h in method_hosts if h != host['name']]
|
|
||||||
inventory_hosts = []
|
|
||||||
records = []
|
records = []
|
||||||
|
inventory_hosts = []
|
||||||
if asset.type == HostTypes.WINDOWS and self.secret_type == SecretType.SSH_KEY:
|
if asset.type == HostTypes.WINDOWS and self.secret_type == SecretType.SSH_KEY:
|
||||||
print(f'Windows {asset} does not support ssh key push')
|
print(f'Windows {asset} does not support ssh key push')
|
||||||
return inventory_hosts
|
return inventory_hosts
|
||||||
|
@ -116,13 +111,20 @@ class ChangeSecretManager(AccountBasePlaybookManager):
|
||||||
h = deepcopy(host)
|
h = deepcopy(host)
|
||||||
secret_type = account.secret_type
|
secret_type = account.secret_type
|
||||||
h['name'] += '(' + account.username + ')'
|
h['name'] += '(' + account.username + ')'
|
||||||
new_secret = self.get_secret(secret_type)
|
if self.secret_type is None:
|
||||||
|
new_secret = account.secret
|
||||||
|
else:
|
||||||
|
new_secret = self.get_secret(secret_type)
|
||||||
|
|
||||||
|
if self.record_id is None:
|
||||||
|
recorder = ChangeSecretRecord(
|
||||||
|
asset=asset, account=account, execution=self.execution,
|
||||||
|
old_secret=account.secret, new_secret=new_secret,
|
||||||
|
)
|
||||||
|
records.append(recorder)
|
||||||
|
else:
|
||||||
|
recorder = ChangeSecretRecord.objects.get(id=self.record_id)
|
||||||
|
|
||||||
recorder = ChangeSecretRecord(
|
|
||||||
asset=asset, account=account, execution=self.execution,
|
|
||||||
old_secret=account.secret, new_secret=new_secret,
|
|
||||||
)
|
|
||||||
records.append(recorder)
|
|
||||||
self.name_recorder_mapper[h['name']] = recorder
|
self.name_recorder_mapper[h['name']] = recorder
|
||||||
|
|
||||||
private_key_path = None
|
private_key_path = None
|
||||||
|
@ -142,8 +144,6 @@ class ChangeSecretManager(AccountBasePlaybookManager):
|
||||||
if asset.platform.type == 'oracle':
|
if asset.platform.type == 'oracle':
|
||||||
h['account']['mode'] = 'sysdba' if account.privileged else None
|
h['account']['mode'] = 'sysdba' if account.privileged else None
|
||||||
inventory_hosts.append(h)
|
inventory_hosts.append(h)
|
||||||
method_hosts.append(h['name'])
|
|
||||||
self.method_hosts_mapper[method_attr] = method_hosts
|
|
||||||
ChangeSecretRecord.objects.bulk_create(records)
|
ChangeSecretRecord.objects.bulk_create(records)
|
||||||
return inventory_hosts
|
return inventory_hosts
|
||||||
|
|
||||||
|
@ -171,7 +171,7 @@ class ChangeSecretManager(AccountBasePlaybookManager):
|
||||||
recorder.save()
|
recorder.save()
|
||||||
|
|
||||||
def on_runner_failed(self, runner, e):
|
def on_runner_failed(self, runner, e):
|
||||||
logger.error("Change secret error: ", e)
|
logger.error("Account error: ", e)
|
||||||
|
|
||||||
def check_secret(self):
|
def check_secret(self):
|
||||||
if self.secret_strategy == SecretStrategy.custom \
|
if self.secret_strategy == SecretStrategy.custom \
|
||||||
|
@ -181,9 +181,11 @@ class ChangeSecretManager(AccountBasePlaybookManager):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def run(self, *args, **kwargs):
|
def run(self, *args, **kwargs):
|
||||||
if not self.check_secret():
|
if self.secret_type and not self.check_secret():
|
||||||
return
|
return
|
||||||
super().run(*args, **kwargs)
|
super().run(*args, **kwargs)
|
||||||
|
if self.record_id:
|
||||||
|
return
|
||||||
recorders = self.name_recorder_mapper.values()
|
recorders = self.name_recorder_mapper.values()
|
||||||
recorders = list(recorders)
|
recorders = list(recorders)
|
||||||
self.send_recorder_mail(recorders)
|
self.send_recorder_mail(recorders)
|
||||||
|
|
|
@ -1,7 +1,4 @@
|
||||||
from copy import deepcopy
|
from accounts.const import AutomationTypes
|
||||||
|
|
||||||
from accounts.const import AutomationTypes, SecretType, Connectivity
|
|
||||||
from assets.const import HostTypes
|
|
||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
from ..base.manager import AccountBasePlaybookManager
|
from ..base.manager import AccountBasePlaybookManager
|
||||||
from ..change_secret.manager import ChangeSecretManager
|
from ..change_secret.manager import ChangeSecretManager
|
||||||
|
@ -10,84 +7,11 @@ logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PushAccountManager(ChangeSecretManager, AccountBasePlaybookManager):
|
class PushAccountManager(ChangeSecretManager, AccountBasePlaybookManager):
|
||||||
ansible_account_prefer = ''
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def method_type(cls):
|
def method_type(cls):
|
||||||
return AutomationTypes.push_account
|
return AutomationTypes.push_account
|
||||||
|
|
||||||
def host_callback(self, host, asset=None, account=None, automation=None, path_dir=None, **kwargs):
|
|
||||||
host = super(ChangeSecretManager, self).host_callback(
|
|
||||||
host, asset=asset, account=account, automation=automation,
|
|
||||||
path_dir=path_dir, **kwargs
|
|
||||||
)
|
|
||||||
if host.get('error'):
|
|
||||||
return host
|
|
||||||
|
|
||||||
accounts = self.get_accounts(account)
|
|
||||||
inventory_hosts = []
|
|
||||||
if asset.type == HostTypes.WINDOWS and self.secret_type == SecretType.SSH_KEY:
|
|
||||||
msg = f'Windows {asset} does not support ssh key push'
|
|
||||||
print(msg)
|
|
||||||
return inventory_hosts
|
|
||||||
|
|
||||||
host['ssh_params'] = {}
|
|
||||||
for account in accounts:
|
|
||||||
h = deepcopy(host)
|
|
||||||
secret_type = account.secret_type
|
|
||||||
h['name'] += '(' + account.username + ')'
|
|
||||||
if self.secret_type is None:
|
|
||||||
new_secret = account.secret
|
|
||||||
else:
|
|
||||||
new_secret = self.get_secret(secret_type)
|
|
||||||
|
|
||||||
self.name_recorder_mapper[h['name']] = {
|
|
||||||
'account': account, 'new_secret': new_secret,
|
|
||||||
}
|
|
||||||
|
|
||||||
private_key_path = None
|
|
||||||
if secret_type == SecretType.SSH_KEY:
|
|
||||||
private_key_path = self.generate_private_key_path(new_secret, path_dir)
|
|
||||||
new_secret = self.generate_public_key(new_secret)
|
|
||||||
|
|
||||||
h['ssh_params'].update(self.get_ssh_params(account, new_secret, secret_type))
|
|
||||||
h['account'] = {
|
|
||||||
'name': account.name,
|
|
||||||
'username': account.username,
|
|
||||||
'secret_type': secret_type,
|
|
||||||
'secret': new_secret,
|
|
||||||
'private_key_path': private_key_path,
|
|
||||||
'become': account.get_ansible_become_auth(),
|
|
||||||
}
|
|
||||||
if asset.platform.type == 'oracle':
|
|
||||||
h['account']['mode'] = 'sysdba' if account.privileged else None
|
|
||||||
inventory_hosts.append(h)
|
|
||||||
return inventory_hosts
|
|
||||||
|
|
||||||
def on_host_success(self, host, result):
|
|
||||||
account_info = self.name_recorder_mapper.get(host)
|
|
||||||
if not account_info:
|
|
||||||
return
|
|
||||||
|
|
||||||
account = account_info['account']
|
|
||||||
new_secret = account_info['new_secret']
|
|
||||||
if not account:
|
|
||||||
return
|
|
||||||
account.secret = new_secret
|
|
||||||
account.save(update_fields=['secret'])
|
|
||||||
account.set_connectivity(Connectivity.OK)
|
|
||||||
|
|
||||||
def on_host_error(self, host, error, result):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def on_runner_failed(self, runner, e):
|
|
||||||
logger.error("Pust account error: {}".format(e))
|
|
||||||
|
|
||||||
def run(self, *args, **kwargs):
|
|
||||||
if self.secret_type and not self.check_secret():
|
|
||||||
return
|
|
||||||
super(ChangeSecretManager, self).run(*args, **kwargs)
|
|
||||||
|
|
||||||
# @classmethod
|
# @classmethod
|
||||||
# def trigger_by_asset_create(cls, asset):
|
# def trigger_by_asset_create(cls, asset):
|
||||||
# automations = PushAccountAutomation.objects.filter(
|
# automations = PushAccountAutomation.objects.filter(
|
||||||
|
|
|
@ -5,7 +5,7 @@ from django_filters import rest_framework as drf_filters
|
||||||
|
|
||||||
from assets.models import Node
|
from assets.models import Node
|
||||||
from common.drf.filters import BaseFilterSet
|
from common.drf.filters import BaseFilterSet
|
||||||
from .models import Account, GatheredAccount
|
from .models import Account, GatheredAccount, ChangeSecretRecord
|
||||||
|
|
||||||
|
|
||||||
class AccountFilterSet(BaseFilterSet):
|
class AccountFilterSet(BaseFilterSet):
|
||||||
|
@ -59,3 +59,10 @@ class GatheredAccountFilterSet(BaseFilterSet):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = GatheredAccount
|
model = GatheredAccount
|
||||||
fields = ['id', 'asset_id', 'username']
|
fields = ['id', 'asset_id', 'username']
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeSecretRecordFilterSet(BaseFilterSet):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ChangeSecretRecord
|
||||||
|
fields = ['asset_id', 'execution_id']
|
||||||
|
|
|
@ -116,7 +116,7 @@ class Migration(migrations.Migration):
|
||||||
('new_secret', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='New secret')),
|
('new_secret', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='New secret')),
|
||||||
('date_started', models.DateTimeField(blank=True, null=True, verbose_name='Date started')),
|
('date_started', models.DateTimeField(blank=True, null=True, verbose_name='Date started')),
|
||||||
('date_finished', models.DateTimeField(blank=True, null=True, verbose_name='Date finished')),
|
('date_finished', models.DateTimeField(blank=True, null=True, verbose_name='Date finished')),
|
||||||
('status', models.CharField(default='pending', max_length=16)),
|
('status', models.CharField(default='pending', max_length=16, verbose_name='Status')),
|
||||||
('error', models.TextField(blank=True, null=True, verbose_name='Error')),
|
('error', models.TextField(blank=True, null=True, verbose_name='Error')),
|
||||||
('account',
|
('account',
|
||||||
models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='accounts.account')),
|
models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='accounts.account')),
|
||||||
|
|
|
@ -40,7 +40,7 @@ class ChangeSecretRecord(JMSBaseModel):
|
||||||
new_secret = fields.EncryptTextField(blank=True, null=True, verbose_name=_('New secret'))
|
new_secret = fields.EncryptTextField(blank=True, null=True, verbose_name=_('New secret'))
|
||||||
date_started = models.DateTimeField(blank=True, null=True, verbose_name=_('Date started'))
|
date_started = models.DateTimeField(blank=True, null=True, verbose_name=_('Date started'))
|
||||||
date_finished = models.DateTimeField(blank=True, null=True, verbose_name=_('Date finished'))
|
date_finished = models.DateTimeField(blank=True, null=True, verbose_name=_('Date finished'))
|
||||||
status = models.CharField(max_length=16, default='pending')
|
status = models.CharField(max_length=16, default='pending', verbose_name=_('Status'))
|
||||||
error = models.TextField(blank=True, null=True, verbose_name=_('Error'))
|
error = models.TextField(blank=True, null=True, verbose_name=_('Error'))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -49,9 +49,3 @@ class ChangeSecretRecord(JMSBaseModel):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.account.__str__()
|
return self.account.__str__()
|
||||||
|
|
||||||
@property
|
|
||||||
def timedelta(self):
|
|
||||||
if self.date_started and self.date_finished:
|
|
||||||
return self.date_finished - self.date_started
|
|
||||||
return None
|
|
||||||
|
|
|
@ -112,8 +112,8 @@ class ChangeSecretRecordSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ChangeSecretRecord
|
model = ChangeSecretRecord
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'asset', 'account', 'date_started', 'date_finished',
|
'id', 'asset', 'account', 'date_finished',
|
||||||
'timedelta', 'is_success', 'error', 'execution',
|
'status', 'is_success', 'error', 'execution',
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _, gettext_noop
|
||||||
|
|
||||||
from accounts.const import AutomationTypes
|
from accounts.const import AutomationTypes
|
||||||
|
from accounts.tasks.common import quickstart_automation_by_snapshot
|
||||||
from common.utils import get_logger, get_object_or_none
|
from common.utils import get_logger, get_object_or_none
|
||||||
from orgs.utils import tmp_to_org, tmp_to_root_org
|
from orgs.utils import tmp_to_org, tmp_to_root_org
|
||||||
|
|
||||||
|
@ -33,3 +34,39 @@ def execute_account_automation_task(pid, trigger, tp):
|
||||||
return
|
return
|
||||||
with tmp_to_org(instance.org):
|
with tmp_to_org(instance.org):
|
||||||
instance.execute(trigger)
|
instance.execute(trigger)
|
||||||
|
|
||||||
|
|
||||||
|
def record_task_activity_callback(self, record_id, *args, **kwargs):
|
||||||
|
from accounts.models import ChangeSecretRecord
|
||||||
|
with tmp_to_root_org():
|
||||||
|
record = get_object_or_none(ChangeSecretRecord, id=record_id)
|
||||||
|
if not record:
|
||||||
|
return
|
||||||
|
resource_ids = [record.id]
|
||||||
|
org_id = record.execution.org_id
|
||||||
|
return resource_ids, org_id
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(
|
||||||
|
queue='ansible', verbose_name=_('Execute automation record'),
|
||||||
|
activity_callback=record_task_activity_callback
|
||||||
|
)
|
||||||
|
def execute_automation_record_task(record_id, tp):
|
||||||
|
from accounts.models import ChangeSecretRecord
|
||||||
|
with tmp_to_root_org():
|
||||||
|
instance = get_object_or_none(ChangeSecretRecord, pk=record_id)
|
||||||
|
if not instance:
|
||||||
|
logger.error("No automation record found: {}".format(record_id))
|
||||||
|
return
|
||||||
|
|
||||||
|
task_name = gettext_noop('Execute automation record')
|
||||||
|
task_snapshot = {
|
||||||
|
'secret': instance.new_secret,
|
||||||
|
'secret_type': instance.execution.snapshot.get('secret_type'),
|
||||||
|
'accounts': [str(instance.account_id)],
|
||||||
|
'assets': [str(instance.asset_id)],
|
||||||
|
'params': {},
|
||||||
|
'record_id': record_id,
|
||||||
|
}
|
||||||
|
with tmp_to_org(instance.execution.org_id):
|
||||||
|
quickstart_automation_by_snapshot(task_name, tp, task_snapshot)
|
||||||
|
|
|
@ -2,7 +2,6 @@ import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from collections import defaultdict
|
|
||||||
from socket import gethostname
|
from socket import gethostname
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
@ -37,8 +36,6 @@ class BasePlaybookManager:
|
||||||
}
|
}
|
||||||
# 根据执行方式就行分组, 不同资产的改密、推送等操作可能会使用不同的执行方式
|
# 根据执行方式就行分组, 不同资产的改密、推送等操作可能会使用不同的执行方式
|
||||||
# 然后根据执行方式分组, 再根据 bulk_size 分组, 生成不同的 playbook
|
# 然后根据执行方式分组, 再根据 bulk_size 分组, 生成不同的 playbook
|
||||||
# 避免一个 playbook 中包含太多的主机
|
|
||||||
self.method_hosts_mapper = defaultdict(list)
|
|
||||||
self.playbooks = []
|
self.playbooks = []
|
||||||
self.gateway_servers = dict()
|
self.gateway_servers = dict()
|
||||||
params = self.execution.snapshot.get('params')
|
params = self.execution.snapshot.get('params')
|
||||||
|
|
Loading…
Reference in New Issue