mirror of https://github.com/veops/cmdb.git
refactor: CI triggers
This commit is contained in:
parent
6e94c72031
commit
d8399f8723
|
@ -48,6 +48,7 @@ six = "==1.12.0"
|
||||||
bs4 = ">=0.0.1"
|
bs4 = ">=0.0.1"
|
||||||
toposort = ">=1.5"
|
toposort = ">=1.5"
|
||||||
requests = ">=2.22.0"
|
requests = ">=2.22.0"
|
||||||
|
requests_oauthlib = "==1.3.1"
|
||||||
PyJWT = "==2.4.0"
|
PyJWT = "==2.4.0"
|
||||||
elasticsearch = "==7.17.9"
|
elasticsearch = "==7.17.9"
|
||||||
future = "==0.18.3"
|
future = "==0.18.3"
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
import copy
|
import copy
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
|
|
||||||
from flask import abort
|
from flask import abort
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
@ -24,27 +25,33 @@ from api.lib.cmdb.const import CMDB_QUEUE
|
||||||
from api.lib.cmdb.const import ConstraintEnum
|
from api.lib.cmdb.const import ConstraintEnum
|
||||||
from api.lib.cmdb.const import ExistPolicy
|
from api.lib.cmdb.const import ExistPolicy
|
||||||
from api.lib.cmdb.const import OperateType
|
from api.lib.cmdb.const import OperateType
|
||||||
from api.lib.cmdb.const import PermEnum, ResourceTypeEnum
|
from api.lib.cmdb.const import PermEnum
|
||||||
from api.lib.cmdb.const import REDIS_PREFIX_CI
|
from api.lib.cmdb.const import REDIS_PREFIX_CI
|
||||||
|
from api.lib.cmdb.const import ResourceTypeEnum
|
||||||
from api.lib.cmdb.const import RetKey
|
from api.lib.cmdb.const import RetKey
|
||||||
from api.lib.cmdb.history import AttributeHistoryManger
|
from api.lib.cmdb.history import AttributeHistoryManger
|
||||||
from api.lib.cmdb.history import CIRelationHistoryManager
|
from api.lib.cmdb.history import CIRelationHistoryManager
|
||||||
|
from api.lib.cmdb.history import CITriggerHistoryManager
|
||||||
from api.lib.cmdb.perms import CIFilterPermsCRUD
|
from api.lib.cmdb.perms import CIFilterPermsCRUD
|
||||||
from api.lib.cmdb.resp_format import ErrFormat
|
from api.lib.cmdb.resp_format import ErrFormat
|
||||||
from api.lib.cmdb.utils import TableMap
|
from api.lib.cmdb.utils import TableMap
|
||||||
from api.lib.cmdb.utils import ValueTypeMap
|
from api.lib.cmdb.utils import ValueTypeMap
|
||||||
from api.lib.cmdb.value import AttributeValueManager
|
from api.lib.cmdb.value import AttributeValueManager
|
||||||
from api.lib.decorator import kwargs_required
|
from api.lib.decorator import kwargs_required
|
||||||
|
from api.lib.notify import notify_send
|
||||||
from api.lib.perm.acl.acl import ACLManager
|
from api.lib.perm.acl.acl import ACLManager
|
||||||
from api.lib.perm.acl.acl import is_app_admin
|
from api.lib.perm.acl.acl import is_app_admin
|
||||||
from api.lib.perm.acl.acl import validate_permission
|
from api.lib.perm.acl.acl import validate_permission
|
||||||
from api.lib.utils import Lock
|
from api.lib.utils import Lock
|
||||||
from api.lib.utils import handle_arg_list
|
from api.lib.utils import handle_arg_list
|
||||||
|
from api.lib.webhook import webhook_request
|
||||||
|
from api.models.cmdb import AttributeHistory
|
||||||
from api.models.cmdb import AutoDiscoveryCI
|
from api.models.cmdb import AutoDiscoveryCI
|
||||||
from api.models.cmdb import CI
|
from api.models.cmdb import CI
|
||||||
from api.models.cmdb import CIRelation
|
from api.models.cmdb import CIRelation
|
||||||
from api.models.cmdb import CITypeAttribute
|
from api.models.cmdb import CITypeAttribute
|
||||||
from api.models.cmdb import CITypeRelation
|
from api.models.cmdb import CITypeRelation
|
||||||
|
from api.models.cmdb import CITypeTrigger
|
||||||
from api.tasks.cmdb import ci_cache
|
from api.tasks.cmdb import ci_cache
|
||||||
from api.tasks.cmdb import ci_delete
|
from api.tasks.cmdb import ci_delete
|
||||||
from api.tasks.cmdb import ci_relation_add
|
from api.tasks.cmdb import ci_relation_add
|
||||||
|
@ -378,16 +385,17 @@ class CIManager(object):
|
||||||
key2attr = value_manager.valid_attr_value(ci_dict, ci_type.id, ci and ci.id,
|
key2attr = value_manager.valid_attr_value(ci_dict, ci_type.id, ci and ci.id,
|
||||||
ci_type_attrs_name, ci_type_attrs_alias, ci_attr2type_attr)
|
ci_type_attrs_name, ci_type_attrs_alias, ci_attr2type_attr)
|
||||||
|
|
||||||
|
operate_type = OperateType.UPDATE if ci is not None else OperateType.ADD
|
||||||
try:
|
try:
|
||||||
ci = ci or CI.create(type_id=ci_type.id, is_auto_discovery=is_auto_discovery)
|
ci = ci or CI.create(type_id=ci_type.id, is_auto_discovery=is_auto_discovery)
|
||||||
record_id = value_manager.create_or_update_attr_value2(ci, ci_dict, key2attr)
|
record_id = value_manager.create_or_update_attr_value(ci, ci_dict, key2attr)
|
||||||
except BadRequest as e:
|
except BadRequest as e:
|
||||||
if existed is None:
|
if existed is None:
|
||||||
cls.delete(ci.id)
|
cls.delete(ci.id)
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
if record_id: # has change
|
if record_id: # has change
|
||||||
ci_cache.apply_async([ci.id], queue=CMDB_QUEUE)
|
ci_cache.apply_async(args=(ci.id, operate_type, record_id), queue=CMDB_QUEUE)
|
||||||
|
|
||||||
if ref_ci_dict: # add relations
|
if ref_ci_dict: # add relations
|
||||||
ci_relation_add.apply_async(args=(ref_ci_dict, ci.id, current_user.uid), queue=CMDB_QUEUE)
|
ci_relation_add.apply_async(args=(ref_ci_dict, ci.id, current_user.uid), queue=CMDB_QUEUE)
|
||||||
|
@ -427,12 +435,12 @@ class CIManager(object):
|
||||||
return abort(403, ErrFormat.ci_filter_perm_attr_no_permission.format(k))
|
return abort(403, ErrFormat.ci_filter_perm_attr_no_permission.format(k))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
record_id = value_manager.create_or_update_attr_value2(ci, ci_dict, key2attr)
|
record_id = value_manager.create_or_update_attr_value(ci, ci_dict, key2attr)
|
||||||
except BadRequest as e:
|
except BadRequest as e:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
if record_id: # has change
|
if record_id: # has change
|
||||||
ci_cache.apply_async([ci_id], queue=CMDB_QUEUE)
|
ci_cache.apply_async(args=(ci_id, OperateType.UPDATE, record_id), queue=CMDB_QUEUE)
|
||||||
|
|
||||||
ref_ci_dict = {k: v for k, v in ci_dict.items() if k.startswith("$") and "." in k}
|
ref_ci_dict = {k: v for k, v in ci_dict.items() if k.startswith("$") and "." in k}
|
||||||
if ref_ci_dict:
|
if ref_ci_dict:
|
||||||
|
@ -442,9 +450,10 @@ class CIManager(object):
|
||||||
def update_unique_value(ci_id, unique_name, unique_value):
|
def update_unique_value(ci_id, unique_name, unique_value):
|
||||||
ci = CI.get_by_id(ci_id) or abort(404, ErrFormat.ci_not_found.format("id={}".format(ci_id)))
|
ci = CI.get_by_id(ci_id) or abort(404, ErrFormat.ci_not_found.format("id={}".format(ci_id)))
|
||||||
|
|
||||||
AttributeValueManager().create_or_update_attr_value(unique_name, unique_value, ci)
|
key2attr = {unique_name: AttributeCache.get(unique_name)}
|
||||||
|
record_id = AttributeValueManager().create_or_update_attr_value(ci, {unique_name: unique_value}, key2attr)
|
||||||
|
|
||||||
ci_cache.apply_async([ci_id], queue=CMDB_QUEUE)
|
ci_cache.apply_async(args=(ci_id, OperateType.UPDATE, record_id), queue=CMDB_QUEUE)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def delete(cls, ci_id):
|
def delete(cls, ci_id):
|
||||||
|
@ -477,9 +486,9 @@ class CIManager(object):
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
AttributeHistoryManger.add(None, ci_id, [(None, OperateType.DELETE, ci_dict, None)], ci.type_id)
|
record_id = AttributeHistoryManger.add(None, ci_id, [(None, OperateType.DELETE, ci_dict, None)], ci.type_id)
|
||||||
|
|
||||||
ci_delete.apply_async([ci.id], queue=CMDB_QUEUE)
|
ci_delete.apply_async(args=(ci_dict, OperateType.DELETE, record_id), queue=CMDB_QUEUE)
|
||||||
|
|
||||||
return ci_id
|
return ci_id
|
||||||
|
|
||||||
|
@ -896,3 +905,128 @@ class CIRelationManager(object):
|
||||||
for parent_id in parents:
|
for parent_id in parents:
|
||||||
for ci_id in ci_ids:
|
for ci_id in ci_ids:
|
||||||
cls.delete_2(parent_id, ci_id)
|
cls.delete_2(parent_id, ci_id)
|
||||||
|
|
||||||
|
|
||||||
|
class CITriggerManager(object):
|
||||||
|
@staticmethod
|
||||||
|
def get(type_id):
|
||||||
|
return CITypeTrigger.get_by(type_id=type_id, to_dict=False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _exec_webhook(operate_type, webhook, ci_dict, trigger_id, record_id):
|
||||||
|
try:
|
||||||
|
response = webhook_request(webhook, ci_dict).text
|
||||||
|
is_ok = True
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.warning("exec webhook failed: {}".format(e))
|
||||||
|
response = e
|
||||||
|
is_ok = False
|
||||||
|
|
||||||
|
CITriggerHistoryManager.add(operate_type,
|
||||||
|
record_id,
|
||||||
|
ci_dict.get('_id'),
|
||||||
|
trigger_id,
|
||||||
|
is_ok=is_ok,
|
||||||
|
webhook=response)
|
||||||
|
|
||||||
|
return is_ok
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _exec_notify(operate_type, notify, ci_dict, trigger_id, record_id, ci_id=None):
|
||||||
|
|
||||||
|
if ci_id is not None:
|
||||||
|
ci_dict = CIManager().get_ci_by_id_from_db(ci_id, need_children=False, use_master=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = notify_send(notify.get('subject'), notify.get('body'), notify.get('tos'), ci_dict)
|
||||||
|
is_ok = True
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.warning("send notify failed: {}".format(e))
|
||||||
|
response = e
|
||||||
|
is_ok = False
|
||||||
|
|
||||||
|
CITriggerHistoryManager.add(operate_type,
|
||||||
|
record_id,
|
||||||
|
ci_dict.get('_id'),
|
||||||
|
trigger_id,
|
||||||
|
is_ok=is_ok,
|
||||||
|
notify=response)
|
||||||
|
|
||||||
|
return is_ok
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ci_filter(ci_id, other_filter):
|
||||||
|
from api.lib.cmdb.search import SearchError
|
||||||
|
from api.lib.cmdb.search.ci import search
|
||||||
|
|
||||||
|
query = "_id:{},{}".format(ci_id, other_filter)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, _, _, _, numfound, _ = search(query).search()
|
||||||
|
return numfound
|
||||||
|
except SearchError as e:
|
||||||
|
current_app.logger.warning("ci search failed: {}".format(e))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def fire(cls, operate_type, ci_dict, record_id):
|
||||||
|
type_id = ci_dict.get('_type')
|
||||||
|
triggers = cls.get(type_id) or []
|
||||||
|
|
||||||
|
for trigger in triggers:
|
||||||
|
if not trigger.option.get('enable'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if trigger.option.get('filter') and not cls.ci_filter(ci_dict.get('_id'), trigger.option['filter']):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if trigger.option.get('attr_ids') and isinstance(trigger.option['attr_ids'], list):
|
||||||
|
if not (set(trigger.option['attr_ids']) &
|
||||||
|
set([i.attr_id for i in AttributeHistory.get_by(record_id=record_id, to_dict=False)])):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if trigger.option.get('action') == operate_type:
|
||||||
|
if trigger.option.get('webhooks'):
|
||||||
|
cls._exec_webhook(operate_type, trigger.option['webhooks'], ci_dict, trigger.id, record_id)
|
||||||
|
elif trigger.option.get('notifies'):
|
||||||
|
cls._exec_notify(operate_type, trigger.option['notifies'], ci_dict, trigger.id, record_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def waiting_cis(cls, trigger):
|
||||||
|
now = datetime.datetime.today()
|
||||||
|
|
||||||
|
delta_time = datetime.timedelta(days=(trigger.option.get('before_days', 0) or 0))
|
||||||
|
|
||||||
|
attr = AttributeCache.get(trigger.attr_id)
|
||||||
|
|
||||||
|
value_table = TableMap(attr=attr).table
|
||||||
|
|
||||||
|
values = value_table.get_by(attr_id=attr.id, to_dict=False)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for v in values:
|
||||||
|
if (isinstance(v.value, (datetime.date, datetime.datetime)) and
|
||||||
|
(v.value - delta_time).strftime('%Y%m%d') == now.strftime("%Y%m%d")):
|
||||||
|
|
||||||
|
if trigger.option.get('filter') and not cls.ci_filter(v.ci_id, trigger.option['filter']):
|
||||||
|
continue
|
||||||
|
|
||||||
|
result.append(v)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def trigger_notify(cls, trigger, ci):
|
||||||
|
"""
|
||||||
|
only for date attribute
|
||||||
|
:param trigger:
|
||||||
|
:param ci:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
if (trigger.notify.get('notify_at') == datetime.datetime.now().strftime("%H:%M") or
|
||||||
|
not trigger.option.get('notify_at')):
|
||||||
|
threading.Thread(target=cls._exec_notify, args=(
|
||||||
|
None, trigger.option['notifies'], None, trigger.id, None, ci.id)).start()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
|
@ -1166,16 +1166,18 @@ class CITypeUniqueConstraintManager(object):
|
||||||
|
|
||||||
class CITypeTriggerManager(object):
|
class CITypeTriggerManager(object):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get(type_id):
|
def get(type_id, to_dict=True):
|
||||||
return CITypeTrigger.get_by(type_id=type_id, to_dict=True)
|
return CITypeTrigger.get_by(type_id=type_id, to_dict=to_dict)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def add(type_id, attr_id, notify):
|
def add(type_id, attr_id, option):
|
||||||
CITypeTrigger.get_by(type_id=type_id, attr_id=attr_id) and abort(400, ErrFormat.ci_type_trigger_duplicate)
|
for i in CITypeTrigger.get_by(type_id=type_id, attr_id=attr_id, to_dict=False):
|
||||||
|
if i.option == option:
|
||||||
|
return abort(400, ErrFormat.ci_type_trigger_duplicate)
|
||||||
|
|
||||||
not isinstance(notify, dict) and abort(400, ErrFormat.argument_invalid.format("notify"))
|
not isinstance(option, dict) and abort(400, ErrFormat.argument_invalid.format("option"))
|
||||||
|
|
||||||
trigger = CITypeTrigger.create(type_id=type_id, attr_id=attr_id, notify=notify)
|
trigger = CITypeTrigger.create(type_id=type_id, attr_id=attr_id, option=option)
|
||||||
|
|
||||||
CITypeHistoryManager.add(CITypeOperateType.ADD_TRIGGER,
|
CITypeHistoryManager.add(CITypeOperateType.ADD_TRIGGER,
|
||||||
type_id,
|
type_id,
|
||||||
|
@ -1185,12 +1187,12 @@ class CITypeTriggerManager(object):
|
||||||
return trigger.to_dict()
|
return trigger.to_dict()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def update(_id, notify):
|
def update(_id, option):
|
||||||
existed = (CITypeTrigger.get_by_id(_id) or
|
existed = (CITypeTrigger.get_by_id(_id) or
|
||||||
abort(404, ErrFormat.ci_type_trigger_not_found.format("id={}".format(_id))))
|
abort(404, ErrFormat.ci_type_trigger_not_found.format("id={}".format(_id))))
|
||||||
|
|
||||||
existed2 = existed.to_dict()
|
existed2 = existed.to_dict()
|
||||||
new = existed.update(notify=notify)
|
new = existed.update(option=option)
|
||||||
|
|
||||||
CITypeHistoryManager.add(CITypeOperateType.UPDATE_TRIGGER,
|
CITypeHistoryManager.add(CITypeOperateType.UPDATE_TRIGGER,
|
||||||
existed.type_id,
|
existed.type_id,
|
||||||
|
@ -1210,35 +1212,3 @@ class CITypeTriggerManager(object):
|
||||||
existed.type_id,
|
existed.type_id,
|
||||||
trigger_id=_id,
|
trigger_id=_id,
|
||||||
change=existed.to_dict())
|
change=existed.to_dict())
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def waiting_cis(trigger):
|
|
||||||
now = datetime.datetime.today()
|
|
||||||
|
|
||||||
delta_time = datetime.timedelta(days=(trigger.notify.get('before_days', 0) or 0))
|
|
||||||
|
|
||||||
attr = AttributeCache.get(trigger.attr_id)
|
|
||||||
|
|
||||||
value_table = TableMap(attr=attr).table
|
|
||||||
|
|
||||||
values = value_table.get_by(attr_id=attr.id, to_dict=False)
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for v in values:
|
|
||||||
if (isinstance(v.value, (datetime.date, datetime.datetime)) and
|
|
||||||
(v.value - delta_time).strftime('%Y%m%d') == now.strftime("%Y%m%d")):
|
|
||||||
result.append(v)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def trigger_notify(trigger, ci):
|
|
||||||
if (trigger.notify.get('notify_at') == datetime.datetime.now().strftime("%H:%M") or
|
|
||||||
not trigger.notify.get('notify_at')):
|
|
||||||
from api.tasks.cmdb import trigger_notify
|
|
||||||
|
|
||||||
trigger_notify.apply_async(args=(trigger.notify, ci.ci_id), queue=CMDB_QUEUE)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
|
@ -16,6 +16,7 @@ from api.lib.perm.acl.cache import UserCache
|
||||||
from api.models.cmdb import Attribute
|
from api.models.cmdb import Attribute
|
||||||
from api.models.cmdb import AttributeHistory
|
from api.models.cmdb import AttributeHistory
|
||||||
from api.models.cmdb import CIRelationHistory
|
from api.models.cmdb import CIRelationHistory
|
||||||
|
from api.models.cmdb import CITriggerHistory
|
||||||
from api.models.cmdb import CITypeHistory
|
from api.models.cmdb import CITypeHistory
|
||||||
from api.models.cmdb import CITypeTrigger
|
from api.models.cmdb import CITypeTrigger
|
||||||
from api.models.cmdb import CITypeUniqueConstraint
|
from api.models.cmdb import CITypeUniqueConstraint
|
||||||
|
@ -286,3 +287,67 @@ class CITypeHistoryManager(object):
|
||||||
change=change)
|
change=change)
|
||||||
|
|
||||||
CITypeHistory.create(**payload)
|
CITypeHistory.create(**payload)
|
||||||
|
|
||||||
|
|
||||||
|
class CITriggerHistoryManager(object):
|
||||||
|
@staticmethod
|
||||||
|
def get(page, page_size, type_id=None, trigger_id=None, operate_type=None):
|
||||||
|
query = CITriggerHistory.get_by(only_query=True)
|
||||||
|
if type_id is not None:
|
||||||
|
query = query.filter(CITriggerHistory.type_id == type_id)
|
||||||
|
|
||||||
|
if trigger_id:
|
||||||
|
query = query.filter(CITriggerHistory.trigger_id == trigger_id)
|
||||||
|
|
||||||
|
if operate_type is not None:
|
||||||
|
query = query.filter(CITriggerHistory.operate_type == operate_type)
|
||||||
|
|
||||||
|
numfound = query.count()
|
||||||
|
|
||||||
|
query = query.order_by(CITriggerHistory.id.desc())
|
||||||
|
result = query.offset((page - 1) * page_size).limit(page_size)
|
||||||
|
result = [i.to_dict() for i in result]
|
||||||
|
for res in result:
|
||||||
|
if res.get('trigger_id'):
|
||||||
|
trigger = CITypeTrigger.get_by_id(res['trigger_id'])
|
||||||
|
res['trigger'] = trigger and trigger.to_dict()
|
||||||
|
|
||||||
|
return numfound, result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_by_ci_id(ci_id):
|
||||||
|
res = db.session.query(CITriggerHistory, CITypeTrigger, OperationRecord).join(
|
||||||
|
CITypeTrigger, CITypeTrigger.id == CITriggerHistory.trigger_id).join(
|
||||||
|
OperationRecord, OperationRecord.id == CITriggerHistory.record_id).filter(
|
||||||
|
CITriggerHistory.ci_id == ci_id).order_by(CITriggerHistory.id.desc())
|
||||||
|
|
||||||
|
result = []
|
||||||
|
id2trigger = dict()
|
||||||
|
for i in res:
|
||||||
|
hist = i.CITriggerHistory
|
||||||
|
record = i.OperationRecord
|
||||||
|
item = dict(is_ok=hist.is_ok,
|
||||||
|
operate_type=hist.operate_type,
|
||||||
|
notify=hist.notify,
|
||||||
|
webhook=hist.webhook,
|
||||||
|
created_at=record.created_at.strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
record_id=record.id,
|
||||||
|
hid=hist.id
|
||||||
|
)
|
||||||
|
if i.CITypeTrigger.id not in id2trigger:
|
||||||
|
id2trigger[i.CITypeTrigger.id] = i.CITypeTrigger.to_dict()
|
||||||
|
|
||||||
|
result.append(item)
|
||||||
|
|
||||||
|
return dict(items=result, id2trigger=id2trigger)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def add(operate_type, record_id, ci_id, trigger_id, is_ok=False, notify=None, webhook=None):
|
||||||
|
|
||||||
|
CITriggerHistory.create(operate_type=operate_type,
|
||||||
|
record_id=record_id,
|
||||||
|
ci_id=ci_id,
|
||||||
|
trigger_id=trigger_id,
|
||||||
|
is_ok=is_ok,
|
||||||
|
notify=notify,
|
||||||
|
webhook=webhook)
|
||||||
|
|
|
@ -18,7 +18,6 @@ from api.extensions import db
|
||||||
from api.lib.cmdb.attribute import AttributeManager
|
from api.lib.cmdb.attribute import AttributeManager
|
||||||
from api.lib.cmdb.cache import AttributeCache
|
from api.lib.cmdb.cache import AttributeCache
|
||||||
from api.lib.cmdb.cache import CITypeAttributeCache
|
from api.lib.cmdb.cache import CITypeAttributeCache
|
||||||
from api.lib.cmdb.const import ExistPolicy
|
|
||||||
from api.lib.cmdb.const import OperateType
|
from api.lib.cmdb.const import OperateType
|
||||||
from api.lib.cmdb.const import ValueTypeEnum
|
from api.lib.cmdb.const import ValueTypeEnum
|
||||||
from api.lib.cmdb.history import AttributeHistoryManger
|
from api.lib.cmdb.history import AttributeHistoryManger
|
||||||
|
@ -140,6 +139,7 @@ class AttributeValueManager(object):
|
||||||
try:
|
try:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
current_app.logger.error("write change failed: {}".format(str(e)))
|
current_app.logger.error("write change failed: {}".format(str(e)))
|
||||||
|
|
||||||
return record_id
|
return record_id
|
||||||
|
@ -235,7 +235,7 @@ class AttributeValueManager(object):
|
||||||
|
|
||||||
return key2attr
|
return key2attr
|
||||||
|
|
||||||
def create_or_update_attr_value2(self, ci, ci_dict, key2attr):
|
def create_or_update_attr_value(self, ci, ci_dict, key2attr):
|
||||||
"""
|
"""
|
||||||
add or update attribute value, then write history
|
add or update attribute value, then write history
|
||||||
:param ci: instance object
|
:param ci: instance object
|
||||||
|
@ -288,66 +288,6 @@ class AttributeValueManager(object):
|
||||||
|
|
||||||
return self._write_change2(changed)
|
return self._write_change2(changed)
|
||||||
|
|
||||||
def create_or_update_attr_value(self, key, value, ci, _no_attribute_policy=ExistPolicy.IGNORE, record_id=None):
|
|
||||||
"""
|
|
||||||
add or update attribute value, then write history
|
|
||||||
:param key: id, name or alias
|
|
||||||
:param value:
|
|
||||||
:param ci: instance object
|
|
||||||
:param _no_attribute_policy: ignore or reject
|
|
||||||
:param record_id: op record
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
attr = self._get_attr(key)
|
|
||||||
if attr is None:
|
|
||||||
if _no_attribute_policy == ExistPolicy.IGNORE:
|
|
||||||
return
|
|
||||||
if _no_attribute_policy == ExistPolicy.REJECT:
|
|
||||||
return abort(400, ErrFormat.attribute_not_found.format(key))
|
|
||||||
|
|
||||||
value_table = TableMap(attr=attr).table
|
|
||||||
|
|
||||||
try:
|
|
||||||
if attr.is_list:
|
|
||||||
value_list = [self._validate(attr, i, value_table, ci) for i in handle_arg_list(value)]
|
|
||||||
if not value_list:
|
|
||||||
self._check_is_required(ci.type_id, attr, '')
|
|
||||||
|
|
||||||
existed_attrs = value_table.get_by(attr_id=attr.id, ci_id=ci.id, to_dict=False)
|
|
||||||
existed_values = [i.value for i in existed_attrs]
|
|
||||||
added = set(value_list) - set(existed_values)
|
|
||||||
deleted = set(existed_values) - set(value_list)
|
|
||||||
for v in added:
|
|
||||||
value_table.create(ci_id=ci.id, attr_id=attr.id, value=v)
|
|
||||||
record_id = self._write_change(ci.id, attr.id, OperateType.ADD, None, v, record_id, ci.type_id)
|
|
||||||
|
|
||||||
for v in deleted:
|
|
||||||
existed_attr = existed_attrs[existed_values.index(v)]
|
|
||||||
existed_attr.delete()
|
|
||||||
record_id = self._write_change(ci.id, attr.id, OperateType.DELETE, v, None, record_id, ci.type_id)
|
|
||||||
else:
|
|
||||||
value = self._validate(attr, value, value_table, ci)
|
|
||||||
existed_attr = value_table.get_by(attr_id=attr.id, ci_id=ci.id, first=True, to_dict=False)
|
|
||||||
existed_value = existed_attr and existed_attr.value
|
|
||||||
if existed_value is None and value is not None:
|
|
||||||
value_table.create(ci_id=ci.id, attr_id=attr.id, value=value)
|
|
||||||
|
|
||||||
record_id = self._write_change(ci.id, attr.id, OperateType.ADD, None, value, record_id, ci.type_id)
|
|
||||||
else:
|
|
||||||
if existed_value != value:
|
|
||||||
if value is None:
|
|
||||||
existed_attr.delete()
|
|
||||||
else:
|
|
||||||
existed_attr.update(value=value)
|
|
||||||
|
|
||||||
record_id = self._write_change(ci.id, attr.id, OperateType.UPDATE,
|
|
||||||
existed_value, value, record_id, ci.type_id)
|
|
||||||
|
|
||||||
return record_id
|
|
||||||
except Exception as e:
|
|
||||||
current_app.logger.warning(str(e))
|
|
||||||
return abort(400, ErrFormat.attribute_value_invalid2.format("{}({})".format(attr.alias, attr.name), value))
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def delete_attr_value(attr_id, ci_id):
|
def delete_attr_value(attr_id, ci_id):
|
||||||
attr = AttributeCache.get(attr_id)
|
attr = AttributeCache.get(attr_id)
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
# -*- coding:utf-8 -*-
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from flask import current_app
|
||||||
|
from jinja2 import Template
|
||||||
|
|
||||||
|
from api.lib.mail import send_mail
|
||||||
|
|
||||||
|
|
||||||
|
def _request_messenger(subject, body, tos, sender):
|
||||||
|
params = dict(sender=sender, title=subject,
|
||||||
|
tos=[to[sender] for to in tos if to.get(sender)])
|
||||||
|
|
||||||
|
if not params['tos']:
|
||||||
|
raise Exception("no receivers")
|
||||||
|
|
||||||
|
if sender == "email":
|
||||||
|
params['msgtype'] = 'text/html'
|
||||||
|
params['content'] = body
|
||||||
|
else:
|
||||||
|
params['msgtype'] = 'text'
|
||||||
|
params['content'] = json.dumps(dict(content=subject or body))
|
||||||
|
|
||||||
|
resp = requests.post(current_app.config.get('MESSENGER_URL'), json=params)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise Exception(resp.text)
|
||||||
|
|
||||||
|
return resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def notify_send(subject, body, methods, tos, payload=None):
|
||||||
|
payload = payload or {}
|
||||||
|
subject = Template(subject).render(payload)
|
||||||
|
body = Template(body).render(payload)
|
||||||
|
|
||||||
|
res = ''
|
||||||
|
for method in methods:
|
||||||
|
if method == "email" and not current_app.config.get('USE_MESSENGER', True):
|
||||||
|
send_mail(None, [to.get('email') for to in tos], subject, body)
|
||||||
|
|
||||||
|
res += _request_messenger(subject, body, tos, method) + "\n"
|
||||||
|
|
||||||
|
return res
|
|
@ -0,0 +1,105 @@
|
||||||
|
# -*- coding:utf-8 -*-
|
||||||
|
|
||||||
|
import json
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from jinja2 import Template
|
||||||
|
from requests.auth import HTTPBasicAuth
|
||||||
|
from requests_oauthlib import OAuth2Session
|
||||||
|
|
||||||
|
|
||||||
|
class BearerAuth(requests.auth.AuthBase):
|
||||||
|
def __init__(self, token):
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
def __call__(self, r):
|
||||||
|
r.headers["authorization"] = "Bearer {}".format(self.token)
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_auth(**kwargs):
|
||||||
|
auth_type = (kwargs.get('type') or "").lower()
|
||||||
|
if auth_type == "basicauth":
|
||||||
|
return HTTPBasicAuth(kwargs.get('username'), kwargs.get('password'))
|
||||||
|
|
||||||
|
elif auth_type == "bearer":
|
||||||
|
return BearerAuth(kwargs.get('token'))
|
||||||
|
|
||||||
|
elif auth_type == 'oauth2.0':
|
||||||
|
client_id = kwargs.get('client_id')
|
||||||
|
client_secret = kwargs.get('client_secret')
|
||||||
|
authorization_base_url = kwargs.get('authorization_base_url')
|
||||||
|
token_url = kwargs.get('token_url')
|
||||||
|
redirect_url = kwargs.get('redirect_url')
|
||||||
|
scope = kwargs.get('scope')
|
||||||
|
|
||||||
|
oauth2_session = OAuth2Session(client_id, scope=scope or None)
|
||||||
|
oauth2_session.authorization_url(authorization_base_url)
|
||||||
|
|
||||||
|
oauth2_session.fetch_token(token_url, client_secret=client_secret, authorization_response=redirect_url)
|
||||||
|
|
||||||
|
return oauth2_session
|
||||||
|
|
||||||
|
elif auth_type == "apikey":
|
||||||
|
return HTTPBasicAuth(kwargs.get('key'), kwargs.get('value'))
|
||||||
|
|
||||||
|
|
||||||
|
def webhook_request(webhook, payload):
|
||||||
|
"""
|
||||||
|
|
||||||
|
:param webhook:
|
||||||
|
{
|
||||||
|
"url": "https://veops.cn"
|
||||||
|
"method": "GET|POST|PUT|DELETE"
|
||||||
|
"body": {},
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "Application/json"
|
||||||
|
},
|
||||||
|
"parameters": {
|
||||||
|
"key": "value"
|
||||||
|
},
|
||||||
|
"authorization": {
|
||||||
|
"type": "BasicAuth|Bearer|OAuth2.0|APIKey",
|
||||||
|
"password": "mmmm", # BasicAuth
|
||||||
|
"username": "bbb", # BasicAuth
|
||||||
|
|
||||||
|
"token": "xxx", # Bearer
|
||||||
|
|
||||||
|
"key": "xxx", # APIKey
|
||||||
|
"value": "xxx", # APIKey
|
||||||
|
|
||||||
|
"client_id": "xxx", # OAuth2.0
|
||||||
|
"client_secret": "xxx", # OAuth2.0
|
||||||
|
"authorization_base_url": "xxx", # OAuth2.0
|
||||||
|
"token_url": "xxx", # OAuth2.0
|
||||||
|
"redirect_url": "xxx", # OAuth2.0
|
||||||
|
"scope": "xxx" # OAuth2.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:param payload:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
assert webhook.get('url') is not None
|
||||||
|
|
||||||
|
url = Template(webhook['url']).render(payload)
|
||||||
|
|
||||||
|
params = webhook.get('parameters') or None
|
||||||
|
if isinstance(params, dict):
|
||||||
|
params = json.loads(Template(json.dumps(params)).render(payload))
|
||||||
|
|
||||||
|
data = Template(json.dumps(webhook.get('body', ''))).render(payload)
|
||||||
|
auth = _wrap_auth(**webhook.get('authorization', {}))
|
||||||
|
|
||||||
|
if (webhook.get('authorization', {}).get("type") or '').lower() == 'oauth2.0':
|
||||||
|
request = getattr(auth, webhook.get('method', 'GET').lower())
|
||||||
|
else:
|
||||||
|
request = partial(requests.request, webhook.get('method', 'GET'))
|
||||||
|
|
||||||
|
return request(
|
||||||
|
url,
|
||||||
|
params=params,
|
||||||
|
headers=webhook.get('headers') or None,
|
||||||
|
data=data,
|
||||||
|
auth=auth
|
||||||
|
)
|
|
@ -125,16 +125,26 @@ class CITypeAttributeGroupItem(Model):
|
||||||
|
|
||||||
|
|
||||||
class CITypeTrigger(Model):
|
class CITypeTrigger(Model):
|
||||||
# __tablename__ = "c_ci_type_triggers"
|
|
||||||
__tablename__ = "c_c_t_t"
|
__tablename__ = "c_c_t_t"
|
||||||
|
|
||||||
type_id = db.Column(db.Integer, db.ForeignKey('c_ci_types.id'), nullable=False)
|
type_id = db.Column(db.Integer, db.ForeignKey('c_ci_types.id'), nullable=False)
|
||||||
attr_id = db.Column(db.Integer, db.ForeignKey("c_attributes.id"), nullable=False)
|
attr_id = db.Column(db.Integer, db.ForeignKey("c_attributes.id"))
|
||||||
notify = db.Column(db.JSON) # {subject: x, body: x, wx_to: [], mail_to: [], before_days: 0, notify_at: 08:00}
|
option = db.Column('notify', db.JSON)
|
||||||
|
|
||||||
|
|
||||||
|
class CITriggerHistory(Model):
|
||||||
|
__tablename__ = "c_ci_trigger_histories"
|
||||||
|
|
||||||
|
operate_type = db.Column(db.Enum(*OperateType.all(), name="operate_type"))
|
||||||
|
record_id = db.Column(db.Integer, db.ForeignKey("c_records.id"))
|
||||||
|
ci_id = db.Column(db.Integer, index=True, nullable=False)
|
||||||
|
trigger_id = db.Column(db.Integer, db.ForeignKey("c_c_t_t.id"))
|
||||||
|
is_ok = db.Column(db.Boolean, default=False)
|
||||||
|
notify = db.Column(db.Text)
|
||||||
|
webhook = db.Column(db.Text)
|
||||||
|
|
||||||
|
|
||||||
class CITypeUniqueConstraint(Model):
|
class CITypeUniqueConstraint(Model):
|
||||||
# __tablename__ = "c_ci_type_unique_constraints"
|
|
||||||
__tablename__ = "c_c_t_u_c"
|
__tablename__ = "c_c_t_u_c"
|
||||||
|
|
||||||
type_id = db.Column(db.Integer, db.ForeignKey('c_ci_types.id'), nullable=False)
|
type_id = db.Column(db.Integer, db.ForeignKey('c_ci_types.id'), nullable=False)
|
||||||
|
@ -363,7 +373,6 @@ class CITypeHistory(Model):
|
||||||
|
|
||||||
# preference
|
# preference
|
||||||
class PreferenceShowAttributes(Model):
|
class PreferenceShowAttributes(Model):
|
||||||
# __tablename__ = "c_preference_show_attributes"
|
|
||||||
__tablename__ = "c_psa"
|
__tablename__ = "c_psa"
|
||||||
|
|
||||||
uid = db.Column(db.Integer, index=True, nullable=False)
|
uid = db.Column(db.Integer, index=True, nullable=False)
|
||||||
|
@ -377,7 +386,6 @@ class PreferenceShowAttributes(Model):
|
||||||
|
|
||||||
|
|
||||||
class PreferenceTreeView(Model):
|
class PreferenceTreeView(Model):
|
||||||
# __tablename__ = "c_preference_tree_views"
|
|
||||||
__tablename__ = "c_ptv"
|
__tablename__ = "c_ptv"
|
||||||
|
|
||||||
uid = db.Column(db.Integer, index=True, nullable=False)
|
uid = db.Column(db.Integer, index=True, nullable=False)
|
||||||
|
@ -386,7 +394,6 @@ class PreferenceTreeView(Model):
|
||||||
|
|
||||||
|
|
||||||
class PreferenceRelationView(Model):
|
class PreferenceRelationView(Model):
|
||||||
# __tablename__ = "c_preference_relation_views"
|
|
||||||
__tablename__ = "c_prv"
|
__tablename__ = "c_prv"
|
||||||
|
|
||||||
uid = db.Column(db.Integer, index=True, nullable=False)
|
uid = db.Column(db.Integer, index=True, nullable=False)
|
||||||
|
|
|
@ -185,8 +185,8 @@ class CIUnique(APIView):
|
||||||
@has_perm_from_args("ci_id", ResourceTypeEnum.CI, PermEnum.UPDATE, CIManager.get_type_name)
|
@has_perm_from_args("ci_id", ResourceTypeEnum.CI, PermEnum.UPDATE, CIManager.get_type_name)
|
||||||
def put(self, ci_id):
|
def put(self, ci_id):
|
||||||
params = request.values
|
params = request.values
|
||||||
unique_name = params.keys()[0]
|
unique_name = list(params.keys())[0]
|
||||||
unique_value = params.values()[0]
|
unique_value = list(params.values())[0]
|
||||||
|
|
||||||
CIManager.update_unique_value(ci_id, unique_name, unique_value)
|
CIManager.update_unique_value(ci_id, unique_name, unique_value)
|
||||||
|
|
||||||
|
|
|
@ -419,22 +419,21 @@ class CITypeTriggerView(APIView):
|
||||||
return self.jsonify(CITypeTriggerManager.get(type_id))
|
return self.jsonify(CITypeTriggerManager.get(type_id))
|
||||||
|
|
||||||
@has_perm_from_args("type_id", ResourceTypeEnum.CI, PermEnum.CONFIG, CITypeManager.get_name_by_id)
|
@has_perm_from_args("type_id", ResourceTypeEnum.CI, PermEnum.CONFIG, CITypeManager.get_name_by_id)
|
||||||
@args_required("attr_id")
|
@args_required("option")
|
||||||
@args_required("notify")
|
|
||||||
def post(self, type_id):
|
def post(self, type_id):
|
||||||
attr_id = request.values.get('attr_id')
|
attr_id = request.values.get('attr_id') or None
|
||||||
notify = request.values.get('notify')
|
option = request.values.get('option')
|
||||||
|
|
||||||
return self.jsonify(CITypeTriggerManager().add(type_id, attr_id, notify))
|
return self.jsonify(CITypeTriggerManager().add(type_id, attr_id, option))
|
||||||
|
|
||||||
@has_perm_from_args("type_id", ResourceTypeEnum.CI, PermEnum.CONFIG, CITypeManager.get_name_by_id)
|
@has_perm_from_args("type_id", ResourceTypeEnum.CI, PermEnum.CONFIG, CITypeManager.get_name_by_id)
|
||||||
@args_required("notify")
|
@args_required("option")
|
||||||
def put(self, type_id, _id):
|
def put(self, type_id, _id):
|
||||||
assert type_id is not None
|
assert type_id is not None
|
||||||
|
|
||||||
notify = request.values.get('notify')
|
option = request.values.get('option')
|
||||||
|
|
||||||
return self.jsonify(CITypeTriggerManager().update(_id, notify))
|
return self.jsonify(CITypeTriggerManager().update(_id, option))
|
||||||
|
|
||||||
@has_perm_from_args("type_id", ResourceTypeEnum.CI, PermEnum.CONFIG, CITypeManager.get_name_by_id)
|
@has_perm_from_args("type_id", ResourceTypeEnum.CI, PermEnum.CONFIG, CITypeManager.get_name_by_id)
|
||||||
def delete(self, type_id, _id):
|
def delete(self, type_id, _id):
|
||||||
|
|
|
@ -5,15 +5,18 @@ import datetime
|
||||||
|
|
||||||
from flask import abort
|
from flask import abort
|
||||||
from flask import request
|
from flask import request
|
||||||
|
from flask import session
|
||||||
|
|
||||||
from api.lib.cmdb.ci import CIManager
|
from api.lib.cmdb.ci import CIManager
|
||||||
from api.lib.cmdb.const import PermEnum
|
from api.lib.cmdb.const import PermEnum
|
||||||
from api.lib.cmdb.const import ResourceTypeEnum
|
from api.lib.cmdb.const import ResourceTypeEnum
|
||||||
from api.lib.cmdb.const import RoleEnum
|
from api.lib.cmdb.const import RoleEnum
|
||||||
from api.lib.cmdb.history import AttributeHistoryManger
|
from api.lib.cmdb.history import AttributeHistoryManger
|
||||||
|
from api.lib.cmdb.history import CITriggerHistoryManager
|
||||||
from api.lib.cmdb.history import CITypeHistoryManager
|
from api.lib.cmdb.history import CITypeHistoryManager
|
||||||
from api.lib.cmdb.resp_format import ErrFormat
|
from api.lib.cmdb.resp_format import ErrFormat
|
||||||
from api.lib.perm.acl.acl import has_perm_from_args
|
from api.lib.perm.acl.acl import has_perm_from_args
|
||||||
|
from api.lib.perm.acl.acl import is_app_admin
|
||||||
from api.lib.perm.acl.acl import role_required
|
from api.lib.perm.acl.acl import role_required
|
||||||
from api.lib.utils import get_page
|
from api.lib.utils import get_page
|
||||||
from api.lib.utils import get_page_size
|
from api.lib.utils import get_page_size
|
||||||
|
@ -76,6 +79,39 @@ class CIHistoryView(APIView):
|
||||||
return self.jsonify(result)
|
return self.jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
class CITriggerHistoryView(APIView):
|
||||||
|
url_prefix = ("/history/ci_triggers/<int:ci_id>", "/history/ci_triggers")
|
||||||
|
|
||||||
|
@has_perm_from_args("ci_id", ResourceTypeEnum.CI, PermEnum.READ, CIManager.get_type_name)
|
||||||
|
def get(self, ci_id=None):
|
||||||
|
if ci_id is not None:
|
||||||
|
result = CITriggerHistoryManager.get_by_ci_id(ci_id)
|
||||||
|
|
||||||
|
return self.jsonify(result)
|
||||||
|
|
||||||
|
if RoleEnum.CONFIG not in session.get("acl", {}).get("parentRoles", []) and not is_app_admin("cmdb"):
|
||||||
|
return abort(403, ErrFormat.role_required.format(RoleEnum.CONFIG))
|
||||||
|
|
||||||
|
type_id = request.values.get("type_id")
|
||||||
|
trigger_id = request.values.get("trigger_id")
|
||||||
|
operate_type = request.values.get("operate_type")
|
||||||
|
|
||||||
|
page = get_page(request.values.get('page', 1))
|
||||||
|
page_size = get_page_size(request.values.get('page_size', 1))
|
||||||
|
|
||||||
|
numfound, result = CITriggerHistoryManager.get(page,
|
||||||
|
page_size,
|
||||||
|
type_id=type_id,
|
||||||
|
trigger_id=trigger_id,
|
||||||
|
operate_type=operate_type)
|
||||||
|
|
||||||
|
return self.jsonify(page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
numfound=numfound,
|
||||||
|
total=len(result),
|
||||||
|
result=result)
|
||||||
|
|
||||||
|
|
||||||
class CITypeHistoryView(APIView):
|
class CITypeHistoryView(APIView):
|
||||||
url_prefix = "/history/ci_types"
|
url_prefix = "/history/ci_types"
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,7 @@ python-ldap==3.4.0
|
||||||
PyYAML==6.0
|
PyYAML==6.0
|
||||||
redis==4.6.0
|
redis==4.6.0
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
|
requests_oauthlib==1.3.1
|
||||||
six==1.12.0
|
six==1.12.0
|
||||||
SQLAlchemy==1.4.49
|
SQLAlchemy==1.4.49
|
||||||
supervisor==4.0.3
|
supervisor==4.0.3
|
||||||
|
|
Loading…
Reference in New Issue