From 1cc8c89e48226492786f03f6d946e603d977111c Mon Sep 17 00:00:00 2001 From: Sergey Klyuykov Date: Tue, 11 Apr 2023 22:38:41 -0600 Subject: [PATCH 1/5] Feature(backend): Setup filename for ANSIBLE_STRING plugin. Note: Required for use with ansible inventory plugins, like openstack, vmware, etc. --- polemarch/plugins/inventory/ansible.py | 9 ++++++++- tests.py | 20 +++++++++++--------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/polemarch/plugins/inventory/ansible.py b/polemarch/plugins/inventory/ansible.py index d3c0cd60..0a817f9f 100644 --- a/polemarch/plugins/inventory/ansible.py +++ b/polemarch/plugins/inventory/ansible.py @@ -14,6 +14,7 @@ from django.db import transaction from rest_framework import fields as drffields from vstutils.api import fields as vstfields +from vstutils.api.validators import RegularExpressionValidator from .base import BasePlugin from ...main.constants import HiddenVariablesEnum, CYPHER from ...main.utils import AnsibleInventoryParser @@ -21,6 +22,10 @@ from ...main.models.vars import AbstractVarsQuerySet +class FilenameValidator(RegularExpressionValidator): + regexp = re.compile(r"^([\d\w\-_\.])*$", re.MULTILINE) + + class InventoryDumper(Dumper): """ Yaml Dumper class for PyYAML @@ -183,6 +188,7 @@ class AnsibleString(BaseAnsiblePlugin): serializer_fields = { 'body': vstfields.FileInStringField(), + 'filename': vstfields.CharField(required=False, allow_blank=True, default='', validators=[FilenameValidator]), 'extension': vstfields.AutoCompletionField(autocomplete=('yaml', 'ini', 'json'), default='yaml'), 'executable': drffields.BooleanField(default=False), } @@ -191,13 +197,14 @@ class AnsibleString(BaseAnsiblePlugin): } defaults = { 'body': '', + 'filename': '', 'extension': 'yaml', 'executable': False, } def render_inventory(self, instance, execution_dir) -> Tuple[Path, list]: state_data = instance.inventory_state.data - filename = f'inventory_{uuid1()}' + filename = state_data.get('filename') or f'inventory_{uuid1()}' if state_data['extension']: filename += f'.{state_data["extension"]}' diff --git a/tests.py b/tests.py index 65734319..2b18d1e3 100644 --- a/tests.py +++ b/tests.py @@ -634,6 +634,7 @@ def test_ansible_string_inventory(self): self.assertEqual(results[0]['data']['plugin'], 'ANSIBLE_STRING') self.assertDictEqual(results[1]['data']['data'], { 'body': '', + 'filename': '', 'extension': 'yaml', 'executable': False, }) @@ -927,26 +928,31 @@ def test_import_ansible_string_inventory(self): self.assertDictEqual(results[1]['data']['data'], { 'extension': 'json', 'executable': False, + 'filename': '', 'body': '{"json": true}', }) self.assertDictEqual(results[3]['data']['data'], { 'extension': 'yml', 'executable': False, + 'filename': '', 'body': '---\nyml:\n true', }) self.assertDictEqual(results[5]['data']['data'], { 'extension': 'ini', 'executable': False, + 'filename': '', 'body': '[example]\nini = true', }) self.assertDictEqual(results[7]['data']['data'], { 'extension': 'sh', 'executable': True, + 'filename': '', 'body': '#!/bin/sh\necho example', }) self.assertDictEqual(results[9]['data']['data'], { 'extension': '', 'executable': False, + 'filename': '', 'body': '', }) @@ -3959,8 +3965,7 @@ def test_override_ansible_cfg_in_project(self): playbook='playbook.yml', ), ]) - popen.assert_called_once() - self.assertTrue(popen.call_args[1]['env']['ANSIBLE_CONFIG'].endswith('/dir0/dir1/ansible.cfg')) + self.assertTrue(popen.call_args[-1]['env']['ANSIBLE_CONFIG'].endswith('/dir0/dir1/ansible.cfg')) var_id = self.get_model_filter('main.Variable').get(key='env_ANSIBLE_CONFIG').id @@ -3976,8 +3981,7 @@ def test_override_ansible_cfg_in_project(self): playbook='playbook.yml', ), ]) - popen.assert_called_once() - self.assertTrue(popen.call_args[1]['env']['ANSIBLE_CONFIG'].endswith('/ansible.cfg')) + self.assertTrue(popen.call_args[-1]['env']['ANSIBLE_CONFIG'].endswith('/ansible.cfg')) # check if env_ANSIBLE_CONFIG is not set and project's ansible.cfg does not exist than # os ANSIBLE_CONFIG env var is used @@ -3992,8 +3996,7 @@ def test_override_ansible_cfg_in_project(self): playbook='playbook.yml', ), ]) - popen.assert_called_once() - self.assertEqual(popen.call_args[1]['env']['ANSIBLE_CONFIG'], '/some/global.cfg') + self.assertEqual(popen.call_args[-1]['env']['ANSIBLE_CONFIG'], '/some/global.cfg') def test_add_vars_to_project(self): results = self.bulk([ @@ -4327,9 +4330,8 @@ def test_env_vars_on_execution(self): inventory=self.inventory.id, ) ]) - popen.assert_called_once() - self.assertEqual(popen.call_args[1]['env']['EXAMPLE'], '1') - self.assertIn('VST_PROJECT', popen.call_args[1]['env']) + self.assertEqual(popen.call_args[-1]['env']['EXAMPLE'], '1') + self.assertIn('VST_PROJECT', popen.call_args[-1]['env']) @own_projects_dir From df8c711417950424f8371fa8b77354ecbb528dca Mon Sep 17 00:00:00 2001 From: Sergey Klyuykov Date: Wed, 12 Apr 2023 00:13:05 -0600 Subject: [PATCH 2/5] Feature(backend): Use name of file for import_inventory to ANSIBLE_STRING plugin. --- polemarch/plugins/inventory/ansible.py | 8 +++----- tests.py | 10 +++++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/polemarch/plugins/inventory/ansible.py b/polemarch/plugins/inventory/ansible.py index 0a817f9f..c56852eb 100644 --- a/polemarch/plugins/inventory/ansible.py +++ b/polemarch/plugins/inventory/ansible.py @@ -220,14 +220,12 @@ def render_inventory(self, instance, execution_dir) -> Tuple[Path, list]: def import_inventory(cls, instance, data): loaded = orjson.loads(data['file']) # pylint: disable=no-member media_type = loaded['mediaType'] or '' - extension = mimetypes.guess_extension(media_type, strict=False) or '' - if extension != '': - extension = extension.replace('.', '', 1) - elif '.' in loaded['name']: - extension = loaded['name'].rsplit('.', maxsplit=1)[-1] + path_name = Path(loaded['name']) + extension = (mimetypes.guess_extension(media_type, strict=False) or path_name.suffix)[1:] body = base64.b64decode(loaded['content']).decode('utf-8') instance.update_inventory_state(data={ 'body': body, + 'filename': path_name.stem, 'extension': extension, 'executable': body.startswith('#!/'), }) diff --git a/tests.py b/tests.py index 2b18d1e3..871378a2 100644 --- a/tests.py +++ b/tests.py @@ -928,31 +928,31 @@ def test_import_ansible_string_inventory(self): self.assertDictEqual(results[1]['data']['data'], { 'extension': 'json', 'executable': False, - 'filename': '', + 'filename': '1', 'body': '{"json": true}', }) self.assertDictEqual(results[3]['data']['data'], { 'extension': 'yml', 'executable': False, - 'filename': '', + 'filename': '2', 'body': '---\nyml:\n true', }) self.assertDictEqual(results[5]['data']['data'], { 'extension': 'ini', 'executable': False, - 'filename': '', + 'filename': '3', 'body': '[example]\nini = true', }) self.assertDictEqual(results[7]['data']['data'], { 'extension': 'sh', 'executable': True, - 'filename': '', + 'filename': '4', 'body': '#!/bin/sh\necho example', }) self.assertDictEqual(results[9]['data']['data'], { 'extension': '', 'executable': False, - 'filename': '', + 'filename': 'unknown', 'body': '', }) From 23d045195f32c320eb138567461e57ad6535f881 Mon Sep 17 00:00:00 2001 From: Sergey Klyuykov Date: Wed, 12 Apr 2023 21:52:59 -0600 Subject: [PATCH 3/5] Chore(deps): Update vstutils to 5.4.2 ### Changelog: * Fix(backend): Provide to setup rpc engine and results backend from env in ``dockerrun``. * Fix(package): Do not store compressed files. * Chore(deps): Update rtd, prod and tests dependencies for backend. --- polemarch/__init__.py | 2 +- requirements-doc.txt | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/polemarch/__init__.py b/polemarch/__init__.py index 51cd2b32..ab19da1b 100644 --- a/polemarch/__init__.py +++ b/polemarch/__init__.py @@ -31,6 +31,6 @@ "VST_ROOT_URLCONF": os.getenv("VST_ROOT_URLCONF", 'vstutils.urls'), } -__version__ = "3.0.1" +__version__ = "3.0.2" prepare_environment(**default_settings) diff --git a/requirements-doc.txt b/requirements-doc.txt index e77fc5bf..0352aab4 100644 --- a/requirements-doc.txt +++ b/requirements-doc.txt @@ -1,4 +1,4 @@ # Docs -rrequirements.txt -vstutils[doc]~=5.4.0 +vstutils[doc]~=5.4.2 sphinxcontrib-openapi~=0.7.0 diff --git a/requirements.txt b/requirements.txt index 72b4ce28..81587387 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # Main -vstutils[rpc,prod]~=5.4.0 +vstutils[rpc,prod]~=5.4.2 markdown2~=2.4.8 # Repo types From a6524f7919de4598fbc15c48032a35e177997bab Mon Sep 17 00:00:00 2001 From: Sergey Klyuykov Date: Wed, 12 Apr 2023 21:54:01 -0600 Subject: [PATCH 4/5] Docs: Add more information about architecture, plugins and centrifugo settings. --- doc/config.rst | 39 +++++++++++++++++++++++++++++--- doc/gui.rst | 15 +++++++++++-- doc/installation.rst | 53 +++++++++----------------------------------- 3 files changed, 60 insertions(+), 47 deletions(-) diff --git a/doc/config.rst b/doc/config.rst index d6431345..cd998ca7 100644 --- a/doc/config.rst +++ b/doc/config.rst @@ -20,6 +20,35 @@ Polemarch nodes to maintain reliability or speedup things. It will give you understanding of services, which are included into Polemarch and how to distribute them between the nodes to reach your goal. +Project architecture +-------------------- + +Polemarch was created to adapt to any work environment. Almost every service can be easily replaced by another +without losing any functionality. The application architecture consists of the following elements: + +- **Database** supports all types and versions that django can. The code was written to be vendor agnostic + to support as many backends as possible. Database contains information about projects settings, schedule and templates + of tasks, execution history, authorisation data, etc. Database performance is a key performance limitation of the entire Polemarch. + +- **Cache** services is used for store session data, services locks, etc. Also, PM support all of Django can. + Mostly, we recommend to use Redis in small and medium clusters. + +- **MQ** or rpc engine is required for notifying celery worker about new task execution request. + Redis in most cases can process up to 1000 executions/min. For more complex and high-load implementations, + it is recommended to use a distributed RabbitMQ cluster. If technically possible, + AWS SQS and its compatible counterparts from other cloud providers are also supported. + +- **Centrifugo** (optional) is used for active user interaction. At this point, + the service notifies the user of an update or change to the data structure that the user is viewing to complete + a data update request. This reduces the load on the database, because without this service, + the interface makes periodic requests on a timer. + +- **Project storage** at now is directory in filesystem where PM clone or unarchive project files for further executions. + Storage must be readable for web-server and writeable for celery worker. It can be mounted dir from shared storage. + +Understanding what services the Polemarch application consists of, you can build any architecture of services +suitable for the circumstances and infrastructure. + .. _cluster: Polemarch clustering overview @@ -151,7 +180,7 @@ If you want to use LDAP protocol, you should create next settings in section ``[ ldap-default-domain is an optional argument, that is aimed to make user authorization easier (without input of domain name). -ldap-auth_format is an optional argument, that is aimed to customize LDAP authorization. +ldap-auth_format is an optional argument, that is aimed to customize LDAP authorization request. Default value: cn=, So in this case authorization logic will be the following: @@ -334,7 +363,6 @@ session_timeout, static_files_url or pagination limit. * **session_timeout** - Session life-cycle time. ``Default: 2w`` (two weeks). * **rest_page_limit** - Default limit of objects in API list. ``Default: 1000``. -* **public_openapi** - Allow to have access to OpenAPI schema from public. ``Default: false``. * **history_metrics_window** - Timeframe in seconds of collecting execution history statuses. ``Default: 1min``. .. note:: You can find more Web options in :ref:`vstutils:web`. @@ -353,7 +381,8 @@ When user change some data, other clients get notification on ``subscriptions_up with model label and primary key. Without the service all GUI-clients get page data every 5 seconds (by default). Centrifugo server v3 is supported. -* **address** - Centrifugo server address. +* **address** - Centrifugo api address. For example, ``http://localhost:8000/api``. +* **public_address** - Centrifugo server address. By default used **address** without ``/api`` prefix (http -> ws, https -> wss). Also, can be used relative path, like ``/centrifugo``. * **api_key** - API key for clients. * **token_hmac_secret_key** - API key for jwt-token generation. * **timeout** - Connection timeout. @@ -364,6 +393,10 @@ every 5 seconds (by default). Centrifugo server v3 is supported. ``token_hmac_secret_key`` is used for jwt-token generation (based on session expiration time). Token will be used for Centrifugo-JS client. +.. note:: + ``api_key`` and ``token_hmac_secret_key`` come from ``config.json`` for Centrifugo. + Read more in `Official Centrifugo documentation `_ + .. _git: diff --git a/doc/gui.rst b/doc/gui.rst index b6fcf547..282b3c06 100644 --- a/doc/gui.rst +++ b/doc/gui.rst @@ -662,7 +662,7 @@ As you can see, compared to `POLEMARCH_DB` inventory, this one is state managed. .. image:: new_screenshots/inventory_state_ansible_string.png -These types of inventory stores an extension of file, its body and specifies either file should be executable or not. +These types of inventory stores an extension of file, its body, filename and specifies either file should be executable or not. Let's edit the state. Click :guilabel:`Edit` button: .. image:: new_screenshots/inventory_state_edit_ansible_string.png @@ -671,7 +671,10 @@ After saving: .. image:: new_screenshots/inventory_state_ansible_string_2.png -Now inventory is ready for using.. +Now inventory is ready for using. + +This type of inventory is used to store the completed file in the project directory at the time the task is run. +It is helpful when you need to use one of the dynamic ansible inventory plugins. ANSIBLE_FILE inventory @@ -696,6 +699,14 @@ executed. Done. Inventory is ready for use. +This type of inventory is used to use file from the project directory at the time the task is run. +It is helpful when you have GitOps infrastructure where project files is single store of truth. + +.. note:: + The use of strings when specifying an inventory path to describe a launch will be deprecated in future releases + and removed from the interface. + This inventory type is a direct string replacement. + Import inventory ---------------- diff --git a/doc/installation.rst b/doc/installation.rst index ae2eb9a7..cd9868e5 100644 --- a/doc/installation.rst +++ b/doc/installation.rst @@ -284,67 +284,36 @@ Settings Main section ~~~~~~~~~~~~ -* **POLEMARCH_DEBUG** - status of debug mode. Default value: `false`. +* **DEBUG** - status of debug mode. Default value: `false`. -* **POLEMARCH_LOG_LEVEL** - log level. Default value: `WARNING`. +* **DJANGO_LOG_LEVEL** - log level. Default value: `WARNING`. * **TIMEZONE** - timezone. Default value: `UTC`. Database section ~~~~~~~~~~~~~~~~ -You can set Database environment variables in two ways: +Setup database connection via ``django-environ``: :ref:`environ:environ-env-db-url`. -1. Using ``django-environ``: :ref:`environ:environ-env-db-url`. - - For example for mysql, **DATABASE_URL** = ``'mysql://user:password@host:port/dbname'``. - Read more about ``django-environ`` in the :doc:`official django-environ documentation `. - -2. Or you can specify every variable, but this way is deprecated and we won't support it in the next release. - - If you not set **POLEMARCH_DB_HOST**, default database would be SQLite3, path to database file: `/db.sqlite3`. - If you set **POLEMARCH_DB_HOST**, Polemarch would be use MYSQL with next variables: - - * **POLEMARCH_DB_TYPE** - name of database type. Support: `mysql` and `postgres` database. Needed only with **POLEMARCH_DB_HOST** option. - - * **POLEMARCH_DB_NAME** - name of database. - - * **POLEMARCH_DB_USER** - user connected to database. - - * **POLEMARCH_DB_PASSWORD** - password for connection to database. - - * **POLEMARCH_DB_HOST** - host for connection to database. - - * **POLEMARCH_DB_PORT** - port for connection to database. - -Database. Options section -~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. note:: If you use :ref:`environ:environ-env-db-url`, you can't use **DB_INIT_CMD**. - -* **DB_INIT_CMD** - command to start your database +For example for mysql, **DATABASE_URL** = ``'mysql://user:password@host:port/dbname'``. +Read more about ``django-environ`` in the :doc:`official django-environ documentation `. Cache ~~~~~ For cache environment variables you can also use ``django-environ`` - :ref:`environ:environ-env-cache-url`. - For example for redis, **CACHE_URL** = ``redis://host:port/dbname``. -Or you can specify variable **CACHE_LOCATION**, but this way is deprecated and we won't support it in the next release. - -* **CACHE_LOCATION** - path to cache, if you use `/tmp/polemarch_django_cache` path, then cache engine would be `FileBasedCache`, - else `MemcacheCache`. Default value: ``/tmp/polemarch_django_cache``. - - RPC section ~~~~~~~~~~~ -* **RPC_ENGINE** - connection to rpc service. If not set, not used. +* **POLEMARCH_RPC_ENGINE** - connection to rpc service. If not set used as tmp-dir. + +* **POLEMARCH_RPC_RESULT_BACKEND** - connection to rpc results service. Default as engine. -* **RPC_HEARTBEAT** - Timeout for RPC. Default value: `5`. +* **POLEMARCH_RPC_HEARTBEAT** - Timeout for RPC. Default value: `5`. -* **RPC_CONCURRENCY** - Number of concurrently tasks. Default value: `4`. +* **POLEMARCH_RPC_CONCURRENCY** - Number of concurrently tasks. Default value: `4`. Web section ~~~~~~~~~~~ @@ -376,7 +345,7 @@ Run Polemarch with Memcache and RabbitMQ and SQLite3. Polemarch log-level=INFO, .. sourcecode:: bash - docker run -d --name polemarch --restart always -v /opt/polemarch/projects:/projects -v /opt/polemarch/hooks:/hooks --env RPC_ENGINE=amqp://polemarch:polemarch@rabbitmq-server:5672/polemarch --env CACHE_URL=memcache://memcached-server:11211/ --env POLEMARCH_LOG_LEVEL=INFO --env SECRET_KEY=mysecretkey -p 8080:8080 vstconsulting/polemarch + docker run -d --name polemarch --restart always -v /opt/polemarch/projects:/projects -v /opt/polemarch/hooks:/hooks --env POLEMARCH_RPC_ENGINE=amqp://polemarch:polemarch@rabbitmq-server:5672/polemarch --env CACHE_URL=memcache://memcached-server:11211/ --env POLEMARCH_LOG_LEVEL=INFO --env SECRET_KEY=mysecretkey -p 8080:8080 vstconsulting/polemarch Also you can use `.env` file with all variable you want use on run docker: From 761defeeb3d6971b67c00ce703239095a7c73cd5 Mon Sep 17 00:00:00 2001 From: Vladislav Korenkov Date: Thu, 20 Apr 2023 13:09:50 +1000 Subject: [PATCH 5/5] Chore(backend): Improve hooks --- polemarch/main/hooks/base.py | 20 +++++++++++++++----- polemarch/main/hooks/http.py | 2 ++ polemarch/main/hooks/script.py | 1 + polemarch/main/models/__init__.py | 18 +++++++++++------- tests.py | 26 ++++++++++++++------------ 5 files changed, 43 insertions(+), 24 deletions(-) diff --git a/polemarch/main/hooks/base.py b/polemarch/main/hooks/base.py index 4d4bd24e..da61edca 100644 --- a/polemarch/main/hooks/base.py +++ b/polemarch/main/hooks/base.py @@ -3,6 +3,13 @@ class BaseHook: + __slots__ = ( + 'when_types', + 'hook_object', + 'when', + 'conf', + ) + def __init__(self, hook_object, when_types=None, **kwargs): self.when_types = when_types or [] self.hook_object = hook_object @@ -31,13 +38,16 @@ def modify_message(self, message): def execute(self, recipient, when, message): # nocv raise NotImplementedError + def execute_many(self, recipients, when, message) -> str: + return '\n'.join(map(lambda r: self.execute(r, when, message), recipients)) + def send(self, message, when: str) -> str: self.when = when - filtered = filter(lambda r: r, self.conf['recipients']) - execute = self.execute - message = self.modify_message(message) - mapping = map(lambda r: execute(r, when, message), filtered) - return '\n'.join(mapping) + return self.execute_many( + recipients=filter(lambda r: r, self.conf['recipients']), + when=when, + message=self.modify_message(message), + ) def on_execution(self, message): return self.send(message, when='on_execution') diff --git a/polemarch/main/hooks/http.py b/polemarch/main/hooks/http.py index 8b41cb51..08842e2b 100644 --- a/polemarch/main/hooks/http.py +++ b/polemarch/main/hooks/http.py @@ -9,6 +9,8 @@ class Backend(BaseHook): + __slots__ = () + def execute(self, url, when, message) -> str: # pylint: disable=arguments-renamed data = dict(type=when, payload=message) try: diff --git a/polemarch/main/hooks/script.py b/polemarch/main/hooks/script.py index 54240841..fc35a6d4 100644 --- a/polemarch/main/hooks/script.py +++ b/polemarch/main/hooks/script.py @@ -12,6 +12,7 @@ class Backend(BaseHook): + __slots__ = () def execute(self, script, when, file) -> str: # pylint: disable=arguments-renamed try: diff --git a/polemarch/main/models/__init__.py b/polemarch/main/models/__init__.py index fac2aef8..309815d3 100644 --- a/polemarch/main/models/__init__.py +++ b/polemarch/main/models/__init__.py @@ -52,18 +52,22 @@ def send_hook(when: Text, target: Any) -> None: @raise_context() def send_user_hook(when: Text, instance: Any) -> None: send_hook( - when, OrderedDict( - user_id=instance.id, - username=instance.username, - admin=instance.is_staff - ) + when, { + 'user_id': instance.id, + 'username': instance.username, + 'admin': instance.is_staff, + } ) @raise_context() def send_polemarch_models(when: Text, instance: Any, **kwargs) -> None: - target = OrderedDict(id=instance.id, name=instance.name, **kwargs) - send_hook(when, target) + send_hook(when, { + 'id': instance.id, + 'name': instance.name, + 'object_name': instance._meta.verbose_name, # pylint: disable=protected-access + **kwargs, + }) def raise_linked_error(exception_class=ValidationError, **kwargs): diff --git a/tests.py b/tests.py index 871378a2..6a205876 100644 --- a/tests.py +++ b/tests.py @@ -4334,18 +4334,7 @@ def test_env_vars_on_execution(self): self.assertIn('VST_PROJECT', popen.call_args[-1]['env']) -@own_projects_dir -class HookTestCase(BaseProjectTestCase): - def setUp(self): - super().setUp() - shutil.copy(f'{TEST_DATA_DIR}/script_hook.sh', f'{settings.HOOKS_DIR}/script_hook.sh') - self.script_hook = self.get_model_filter('main.Hook').create(type='SCRIPT', recipients='script_hook.sh') - self.http_hook = self.get_model_filter('main.Hook').create(type='HTTP', recipients='https://example.com') - - def tearDown(self): - super().tearDown() - os.remove(f'{settings.HOOKS_DIR}/script_hook.sh') - +class BaseHookTestCase(BaseProjectTestCase): @staticmethod def create_hook_bulk_data(type, recipients, when): return { @@ -4359,6 +4348,19 @@ def create_hook_bulk_data(type, recipients, when): } } + +@own_projects_dir +class HookTestCase(BaseHookTestCase): + def setUp(self): + super().setUp() + shutil.copy(f'{TEST_DATA_DIR}/script_hook.sh', f'{settings.HOOKS_DIR}/script_hook.sh') + self.script_hook = self.get_model_filter('main.Hook').create(type='SCRIPT', recipients='script_hook.sh') + self.http_hook = self.get_model_filter('main.Hook').create(type='HTTP', recipients='https://example.com') + + def tearDown(self): + super().tearDown() + os.remove(f'{settings.HOOKS_DIR}/script_hook.sh') + def test_create_hook(self): results = self.bulk([ # [0] create valid script hook