Русский
Русский
English
Статистика
Реклама

Scripting

Я сделал свой PyPI-репозитарий с авторизацией и S3. На Nginx

07.09.2020 16:04:06 | Автор: admin

В данной статье хочу поделится опытом работы с NJS, интерпретатора JavaScript для Nginx разрабатываемого в компании Nginx inc, описав на реальном примере его основные возможности. NJS это подмножество ЯП JavaScript, которое позволяет расширить функциональность Nginx. На вопрос зачем свой интерпретатор??? подробно ответил Дмитрий Волынцев. Если вкратце: NJS это nginx-way, а JavaScript более прогрессивный, родной и без GC в отличии от Lua.

A long time ago...

На прошлой работе в наследство мне достался gitlab с некоторым количеством разношерстых CI/CD-пайплайнов с docker-compose, dind и прочими прелестями, которые были переведены на рельсы kaniko. Образы же, которые использовались ранее в CI, переехали в первозданном виде. Работали они исправно до того дня, пока у нашего gitlab не сменился IP и CI превратился в тыкву. Проблема была в том, что в одном из docker-образов, участвовавшего в CI, был git, который по ssh тянул Python-модули. Для ssh нужен приватный ключ и ... он был в образе вместе с known_hosts. И любой CI завершался ошибкой проверки ключа из-за несовпадения реального IP и указанного в known_hosts. Из имеющихся Dockfile-ов был быстро собран новый образ и добавлена опция StrictHostKeyChecking no. Но неприятный привкус остался и появилось желание перенести либы в приватный PyPI-репозиторий. Дополнительным бонусом, после перехода на приватный PyPI, становился более простой пайплайн и нормальное описание requirements.txt

Выбор сделан, Господа!

Мы всё крутим в облаках и Kubernetes и в итоге хотелось получить небольшой сервис который представлял из себя stateless-контейнером с внешнем хранилищем. Ну а так как мы используем S3, то и приоритет был за ним. И по возможности с аутентификацией в gitlab (можно и самому дописать по необходимости).

Беглый поиск дал несколько результатов s3pypi, pypicloud и вариант с ручным созданием html-файлов для репы. Последний вариант отпал сам-собой.

s3pypi:Это cli для использования хостинга на S3. Выкладываем файлы, генерим html и заливаем в тот же бакет. Для домашнего использования подойдет.

pypicloud: Казался интересным проектом, но после прочтения доки пришло разочарование. Не смотря на хорошую документацию и возможности расширения под свои задачи, на деле оказался избыточен и сложный в настройке. Поправить код под свои задачи, по тогдашним прикидкам, заняло бы 3-5 дней. Так же сервису необходима БД. Оставили его на случай, если более ничего не найдем.

Более углубленный поиск дал модуль для Nginx, ngx_aws_auth. Результатом его тестирования стал XML отображаемый в браузере, по которому было видно содержимое бакета S3. Последний коммит, на момент поиска, был год назад. Репозиторий выглядел заброшенным.

Обратившись к первоисточнику и прочитав PEP-503 понял, что XML можно конвертировать в HTML налету и отдавать его pip. Ещё немного погуглив по словам Nginx и S3 наткнулся на пример аутентификации в S3 написанный на JS для Nginx. Так я познакомился с NJS.

Взяв за основу этотпример,через час наблюдал в своем браузере тот же XML, что и при использовании модуляngx_aws_auth, но написано уже все было на JS.

Решение на nginx мне очень нравилось. Во-первых хорошая документация и множество примеров, во-вторых мы получаем все плюшки Nginx по работе с файлами (из коробки), в-третьих любой человек умеющий писать конфиги для Nginx, сможет разобраться что к чему. Так же плюсом для меня является минимализм, по сравнению с Python или Go(если писать с нуля), не говоря уж об nexus.

TL;DR Через 2 дня тестовая версия PyPi уже была использована в CI.

Как это работает?

В Nginx подгружается модуль ngx_http_js_module, включен в официальный docker-образ. Импортируем наш скрипт c помощью директивы js_importв конфигурацию Nginx. Вызов функции осуществляется директивойjs_content. Для установки переменных используется директива js_set, которая аргументом принимает только функцию описанную в скрипте. А вот выполнять подзапросы в NJS мы можем только с помощью Nginx, ни каких Вам тамXMLHttpRequest. Для этого в конфигурации Nginx должен быть добавлен соответствующий локейшн. А в скрипте должен быть описан подзапрос (subrequest) к этому локейшену.Чтобы иметь возможность обратиться к функции из конфига Nginx, в самом скрипте имя функции необходимо экспортировать export default.

nginx.conf

load_module modules/ngx_http_js_module.so;http {  js_import   imported_name  from script.js;server {  listen 8080;  ...  location = /sub-query {    internal;    proxy_pass http://upstream;  }  location / {    js_contentimported_name.request;  }}

script.js

function request(r) {  function call_back(resp) {    // handler's code    r.return(resp.status, resp.responseBody);  }  r.subrequest('/sub-query', { method: r.method }, call_back);}export default {request}

При запросе в браузере http://localhost:8080/ мы попадаем в location /в котором директива js_content вызывает функцию request описанную в нашем скрипте script.js. В свою очередь в функции request осуществляется подзапрос к location = /sub-query, с методом (в текущем примере GET) полученным из аргумента (r), неявно передаваемым при вызове этой функции. Обработка ответа подзапроса будет осуществлена в функции call_back.

Пробуем S3

Чтобы сделать запрос к приватному S3-хранилищу, нам необходимы:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Из используемого http-метода, текущая дата/время,S3_NAME и URI генерируется определенного вида строка, которая подписывается (HMAC_SHA1) с помощьюSECRET_KEY. Далее строку, вида AWS $ACCESS_KEY:$HASH, можно использовать в заголовке авторизации. Та же дата/время, что была использована для генерации строки на предыдущем шаге, должна быть добавлена взаголовок X-amz-date.В коде это выглядит так:

nginx.conf

load_module modules/ngx_http_js_module.so;http {  js_import   s3      from     s3.js;  js_set      $s3_datetime     s3.date_now;  js_set      $s3_auth         s3.s3_sign;server {  listen 8080;  ...  location~* /s3-query/(?<s3_path>.*) {    internal;    proxy_set_header    X-amz-date     $s3_datetime;    proxy_set_header    Authorization  $s3_auth;    proxy_pass          $s3_endpoint/$s3_path;}  location~ "^/(?<prefix>[\w-]*)[/]?(?<postfix>[\w-\.]*)$" {    js_content s3.request;  }}

s3.js(пример авторизации AWS Sign v2, переведена в статус deprecated)

var crypt = require('crypto');var s3_bucket = process.env.S3_BUCKET;var s3_access_key = process.env.S3_ACCESS_KEY;var s3_secret_key = process.env.S3_SECRET_KEY;var _datetime = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, '');function date_now() {  return _datetime}function s3_sign(r) {  var s2s = r.method + '\n\n\n\n';  s2s += `x-amz-date:${date_now()}\n`;  s2s += '/' + s3_bucket;  s2s += r.uri.endsWith('/') ? '/' : r.variables.s3_path;  return `AWS ${s3_access_key}:${crypt.createHmac('sha1', s3_secret_key).update(s2s).digest('base64')}`;}function request(r) {  var v = r.variables;  function call_back(resp) {    r.return(resp.status, resp.responseBody);  }  var _subrequest_uri =r.uri;  if (r.uri ==='/') {    // root    _subrequest_uri = '/?delimiter=/';  } else if (v.prefix !== '' && v.postfix === '') {    // directory    var slash = v.prefix.endsWith('/') ? '' : '/';    _subrequest_uri = '/?prefix=' + v.prefix + slash;}  r.subrequest(`/s3-query${_subrequest_uri}`, { method: r.method }, call_back);}export default {request,s3_sign,date_now}

Немного пояснения про _subrequest_uri: это переменная которая в зависимости от изначального uri формирует запрос к S3. Если нужно получить содержимое корня, в таком случае необходимо сформировать uri-запрос с указанием разделителя delimiter, который вернет список всех xml-элементов CommonPrefixes, что соответствует директориям (в случае с PyPI, список всех пакетов). Если нужно получить список содержимого в определенной директории (список всех версий пакетов), тогда uri-запрос должен содержатьполе prefix с именем директории (пакета) обязательно заканчивающийся на слэш /. В противном случае возможны коллизии при запросе содержимого директории, например. Есть директории aiohttp-request иaiohttp-requests и если в запросе будет указано /?prefix=aiohttp-request, тогда в ответе будет содержимое обеих директорий. Если же на конце будет слэш,/?prefix=aiohttp-request/, то в ответе будет только нужная директория. И если мы запрашиваем файл, то результирующий uri не должен отличать от изначального.

Сохраняем, перезапускаем Nginx. В браузере вводим адрес нашего Nginx, результатом работы запроса будет XML, например:

Список директорий
<?xml version="1.0" encoding="UTF-8"?><ListBucketResult xmlns="http://personeltest.ru/away/s3.amazonaws.com/doc/2006-03-01/">  <Name>myback-space</Name>  <Prefix></Prefix>  <Marker></Marker>  <MaxKeys>10000</MaxKeys>  <Delimiter>/</Delimiter>  <IsTruncated>false</IsTruncated>  <CommonPrefixes>    <Prefix>new/</Prefix>  </CommonPrefixes>  <CommonPrefixes>    <Prefix>old/</Prefix>  </CommonPrefixes></ListBucketResult>

Из списка директорий понадобятся только элементыCommonPrefixes.

Добавив, в браузере, к нашему адресу нужную нам директорию, получим ее содержимое так же в виде XML:

Список файлов в директории
<?xml version="1.0" encoding="UTF-8"?><ListBucketResult xmlns="http://personeltest.ru/away/s3.amazonaws.com/doc/2006-03-01/">  <Name>myback-space</Name>  <Prefix>old/</Prefix>  <Marker></Marker>  <MaxKeys>10000</MaxKeys>  <Delimiter></Delimiter>  <IsTruncated>false</IsTruncated>  <Contents>    <Key>old/giphy.mp4</Key>    <LastModified>2020-08-21T20:27:46.000Z</LastModified>    <ETag>&#34;00000000000000000000000000000000-1&#34;</ETag>    <Size>1350084</Size>    <Owner>      <ID>02d6176db174dc93cb1b899f7c6078f08654445fe8cf1b6ce98d8855f66bdbf4</ID>      <DisplayName></DisplayName>    </Owner>    <StorageClass>STANDARD</StorageClass>  </Contents>  <Contents>    <Key>old/hsd-k8s.jpg</Key>    <LastModified>2020-08-31T16:40:01.000Z</LastModified>    <ETag>&#34;b2d76df4aeb4493c5456366748218093&#34;</ETag>    <Size>93183</Size>    <Owner>      <ID>02d6176db174dc93cb1b899f7c6078f08654445fe8cf1b6ce98d8855f66bdbf4</ID>      <DisplayName></DisplayName>    </Owner>    <StorageClass>STANDARD</StorageClass>  </Contents></ListBucketResult>

Из списка файлов возьмем только элементыKey.

Остается полученный XML распарсить и отдать в виде HTML, предварительно заменив заголовок Content-Type на text/html.

function request(r) {  var v = r.variables;  function call_back(resp) {    var body = resp.responseBody;    if (r.method !== 'PUT' && resp.status < 400 && v.postfix === '') {      r.headersOut['Content-Type'] = "text/html; charset=utf-8";      body = toHTML(body);    }    r.return(resp.status, body);  }    var _subrequest_uri =r.uri;  ...}function toHTML(xml_str) {  var keysMap = {    'CommonPrefixes': 'Prefix',    'Contents': 'Key',  };  var pattern = `<k>(?<v>.*?)<\/k>`;  var out = [];  for(var group_key in keysMap) {    var reS;    var reGroup = new RegExp(pattern.replace(/k/g, group_key), 'g');    while(reS = reGroup.exec(xml_str)) {      var data = new RegExp(pattern.replace(/k/g, keysMap[group_key]), 'g');      var reValue = data.exec(reS);      var a_text = '';      if (group_key === 'CommonPrefixes') {        a_text = reValue.groups.v.replace(/\//g, '');      } else {        a_text = reValue.groups.v.split('/').slice(-1);      }      out.push(`<a href="http://personeltest.ru/aways/habr.com/${reValue.groups.v}">${a_text}</a>`);    }  }  return '<html><body>\n' + out.join('</br>\n') + '\n</html></body>'}

Пробуем PyPI

Проверяем, что ни где и ни чего не ломается на заведомо рабочих пакетах.

# Создаем для тестов новое окружениеpython3 -m venv venv. ./venv/bin/activate# Скачиваем рабочие пакеты.pip download aiohttp# Загружаем в приватную репуfor wheel in *.whl; do curl -T $wheel http://localhost:8080/${wheel%%-*}/$wheel; donerm -f *.whl# Устанавливаем из приватной репыpip install aiohttp -i http://localhost:8080

Повторяем с нашими либами.

# Создаем для тестов новое окружениеpython3 -m venv venv. ./venv/bin/activatepip install setuptools wheelpython setup.py bdist_wheelfor wheel in dist/*.whl; do curl -T $wheel http://localhost:8080/${wheel%%-*}/$wheel; donepip install our_pkg--extra-index-url http://localhost:8080

В CI, создание и загрузка пакета выглядит так:

pip install setuptools wheelpython setup.py bdist_wheelcurl -sSfT dist/*.whl -u "gitlab-ci-token:${CI_JOB_TOKEN}" "https://pypi.our-domain.com/${CI_PROJECT_NAME}"

Аутентификация

В Gitlab возможно использовать JWT для аутентификации/авторизации внешних сервисов. Воспользовавшись директивой auth_request в Nginx, перенаправим аутентификационные данные в подзапрос содержащий вызов функции в скрипте. В скрипте будет сделан ещё один подзапрос на url Gitlab-а и если аутентификационные данные указаны были верно, то Gitlab вернет код 200 и будет разрешена загрузка/скачивание пакета. Почему не воспользоваться одним подзапросом и сразу не отправить данные в Gitlab? Потому, что придется тогда править файл конфигурации Nginx каждый раз, как у нас будут какие-то изменения в авторизации, а это достаточно муторное занятие. Так же, если в Kubernetes используется политика read-only root filesystem, то это ещё больше добавляет сложностей при подмене nginx.conf через configmap. И становится абсолютно невозможна конфигурация Nginx через configmap при одновременном использованииполитикзапрещающих подключение томов (pvc) иread-only root filesystem (такое тоже бывает).

Используя промежуточным звеном NJS, мы получаем возможность менять указанные параметры в конфиге nginx с помощью переменных окружения и делать какие-нибудь проверки в скрипте (например, неверно указанного URL).

nginx.conf

location = /auth-provider {  internal;  proxy_pass $auth_url;}location = /auth {  internal;  proxy_set_header Content-Length "";  proxy_pass_request_body off;  js_content auth.auth;}location ~ "^/(?<prefix>[\w-]*)[/]?(?<postfix>[\w-\.]*)$" {  auth_request /auth;  js_content s3.request;}

s3.js

var env = process.env;var env_bool = new RegExp(/[Tt]rue|[Yy]es|[Oo]n|[TtYy]|1/);var auth_disabled  = env_bool.test(env.DISABLE_AUTH);var gitlab_url = env.AUTH_URL;function url() {  return `${gitlab_url}/jwt/auth?service=container_registry`}function auth(r) {  if (auth_disabled) {    r.return(202, '{"auth": "disabled"}');    return null}  r.subrequest('/auth-provider',                {method: 'GET', body: ''},                function(res) {                  r.return(res.status, "");                }  );}export default {auth, url}

Скорее всего назревает вопрос: -А почему бы не использовать готовые модули? Там ведь всё уже сделано! Например, var AWS = require('aws-sdk')и не надо писать"велосипед" с S3-аутентификацией!

Перейдем к минусам

Для меня, невозможность импортирование внешние JS-модулей, стало неприятной, но ожидаемой особенностью. Описанный в примере выше require('crypto'), это build-in-модули и require работает только для них. Так же нет возможности переиспользовать код из скриптов и приходится копи-пастить его по разным файлам. Надеюсь, что когда-нибудь этот функционал будет реализован.

Так же для текущего проекта в Nginx должно быть отключено сжатие gzip off;

Потому, что нет gzip-модуля в NJS и подключить его невозможно, соотвественно нет возможности работать с сжатыми данными. Правда, не особо это и минус для данного кейса. Текста не много, а передаваемые файлы уже сжатые и дополнительное сжатие им не особо поможет. Так же это не на столько нагруженный или критичный сервис, чтобы заморачиваться с отдачей контента на несколько миллисекунд быстрее.

Отладка скрипта долгая и возможна только через принты в error.log. В зависимости от выставленного уровня логирования info, warn или error возможно использовать 3 метода r.log, r.warn, r.error соответственно. Некоторые скрипты пытаюсь отлаживать в Chrome (v8) или консольной тулзе njs, но не все возможно там проверить. При отладке кода, ака функциональное тестирование, history выглядит примерно так:

docker-compose restart nginxcurl localhost:8080/docker-compose logs --tail 10 nginx

и таких последовательностей может быть сотни.

Написание кода с использование подзапросов и переменных для них, превращается в запутанный клубок. Иногда начинаешь метаться по разным окнам IDE пытаясь разобраться в последовательности действий твоего кода. Это не сложно, но иногда сильно напрягает.

Нет полноценной поддержки ES6.

Может есть и ещё какие-то недостатки, но ни с чем я более не сталкивался. Поделитесь инфой если у Вас есть отрицательный опыт эксплуатации NJS.

Заключение

NJS - легковесный open-source интерпретатор, позволяющий реализовать в Nginx разные сценарии на ЯП JavaScript. При его разработке было уделено большое внимание производительности. Конечно много чего в нём ещё не хватает, но проект развивается силами небольшой команды и они активно добавляют новые фичи и фиксят баги. Я же надеюсь, что когда-нибудь NJS позволит подключать внешние модули, что сделает функционал Nginx практически неограниченным. Но есть NGINX Plus и каких-то фич скорее всего не будет!

Репозиторий с полным кодом к статье

njs-pypi с поддержкой AWS Sign v4

Описание директив модуля ngx_http_js_module

Официальный репозиторий NJS и документация

Примеры использования NJS от Дмитрия Волынцева

njs - родной JavaScript-скриптинг в nginx / Выступление Дмитрия Волныева на Saint HighLoad++ 2019

NJS в production / Выступление Василия Сошникова на HighLoad++ 2019

Подпись и аутентификация REST-запросов в AWS

Подробнее..
Категории: Javascript , Development , Devops , Nginx , Pip , Gitlab-ci , Scripting , Pypi , Aws s3

OBS Studio Lua Скриптинг. Часть 2

06.10.2020 16:11:14 | Автор: admin

Всем привет, в этой части руководства рассмотрим:
фильтры, сцены, предметы сцен, Frontend API, создание функциональных фильтров и прочее
С первой частью можно ознакомиться по этой ссылке.


КДПВ сделана в OBS 26.0.0


Краткая справка для этой части


  • Источник Источники используются для рендера аудио/видео, например: захват камеры, игры, звука. С помощью источников можно создавать фильтры, переходы


  • Фильтр Источник который дополняет другие источники


  • Сцены Коллекция источников, сцена является источником ( сцену можно добавить как источник в другой сцене)


  • Предмет сцены конкретный источник в сцене, его можно: перемещать, увеличивать/уменьшать, переворачивать, менять состояние выкл/вкл и.т.д


  • Frontend API Набор функций который предоставляет OBS Studio, например:


    • подписка на событие о переключении сцен
    • запрос статуса о том, идёт ли стрим/запись
    • вкл/выкл стрим/запись
    • переключение сцен


Фильтры


Типы фильтров так же как и источников, можно узнать через функцию obs_source_get_unversioned_id


Название Внутреннее представление типа
Компрессор compressor_filter
Экспандер expander_filter
Усиление gain_filter
Инвертировать полярность invert_polarity_filter
Лимитер limiter_filter
Пропускной уровень шума noise_gate_filter
Шумоподавление noise_suppress_filter
VST 2.x плагин vst_filter
Задержка видео (асинхронность) async_delay_filter
Хромакей chroma_key_filter
Коррекция цвета color_filter
Цветовой ключ color_key_filter
Кадрирование crop_filter
Маска изображения/Смешивание mask_filter
Яркостный ключ luma_key_filter
Задержка отображения gpu_delay
Масштабирование/Соотношение сторон scale_filter
Прокрутка scroll_filter
Увеличить резкость sharpness_filter

В английском варианте: ссылка


Скрипт: изменение параметра прозрачности у фильтра на случайную величину от 1 до 100.


Чтобы узнать название параметра "прозрачность" необходимо добавить фильтр с прозрачностью на какой-нибудь источник, изменить этот параметр. Далее открыть файл коллекции сцен, путь к директории можно узнать через меню OBS:
Справка > Файлы журнала > Показать файлы журнала
далее с этой директории поднимаемся выше, и получаем путь ~/basic>scenes>название_сцены.json
В этом файле ищем color_filter или color_key_filter (оба фильтра могут изменить прозрачность источника).
В строке settings видим что прозрачность записана как opacity.
Ещё один способ узнать название параметра, прочитать исходный код фильтра ссылка


Находим источник по имени


function add_filter_to_source(random_n)  source = obs.obs_get_source_by_name(source_name)

Создаём настройки с изменением параметра opacity на случайное число


settings = obs.obs_data_create()obs.obs_data_set_int(settings, "opacity",random_n)

Проверяем существует ли уже фильтр на источнике, если нет добавляем


_color_filter = obs.obs_source_get_filter_by_name(source,"opacity_random")if _color_filter == nil then -- if not exists  _color_filter = obs.obs_source_create_private( "color_filter", "opacity_random", settings)  obs.obs_source_filter_add(source, _color_filter)end

Обновляем и освобождаем память


  obs.obs_source_update(_color_filter,settings)  obs.obs_source_release(source)  obs.obs_data_release(settings)  obs.obs_source_release(_color_filter)end

Привязка к горячей клавише


function htk_1_cb(pressed)   if pressed then    n = math.random(1,100)    add_filter_to_source(n)  endend

Гифка


Исходный код
local obs = obsluasource_name = ''function htk_1_cb(pressed)   if pressed then    n = math.random(1,100)    add_filter_to_source(n)  endendfunction add_filter_to_source(random_n)  source = obs.obs_get_source_by_name(source_name)  settings = obs.obs_data_create()  obs.obs_data_set_int(settings, "opacity",random_n)  _color_filter = obs.obs_source_get_filter_by_name(source,"opacity_random")  if _color_filter == nil then -- if not exists    _color_filter = obs.obs_source_create_private( "color_filter", "opacity_random", settings)    obs.obs_source_filter_add(source, _color_filter)  end  obs.obs_source_update(_color_filter,settings)  obs.obs_source_release(source)  obs.obs_data_release(settings)  obs.obs_source_release(_color_filter)endfunction script_properties()  -- source https://raw.githubusercontent.com/insin/obs-bounce/master/bounce.lua  local props = obs.obs_properties_create()  local source = obs.obs_properties_add_list(    props,    'source',    'Source:',    obs.OBS_COMBO_TYPE_EDITABLE,    obs.OBS_COMBO_FORMAT_STRING)  for _, name in ipairs(get_source_names()) do    obs.obs_property_list_add_string(source, name, name)  end  return propsendfunction script_update(settings)  source_name = obs.obs_data_get_string(settings, 'source')end--- get a list of source names, sorted alphabeticallyfunction get_source_names()  local sources = obs.obs_enum_sources()  local source_names = {}  if sources then    for _, source in ipairs(sources) do      -- exclude Desktop Audio and Mic/Aux by their capabilities      local capability_flags = obs.obs_source_get_output_flags(source)      if bit.band(capability_flags, obs.OBS_SOURCE_DO_NOT_SELF_MONITOR) == 0 and        capability_flags ~= bit.bor(obs.OBS_SOURCE_AUDIO, obs.OBS_SOURCE_DO_NOT_DUPLICATE) then        table.insert(source_names, obs.obs_source_get_name(source))      end    end  end  obs.source_list_release(sources)  table.sort(source_names, function(a, b)    return string.lower(a) < string.lower(b)  end)  return source_namesendkey_1 = '{"htk_1": [ { "key": "OBS_KEY_1" } ]}'json_s = key_1default_hotkeys = {  {id='htk_1',des='Кнопка 1 ',callback=htk_1_cb},}function script_load(settings)  s = obs.obs_data_create_from_json(json_s)  for _,v in pairs(default_hotkeys) do     a = obs.obs_data_get_array(s,v.id)    h = obs.obs_hotkey_register_frontend(v.id,v.des,v.callback)    obs.obs_hotkey_load(h,a)    obs.obs_data_array_release(a)  end  obs.obs_data_release(s)end

Стоит упомянуть также о функции obs_source_enum_filters с её помощью можно получить
список всех фильтров у конкретного источника, кстати эта функция не работает в obspython,
но об этом чуть позже.


function check()  source = obs.obs_get_source_by_name(source_name)  result = obs.obs_source_enum_filters(source)  for k,v in pairs(result) do     name = obs.obs_source_get_name(v)    print('name'.. name)  end  obs.source_list_release(result)  obs.obs_source_release(source)end

Эвенты и состояние


Скрипт: звуковое оповещение о том что сцена изменена, с использованием .mp3 файла.
На основе этого скрипта


Создадим функцию для проигрывания звука при смене сцен.


function on_event(event)   if event == obs.OBS_FRONTEND_EVENT_SCENE_CHANGED    then obs_play_sound_release_source()  end end

Добавим источник медиа, установим настройки: файл alert.mp3 относителен директории нахождения
скрипта, obs_source_set_monitoring_type выставляет прослушивание аудио.


function play_sound()  mediaSource = obs.obs_source_create_private("ffmpeg_source", "Global Media Source", nil)  local s = obs.obs_data_create()  obs.obs_data_set_string(s, "local_file",script_path() .. "alert.mp3")  obs.obs_source_update(mediaSource,s)  obs.obs_source_set_monitoring_type(mediaSource,obs.OBS_MONITORING_TYPE_MONITOR_AND_OUTPUT)  obs.obs_data_release(s)  obs.obs_set_output_source(outputIndex, mediaSource)  return mediaSourceendfunction obs_play_sound_release_source()  r = play_sound()  obs.obs_source_release(r)end

Исходный код
local obs = obsluamediaSource = nil -- Null pointeroutputIndex = 63 -- Last indexfunction play_sound()  mediaSource = obs.obs_source_create_private("ffmpeg_source", "Global Media Source", nil)  local s = obs.obs_data_create()  obs.obs_data_set_string(s, "local_file",script_path() .. "alert.mp3")  obs.obs_source_update(mediaSource,s)  obs.obs_source_set_monitoring_type(mediaSource,obs.OBS_MONITORING_TYPE_MONITOR_AND_OUTPUT)  obs.obs_data_release(s)  obs.obs_set_output_source(outputIndex, mediaSource)  return mediaSourceendfunction obs_play_sound_release_source()  r = play_sound()  obs.obs_source_release(r)endfunction on_event(event)   if event == obs.OBS_FRONTEND_EVENT_SCENE_CHANGED    then obs_play_sound_release_source()  end endfunction script_load(settings)  obs.obs_frontend_add_event_callback(on_event)endfunction script_unload()  obs.obs_set_output_source(outputIndex, nil)end

Время и файлы


Запись в файл, "a" создаст(если нет) файл и добавит "content", а "w" перезапишет .


io.output(io.open(script_path() .. "out.txt","a"))io.write("content")io.close()

print(os.date("%c"))-- День недели Месяц Время Год


Сцены и предметы сцен


  • obs_sceneitem_get_source предметы сцен в источник
  • obs_scene_from_source преобразование сцены в источник
  • obs_scene_find_source преобразование источника в предмет сцены
  • obs_frontend_get_scenes получение всех сцен, освобождать с source_list_release
  • obs_frontend_get_current_scene получение текущей сцены
  • obs_scene_enum_items список всех предметов в сцене, освобождать с sceneitem_list_release

Скрипт: включение и выключение предмета сцены(источника на сцене).


Получение всех сцен и предметов в них


function toggle_source()  scenes = obs.obs_frontend_get_scenes()  for _,scene in pairs(scenes) do    scene_source = obs.obs_scene_from_source(scene)    items = obs.obs_scene_enum_items(scene_source)...

Поиск конкретного источника и его включение или выключение, source_name и boolean определены глобально.


...for _,scene_item in pairs(items) do  _source = obs.obs_sceneitem_get_source(scene_item)  _name = obs.obs_source_get_name(_source)  if _name == source_name then    boolean = not boolean     obs.obs_sceneitem_set_visible(scene_item, boolean)  endend...

Гифка


Исходный код
local obs = obsluasource_name = ''boolean = truefunction htk_1_cb(pressed)   if pressed then    toggle_source()  endendfunction toggle_source()  scenes = obs.obs_frontend_get_scenes()  for _,scene in pairs(scenes) do    scene_source = obs.obs_scene_from_source(scene)    items = obs.obs_scene_enum_items(scene_source)    for _,scene_item in pairs(items) do      _source = obs.obs_sceneitem_get_source(scene_item)      _name = obs.obs_source_get_name(_source)      if _name == source_name then        boolean = not boolean         obs.obs_sceneitem_set_visible(scene_item, boolean)      end    end    obs.sceneitem_list_release(items)  end  obs.source_list_release(scenes)endfunction script_properties()  -- source https://raw.githubusercontent.com/insin/obs-bounce/master/bounce.lua  local props = obs.obs_properties_create()  local source = obs.obs_properties_add_list(    props,    'source',    'Source:',    obs.OBS_COMBO_TYPE_EDITABLE,    obs.OBS_COMBO_FORMAT_STRING)  for _, name in ipairs(get_source_names()) do    obs.obs_property_list_add_string(source, name, name)  end  obs.obs_property_set_long_description(source,"?" )  return propsendfunction script_update(settings)  source_name = obs.obs_data_get_string(settings, 'source')end--- get a list of source names, sorted alphabeticallyfunction get_source_names()  local sources = obs.obs_enum_sources()  local source_names = {}  if sources then    for _, source in ipairs(sources) do      -- exclude Desktop Audio and Mic/Aux by their capabilities      local capability_flags = obs.obs_source_get_output_flags(source)      if bit.band(capability_flags, obs.OBS_SOURCE_DO_NOT_SELF_MONITOR) == 0 and        capability_flags ~= bit.bor(obs.OBS_SOURCE_AUDIO, obs.OBS_SOURCE_DO_NOT_DUPLICATE) then        table.insert(source_names, obs.obs_source_get_name(source))      end    end  end  obs.source_list_release(sources)  table.sort(source_names, function(a, b)    return string.lower(a) < string.lower(b)  end)  return source_namesendkey_1 = '{"htk_1": [ { "key": "OBS_KEY_1" } ]}'json_s = key_1default_hotkeys = {  {id='htk_1',des='Кнопка 1 ',callback=htk_1_cb},}function script_load(settings)  s = obs.obs_data_create_from_json(json_s)  for _,v in pairs(default_hotkeys) do     a = obs.obs_data_get_array(s,v.id)    h = obs.obs_hotkey_register_frontend(v.id,v.des,v.callback)    obs.obs_hotkey_load(h,a)    obs.obs_data_array_release(a)  end  obs.obs_data_release(s)end

Регистрация фильтров


В obslua доступна функция obs_register_source, с её помощью можно зарегистрировать
источник( переходы и фильтры это источники). Для этого понадобится создать таблицу источника
Регистрация фильтров удобна тем, что позволяет закрепить функциональность скрипта за
определенным источником. Поддерживает горячие клавиши, интерфейс, таймеры.


Скрипт: закрепление горячих клавиш на фильтре, и полный доступ к источнику.


Импорт библиотеки, и определение типа как источник-фильтр.


local obs = obslualocal bit = require("bit")local info = {} -- obs_source_info https://obsproject.com/docs/reference-sources.htmlinfo.id = "uniq_filter_id"info.type = obs.OBS_SOURCE_TYPE_FILTERinfo.output_flags = bit.bor(obs.OBS_SOURCE_VIDEO)info.get_name = function() return 'default filter name' end

Инициализация фильтра, будет вызываться при запуске программы или при добавлении к источнику


info.create = function(settings,source)   local filter = {}  filter.context = source

Начальная регистрация горячих клавиш, принадлежащих конкретному фильтру.


filter.hotkeys = {  htk_stop = "[stop] ",  htk_restart = "[start] ",}filter.hotkey_mapping = function(hotkey,data)  if hotkey == "htk_stop" then    print('stop '.. data.srsn .. " : " .. data.filn)  elseif hotkey == "htk_restart" then    print('restart ' .. data.srsn .. " : " .. data.filn)  endendfilter.hk = {}for k,v in pairs(filter.hotkeys) do   filter.hk[k] = obs.OBS_INVALID_HOTKEY_IDend

Создание функции которая запустится не сразу ( это необходимо т.к фильтр ещё не создан)
Он будет создан после return


filter._reg_htk = function()    info.reg_htk(filter,settings)  end  obs.timer_add(filter._reg_htk,100) -- callback to register hotkeys, one time only

Завершающая регистрация горячих клавиш,obs_filter_get_parent источник который фильтруется
к которому прикреплён фильтр этого типа. Удаление таймера.


info.reg_htk = function(filter,settings) -- register hotkeys after 100 ms since filter was created  local target = obs.obs_filter_get_parent(filter.context)  local srsn = obs.obs_source_get_name(target)   local filn =  obs.obs_source_get_name(filter.context)  local data = {srsn = srsn, filn = filn}   for k, v in pairs(filter.hotkeys) do     filter.hk[k] = obs.obs_hotkey_register_frontend(k, v .. srsn .. " : " .. filn, function(pressed)    if pressed then filter.hotkey_mapping(k,data) end end)    local a = obs.obs_data_get_array(settings, k)    obs.obs_hotkey_load(filter.hk[k], a)    obs.obs_data_array_release(a)  end  obs.remove_current_callback()end

Необходимый код для пропуска фильтрации, без него при добавлении фильтра источник "выключится"


info.video_render = function(filter, effect)   -- called every frame  local target = obs.obs_filter_get_parent(filter.context)  if target ~= nil then    filter.width = obs.obs_source_get_base_width(target)    filter.height = obs.obs_source_get_base_height(target)  end  obs.obs_source_skip_video_filter(filter.context) endinfo.get_width = function(filter)  return filter.widthendinfo.get_height = function(filter)  return filter.heightend

Функция .save вызывается при сохранении настроек, т.е можно переназначить горячие клавиши.
obs.obs_register_source(info) регистрация фильтра, теперь его видно при нажатии ПКМ


info.save = function(filter,settings)  for k, v in pairs(filter.hotkeys) do    local a = obs.obs_hotkey_save(filter.hk[k])    obs.obs_data_set_array(settings, k, a)    obs.obs_data_array_release(a)  endendobs.obs_register_source(info)

info.load также как и script_load, вызывается при запуске программы, но в данном
случае дублирует функциональность и требует перезапуска. .update, .get_properties
функции аналогичные script_update, script_properties.


Гифка


Исходный код
local obs = obslualocal bit = require("bit")local info = {} -- obs_source_info https://obsproject.com/docs/reference-sources.htmlinfo.id = "uniq_filter_id"info.type = obs.OBS_SOURCE_TYPE_FILTERinfo.output_flags = bit.bor(obs.OBS_SOURCE_VIDEO)info.get_name = function() return 'default filter name' endinfo.create = function(settings,source)   local filter = {}  filter.context = source  filter.hotkeys = {    htk_stop = "[stop] ",    htk_restart = "[start] ",  }  filter.hotkey_mapping = function(hotkey,data)    if hotkey == "htk_stop" then      print('stop '.. data.srsn .. " : " .. data.filn)    elseif hotkey == "htk_restart" then      print('restart ' .. data.srsn .. " : " .. data.filn)    end  end  filter.hk = {}  for k,v in pairs(filter.hotkeys) do     filter.hk[k] = obs.OBS_INVALID_HOTKEY_ID  end  filter._reg_htk = function()    info.reg_htk(filter,settings)  end  obs.timer_add(filter._reg_htk,100) -- callback to register hotkeys, one time only  return filterendinfo.reg_htk = function(filter,settings) -- register hotkeys after 100 ms since filter was created  local target = obs.obs_filter_get_parent(filter.context)  local srsn = obs.obs_source_get_name(target)   local filn =  obs.obs_source_get_name(filter.context)  local data = {srsn = srsn, filn = filn}   for k, v in pairs(filter.hotkeys) do     filter.hk[k] = obs.obs_hotkey_register_frontend(k, v .. srsn .. " : " .. filn, function(pressed)    if pressed then filter.hotkey_mapping(k,data) end end)    local a = obs.obs_data_get_array(settings, k)    obs.obs_hotkey_load(filter.hk[k], a)    obs.obs_data_array_release(a)  end  obs.remove_current_callback()endinfo.video_render = function(filter, effect)   -- called every frame  local target = obs.obs_filter_get_parent(filter.context)  if target ~= nil then    filter.width = obs.obs_source_get_base_width(target)    filter.height = obs.obs_source_get_base_height(target)  end  obs.obs_source_skip_video_filter(filter.context) endinfo.get_width = function(filter)  return filter.widthendinfo.get_height = function(filter)  return filter.heightend--info.load = function(filter,settings) -- restart required--... same code as in info.reg_htk, but filters will be created from scratch every time--obs restarts, there is no reason to define it here again becuase hotkeys will be duplicated--endinfo.save = function(filter,settings)  for k, v in pairs(filter.hotkeys) do    local a = obs.obs_hotkey_save(filter.hk[k])    obs.obs_data_set_array(settings, k, a)    obs.obs_data_array_release(a)  endendobs.obs_register_source(info)

obspython


В OBS также доступен скриптинг через Python, для Windows только 3.6 версия, для Linux встроенная (т.к в настройках нельзя указать путь),
для MacOS Python не доступен для текущей (26.0.0) версии.
В отличии от Lua тут нельзя регистрировать источники, перебор фильтров не работает,
т.к не написан wrapper на функции с аргументом типа указатель-указатель.
Но в контексте скриптинга имеет место быть т.к:



Задачи


Перед тем как начать делать задачи, рекомендую сделать бэкап коллекции сцен,
с осторожностью использовать script_tick(вызывается раз в каждый кадр)
Проверять утечки памяти в папке logs, последняя строка последнего файла
пример время: Number of memory leaks: 0, если скрипт написан неправильно то
этой строчки там не окажется т.к OBS вылетит с ошибкой при закрытии.


3)[фильтры] "Динамическая прокрутка"
Создать программно или выбрать через интерфейс источник который будет фильтроваться,
к этом источнику добавить(если нет) фильтр Прокрутка (scroll_filter),
добавить интерфейс и/или горячие клавиши которые меняют значение вертикальной скорости
на случайную величину от 0 до 1000 при этом включать/выключать повторение с 50% шансом.


Гифка


4)[эвенты] "Проверка"
При переключении сцен проверять идёт ли запись.
Если нет вывести оповещение ( например через error())


5)[время и файлы] "Пост-продакшен"
Создать скрипт который при нажатии горячей клавиши записывает текущее время,
относительное время от старта записи, добавляет текст "МЕТКА",
а через интерфейс UI кнопку записать текст, и место для набора текста.
изображение


6)[предметы сцены] "Сумма"
Посчитать количество сцен и предметов сцен, записать ответ в названии первой сцены.
Не учитывать группы, т.к перебор предметов груп не работает.
Гифка


7) [фильтры и источники] "Нэйтив скриптинг"
Создать фильтр который будет с интервалом в 2 секунды включать и выключать источник за которым он закреплён.
Гифка


Ответы на задачи и код скриптов включая первую часть на Github


Ссылки


Подробнее..

Перевод Лучшие практики bash-скриптов краткое руководство по надежным и производительным скриптам bash

17.08.2020 18:04:48 | Автор: admin

Shell wallpaper by manapi

Отладка сценариев bash это как поиск иголки в стоге сена, тем более, когда новые дополнения появляются в существующей кодовой базе без своевременного рассмотрения вопросов структуры, логирования и надежности. В таких ситуациях можно оказаться как из-за собственных ошибок, так и при управлении сложными нагромождениями скриптов.

Команда Mail.ru Cloud Solutions перевела статью с рекомендациям, благодаря которым вы сможете лучше писать, отлаживать и поддерживать свои сценарии. Хотите верьте, хотите нет, но ничто не может сравниться с удовлетворением от написания чистого, готового к использованию bash-кода, который работает каждый раз.

В статье автор делится тем, что узнал за последние несколько лет, а также некоторыми распространенными ошибками, которые заставали его врасплох. Это важно, потому что каждый разработчик программного обеспечения в определенный момент своей карьеры работает со сценариями для автоматизации рутинных рабочих задач.

Обработчики ловушек


Большинство скриптов bash, с которыми я сталкивался, никогда не использовали эффективный механизм очистки, когда во время выполнения скрипта происходит что-то неожиданное.

Неожиданности могут возникнуть извне, например получение сигнала от ядра. Обработка таких случаев чрезвычайно важна для того, чтобы сценарии были достаточно надежными для запуска в продакшен-системах. Я часто использую обработчики выхода, чтобы реагировать на такие сценарии:

function handle_exit() {  // Add cleanup code here  // for eg. rm -f "/tmp/${lock_file}.lock"  // exit with an appropriate status code}  // trap <HANDLER_FXN> <LIST OF SIGNALS TO TRAP>trap handle_exit 0 SIGHUP SIGINT SIGQUIT SIGABRT SIGTERM

trap это встроенная команда оболочки, помогающая вам зарегистрировать функцию очистки, которая вызывается в случае каких-либо сигналов. Однако следует соблюдать особую осторожность с такими обработчиками, как SIGINT, который вызывает прерывание сценария.

Кроме того, в большинстве случаев следует ловить только EXIT, но идея в том, что вы действительно можете настроить поведение скрипта для каждого отдельного сигнала.

Встроенные функции set быстрое завершение при ошибке


Очень важно реагировать на ошибки, как только они возникают, и быстро прекращать выполнение. Ничего не может быть хуже, чем продолжать выполнение команды вроде такой:

rm -rf ${directory_name}/*

Обратите внимание, что переменная directory_name не определена.

Для обработки таких сценариев важно использовать встроенные функции set, такие как set -o errexit, set -o pipefail или set -o nounset в начале скрипта. Эти функции гарантируют, что ваш скрипт завершит работу, как только он встретит любой ненулевой код завершения, использование неопределенных переменных, неправильные команды, переданные по каналу и так далее:

#!/usr/bin/env bashset -o errexitset -o nounsetset -o pipefailfunction print_var() {  echo "${var_value}"}print_var$ ./sample.sh./sample.sh: line 8: var_value: unbound variable

Примечание: встроенные функции, такие как set -o errexit, выйдут из скрипта, как только появится необработанный код возврата (кроме нуля). Поэтому лучше ввести пользовательскую обработку ошибок, например:

#!/bin/basherror_exit() {  line=$1  shift 1  echo "ERROR: non zero return code from line: $line -- $@"  exit 1}a=0let a++ || error_exit "$LINENO" "let operation returned non 0 code"echo "you will never see me"# run it, now we have useful debugging output$ bash foo.shERROR: non zero return code from line: 9 -- let operation returned non 0 code

Подобное написание скриптов заставляет вас внимательнее относиться к поведению всех команд в скрипте и предусматривать возможность возникновения ошибки прежде, чем она застанет врасплох.

ShellCheck для выявления ошибок во время разработки


Стоит интегрировать что-то вроде ShellCheck в ваши конвейеры разработки и тестирования, чтобы проверять ваш код bash на применение лучших практик.

Я использую его в своих локальных средах разработки, чтобы получать отчеты о синтаксисе, семантике и некоторых ошибках в коде, которые я мог пропустить при разработке. Это инструмент статического анализа для ваших скриптов bash, и я настоятельно рекомендую его применять.

Использование своих exit-кодов


Коды возврата в POSIX это не просто ноль или единица, а ноль или ненулевое значение. Используйте эти возможности для возврата пользовательских кодов ошибок (между 201-254) для различных случаев ошибок.

Эта информация может затем использоваться другими сценариями, которые обертывают ваш, чтобы точно понять, какой тип ошибки произошел, и реагировать соответствующим образом:

#!/usr/bin/env bashSUCCESS=0FILE_NOT_FOUND=240DOWNLOAD_FAILED=241function read_file() {  if ${file_not_found}; then    return ${FILE_NOT_FOUND}  fi}

Примечание: пожалуйста, будьте особенно осторожны с именами переменных, которые вы определяете, чтобы не допустить случайного переопределения переменных среды.

Функции-логгеры


Красивое и структурированное ведение логов важно, чтобы легко понять результаты выполнения вашего скрипта. Как и в других языках программирования высокого уровня, я всегда использую в моих скриптах bash собственные функции логирования, такие как __msg_info, __msg_error и так далее.

Это помогает обеспечить стандартизированную структуру ведения логов, внося изменения только в одном месте:

#!/usr/bin/env bashfunction __msg_error() {    [[ "${ERROR}" == "1" ]] && echo -e "[ERROR]: $*"}function __msg_debug() {    [[ "${DEBUG}" == "1" ]] && echo -e "[DEBUG]: $*"}function __msg_info() {    [[ "${INFO}" == "1" ]] && echo -e "[INFO]: $*"}__msg_error "File could not be found. Cannot proceed"__msg_debug "Starting script execution with 276MB of available RAM"

Я обычно стараюсь иметь в своих скриптах какой-то механизм __init, где такие переменные логгера и другие системные переменные инициализируются или устанавливаются в значения по умолчанию. Эти переменные также могут устанавливаться из параметров командной строки во время вызова скрипта.

Например, что-то вроде:

$ ./run-script.sh --debug

Когда такой скрипт выполняется, в нем гарантировано, что общесистемные настройки установлены в значениях по умолчанию, если они обязательны, или, по крайней мере, инициализированы чем-то соответствующим, если это необходимо.

Я обычно основываю выбор, что инициализировать, а что нет, на компромиссе между пользовательским интерфейсом и деталями конфигураций, в которые пользователь может/должен вникнуть.

Архитектура для повторного использования и чистого состояния системы


Модульный / многоразовый код


 framework    common       loggers.sh       mail_reports.sh       slack_reports.sh    daily_database_operation.sh

Я держу отдельный репозиторий, который можно использовать для инициализации нового проекта/скрипта bash, который хочу разработать. Всё, что можно использовать повторно, может быть сохранено в репозитории и получено в других проектах, которые хотят использовать такие функциональные возможности. Такая организация проектов значительно уменьшает размер других скриптов, а также гарантирует, что кодовая база мала и легко тестируема.

Как и в приведенном выше примере, все функции ведения логов, такие как __msg_info, __msg_error и другие, например отчеты по Slack, содержатся отдельно в common/* и динамически подключаются в других сценариях, вроде daily_database_operation.sh.

Оставьте после себя чистую систему


Если вы загружаете какие-то ресурсы во время выполнения сценария, рекомендуется хранить все такие данные в общем каталоге со случайным именем, например /tmp/AlRhYbD97/*. Вы можете использовать генераторы случайного текста для выбора имени директории:

rand_dir_name="$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 16 | head -n 1)"

После завершения работы очистка таких каталогов может быть обеспечена в обработчиках ловушек, обсуждаемых выше. Если об удалении временных директорий не позаботиться, они накапливаются, и на каком-то этапе вызывают неожиданные проблемы на хосте, например заполненный диск.

Использование lock-файлов


Часто нужно обеспечить выполнение только одного экземпляра сценария на хосте в любой момент времени. Это можно сделать с помощью lock-файлов.

Я обычно создаю lock-файлы в /tmp/project_name/*.lock и проверяю их наличие в начале скрипта. Это помогает корректно завершить работу скрипта и избежать неожиданных изменений состояния системы другим сценарием, работающим параллельно. Lock-файлы не нужны, если вам необходимо, чтобы один и тот же скрипт выполнялся параллельно на данном хосте.

Измерить и улучшить


Нам часто приходится работать со сценариями, которые выполняются в течение длительного периода времени, например ежедневными операциями с базами данных. Такие операции обычно включают в себя последовательность шагов: загрузка данных, проверка на наличие аномалий, импорт данных, отправка отчетов о состоянии и так далее.

В таких случаях я всегда стараюсь разбивать сценарий на отдельные маленькие скрипты и сообщать об их состоянии и времени выполнения с помощью:

time source "${filepath}" "${args}">> "${LOG_DIR}/RUN_LOG" 2>&1

Позже я могу посмотреть время выполнения с помощью:

tac "${LOG_DIR}/RUN_LOG.txt" | grep -m1 "real"

Это помогает мне определить проблемные/медленные области в скриптах, которые нуждаются в оптимизации.

Удачи!

Что еще почитать:

  1. Go и кеши GPU.
  2. Пример event-driven приложения на основе вебхуков в объектном S3-хранилище Mail.ru Cloud Solutions.
  3. Наш телеграм-канал о цифровой трансформации.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru