Типичный юзкейс для Kibana - смотрим логи, видим ошибки, создаем тикеты по ним. Логов у нас довольно много, места для их хранения мало. Поэтому просто вставить ссылку на документ из Elasticsearch/Kibana недостаточно, особенно для низкоприоритетных задач: пока доберемся до нее, индекс с логом может быть уже удален. Соответственно, приходится документ сохранять в файл и прикреплять к тикету.

Если один раз это делать, то это еще куда ни шло, но создавать уже десять тикетов подряд будет тупо лень, поэтому я решил это “быстренько” (ха-ха) автоматизировать.


Под катом: статья для пятницы, экспериментальная фича javascript, пара грязных хаков, небольшая регулярка с галочками, reverse proxy, проигрыш безопасности удобству, костыли и очевидная картинка из xkcd. Предупреждаю: я далеко не специалист в web-технологиях, и поэтому специалистам, скорее всего, покажутся мои проблемы очень очевидными, а решения - тупыми. Но это не продакшн-решение, а просто мелкий скрипт "для своих". Все происходит в доверенной локальной сети и поэтому скрипт имеет много проблем с безопасностью.

Варианты решения

Сходу можно придумать достаточно много решений проблемы. Во-первых, можно пихать сразу все логи в RM (внезапно, для этого даже есть плагин logstash), предварительно их фильтруя/агрегируя - знай себе меняй описание да исполнителя. Это, конечно, прикольно, но надо будет долго отлаживать/настраивать и появится много новой рутинной работы - давать описания/удалять лишнее.

Второй вариант - намастрячить какой-нибудь скрипт, который получает ссылки на логи, скачивает их, спрашивает дополнительные параметры у пользователя и через API Redmine создает новый тикет. Но к этому надо будет нормальный интерфейс пилить, да и дублировать часть функций RM…

Можно извратиться и сделать кликер или с помощью selenium подготовить тикет, чтобы потом в привычном интерфейсе дозаполнить что надо, но нельзя будет трогать мышку… Да и редактирование может вдруг понадобиться.

Плагин для браузера? Окститесь, его еще регистрировать и поддерживать, да еще хотя бы под два браузера делать.

Плагин Redmine? Не, это ж API надо будет изучать, да и лезть в кишки RM… Простого дополнительного поля недостаточно будет.

В итоге приходим к букмарклету (выполнению javascript из закладок) и/или пользовательскому скрипту (greasemonkey/tampermonkey и т.п.) - javascript‘ом вроде можно и интерфейс нарисовать, и логи скачать через ajax-запрос, да и вообще почти все что угодно со страницей сделать.

Загрузка файлов

Пока самая неясная часть - это загрузка файлов. Все остальное вроде можно легко сделать… За загрузку файлов на странице создания тикета RM отвечает обычный <input type="file">, при изменении которого вызывается функция addInputFiles(this).

По идее, надо всего лишь изменить список файлов у этого элемента и дернуть этот метод. Есть только одна мааааленькая проблема:


Сделано это ради того, чтобы нельзя было отправить на сервер /etc/passwd, /etc/shadow/ или фото вашего кота с рабочего стола. В принципе, разумно, но надо это как-то обойти. Впрочем, если нельзя, но очень хочется, то можно заиспользовать такой грязный хак, который основан экспериментальной фиче - Clipboard API.
function createFileList(files){
    const dt = new ClipboardEvent("").clipboardData || new DataTransfer();
    for (let file of files) {
        dt.items.add(file);
    }
    return dt.files;
}

Т.е. тут имитируется добавление файлов из буфера обмена, которые мы потом получаем списком. Сам по себе “файл” из текста создается элементарно:

function createFile(text, fileName){
    let blob = new Blob([text], {type: 'text/plain'});
    let file = new File([blob], fileName);
    return file;
}

Пользовательский интерфейс

Тут все просто, как топор: делаем в нужном месте надпись, поле ввода и кнопку загрузки. Поскольку делается "для своих" с форматом ввода (и его валидацией) особо не стал заморачиваться - пусть будет текстовое поле, одна строка - один лог (ссылка и имя создаваемого файла через пробел).

Для букмарклета еще пригодилось предварительное удаление себя по id.

Элементарные вещи
function removeSelf(){
    let old = document.getElementById(ui_id);
    if (old != null) old.remove();
}

function createUi(){
    removeSelf();

    let ui = document.createElement('p');
    ui.id = ui_id;

    let label = document.createElement('label');
    label.innerHTML = "Logs data:";
    ui.appendChild(label);

    let textarea = document.createElement('textarea');
    textarea.id = data_id;
    textarea.cols = 60;
    textarea.rows = 10;
    textarea.name = "issue[logs_data]";
    ui.appendChild(textarea);

    let button = document.createElement('button');
    button.type = "button";
    button.onclick = addLogsData;
    button.innerHTML = "Add logs data";
    ui.appendChild(button);

    let attributesBlock = document.querySelector("#attributes");
    attributesBlock.parentNode.insertBefore(ui, attributesBlock);
}

Основная работа

Здесь тоже все просто: разбиваем текст из поля ввода на пары "ссылка"-"имя файла", скачиваем все из эластика, потому что Kibana так просто данные не отдаст, заливаем на RM, изменяем описание тикета и все. Благо к RM уже подключен jquery и ajax-запросы легко создаются.
Скучный код, регулярку искать здесь
function addLogsData(){
    let text = document.getElementById(data_id).value;
    let lines = text.split('\n');
    let urlsAndNames = lines
        .filter(x => x.length > 2)
        .map(line => line.split(/\s+/, 2));
    downloadUrlsToFiles(urlsAndNames);
}

const kibana_pattern = /http:\/\/([^:]*):\d+\/app\/kibana#\/doc\/[^\/]*\/([^\/]*)\/([^\/]*)\/?\?id=(.*?)(&.*)?$/;
const es_pattern = 'http://$1:9200/$2/$3/$4';

function downloadUrlsToFiles(urlsAndNames){
    let requests = urlsAndNames.map((splitted) => {
        let url = splitted[0].replace(kibana_pattern, es_pattern);
        return $.ajax({
            url: url,
            dataType: 'json'
        });
    });
    $.when(...requests).done(function(...responses){
        let files = responses.map((responseRaw, index) => {
            let response = responseRaw[0];
            checkError(response);
            let fileName = urlsAndNames[index][1];
            return createFile(JSON.stringify(response._source), fileName + '.json');
        });
        uploadFiles(files, urlsAndNames);
    }).fail((error) => {
        let errorString = JSON.stringify(error);
        alert(errorString);
        throw errorString;
    });
}

function uploadFiles(files, urlsAndNames){
    pseudoUpload(files);

    changeDescription(urlsAndNames);
    removeSelf();
}

Отлично, все готово! Делаем тестовый запуск и…

Безопасность

Для тех кто не в курсе, запрашивать http-данные, находясь на https ресурсе, - очень плохо, потому что вам могут подпихнуть левые данные через MITM атаку. Более того, какой-нибудь Firefox даже если вам и разрешит это сделать, просить у него разрешение надо будет каждый раз - и белого списка никогда не будет. Это все правильно и хорошо с точки зрения пользователя, но для скрипта на коленке это только палки в колеса.

Что ж, покупать X-Pack для Elasticsearch ради вшивого скрипта не хочется, поэтому придется сделать прокси https -> http. Он же reverse proxy. Вариантов тут достаточно много, от монструозного squid до питонячего скрипта. Самым подходящим мне показался haproxy - он и прост в настройке/установке, и ресурсы не жрет.

Достаточно лишь сгенерить самоподписанный сертификат (прости, let‘s encrypt, но мы в траст-зоне)

openssl genrsa -out dummy.key 1024
openssl req -new -key dummy.key -out dummy.csr
openssl x509 -req -days 3650 -in dummy.csr -signkey dummy.key -out dummy.crt
cat dummy.crt dummy.key > dummy.pem

и, собственно, настроить haproxy:

frontend https-in
    mode tcp
    bind *:9243 ssl crt /etc/ssl/localcerts/dummy.pem alpn http/1.1
    http-response set-header Strict-Transport-Security "max-age=16000000; includeSubDomains; preload;"
    default_backend nodes-http

backend nodes-http
    server node1 localhost:9200 check

Теперь на порту 9243 будет прозрачная прокси до эластика (соответственно, меняем порт в регулярке и добавляем https).

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

http.cors.allow-headers: X-Requested-With, Content-Type, Content-Length
http.cors.allow-origin: "/.*/"
http.cors.enabled: true

Userscript

Напомню, что мы все еще втирали делали эту дичь в формате букмарклета. В принципе, ничего страшного, но кому-то даже лишний раз кликнуть лень (например, мне). Поэтому будем делать userscript. Тут заодно встает проблема его обновления (делаем-то на века!). Поэтому воспользуемся механизмом обновления юзерскриптов кого я обманываю, конечно, очередным костылем:
// ==UserScript==
// @name     KIBANA_LOGS
// @grant    none
// @include  https://<rm-address>/*issues*
// ==/UserScript==
(function(){document.body.appendChild(document.createElement('script')).src='https://<kibana-address>:4443/kibana_logs_rm.js';})();

Зато и в букмарклетах у параноиков будет обновляться. Для раздачи этой фигни нам понадобится https-сервер. Тут я уже откровенно заленился и взял первый попавшийся (да еще и на python 2.7) *посыпаю голову пеплом*:

import BaseHTTPServer, SimpleHTTPServer
import ssl

httpd = BaseHTTPServer.HTTPServer(('0.0.0.0', 4443), 
                    SimpleHTTPServer.SimpleHTTPRequestHandler)
httpd.socket = ssl.wrap_socket(httpd.socket, certfile='/etc/ssl/localcerts/dummy.pem',
                            server_side=True)
httpd.serve_forever()

Вот теперь пользователям осталось только создать юзерскрипт/букмарклет, добавить в исключения сертификат и все будет работать.

Пара багов

Суть первой проблемы заключается в следующем: когда нужно обработать результаты сразу нескольких ajax-запросов, в функцию передается столько аргументов, сколько было запросов. Но когда запрос один, jquery "любезно" его раскрывает в три аргумента. Поэтому пришлось писать такой костыль:
let responses;
if (requests.length == 1){
    responses = [arguments];
} else {
    responses = Array.from(arguments);
}

Второй баг связан с тем, что при смене трекера или при смене статуса заявки Redmine сохраняет все введенные данные, запрашивает новый интерфейс (прямо html cо встроенным js), пересоздает интерфейс и перезаполняет поля с помощью функции replaceIssueFormWith. Звучит немного дико, но это сделано для реализации workflow (а там на разных стадиях поля для ввода потенциально могут отличаться). Тут тоже пришлось сделать костыль ad-hoc решение:

function installReplaceHook(){
    let original = window.replaceIssueFormWith;
    window.replaceIssueFormWith = function(html){
        let logs_data = document.getElementById(data_id).value;
        let ret = original(html);
        createUi();
        document.getElementById(data_id).value = logs_data;
        return ret;
   };
}

Т.е. просто делаем хук на оригинальную функцию и делаем аналогичные ей действия для своего поля.

Заключение

Полную версию скрипта можно посмотреть в моем gist. Вот картинка, которую должно большинство ожидать к концу этой статьи:

А вообще автоматизировать вещи - весело и полезно, и позволяет изучить что-то новое в другой области. Пользователи скрипта довольны, создание тикетов по логам в кибане теперь не так сильно напрягает.