Свежие заметки

Автоматическое развёртывание проекта при обновлении кода в репозитории

Я уже не представляю себе процесс разработки без системы контроля версий. Но помимо явного полезного действия, такие системы при определённых событиях могу вызывать различные скрипты (например, запрос по URL или отправка уведомлений на электронную почту). Этим можно воспользоваться для автоматического обновления кода проекта на сервере при наличии изменений в определённой ветке.

Коммит в репозиторий, сборка, копирование артефакта

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

Для хостинга приватных Git-репозиториев я использую Bitbucket. Там есть настройка «Webhooks». Webhook — это URL, на который Bitbucket делает POST-запрос. Нам нужно написать скрипт, который бы вызывался бы на каждый запрос, загружал изменения из Git-запозитория и выполнял другие полезные действия.

Все команды и настройки были произведены на чистом сервере Ubuntu 17.04

Подготовка окружения

В файл /etc/apt/sources.list.d/nginx.list добавляем адрес репозитория с официальными пакетами nginx:

deb http://nginx.org/packages/ubuntu/ zesty nginx
deb-src http://nginx.org/packages/ubuntu/ zesty nginx

Теперь загрузим ключ, которым подписаны пакеты и установим nginx и fcgiwrap:

curl -L https://nginx.ru/keys/nginx_signing.key | apt-key add -
apt-get update
apt-get install nginx fcgiwrap

Добавим конфигурацию сервера, который будет принимать запросы от Git-репозитория. Создадим файл /etc/nginx/conf.d/deploy.conf со следующим содержимым:

server {
  listen 80;
  listen [::]:80;

  server_name deploy.my-cool-project.com;

  location /push-hook {
    gzip off;
    fastcgi_pass unix:/var/run/fcgiwrap.socket;
    include /etc/nginx/fastcgi_params;
    fastcgi_param DOCUMENT_ROOT /var/www/deploy;
    fastcgi_param SCRIPT_FILENAME /var/www/deploy/deploy.sh;
  }
}

Каждый раз, когда nginx получить запрос по адресу http://deploy.my-cool-project.com/push-hook, на сервере будет выполнен скрипт /var/www/deploy/deploy.sh. Давайте создадим его.

mkdir -p /var/www/deploy
nano deploy.sh

Содержимое файла deploy.sh:

#!/bin/sh

echo "Content-type:text/plain\n"
echo "OK!"

Обязательно установим флаг «eXecute» у этого скрипта:

chmod 755 /var/www/deploy/deploy.sh

Теперь можно проверить как он работает:

curl http://ci.my-cool-project.com/push-hook

Клонирование репозитория

Перейдём к настройке git и первоначальному клонированию проекта на сервере.

apt-get install git

Генерируем ключ для доступа:

ssh-keygen -t rsa -f /var/www/deploy/access-key -N ""

Добавляем этот ключ в панели управления репозиторием. Ключ для доступа имеет права «только чтение» и специально создаётся для того, чтобы система CI/CD могла получать изменения.

Сохраняем публичный ключ удаленного сервера:

ssh-keyscan -t rsa bitbucket.org 2>&1 >> /etc/ssh/ssh_known_hosts

Команда ssh делает это автоматически при первом подключении к неизвестному хосту во время процедуры обмена ключами. Но сохраняет она этот публичный ключ в файл для пользователя, выполнившего команду, и только после положительного ответа на запрос. Чтобы команда git pull нормально выполнялась в фоновом процессе под пользователем www-data, публичный ключ нужно добавить вручную в общий файл.

Установим владельца папки /var/www/deploy и её содержимого:

chown -R www-data:www-data /var/www/deploy

Теперь можно клонировать репозиторий:

cd /var/www/deploy
GIT_SSH_COMMAND='ssh -i /var/www/deploy/access-key -o UserKnownHostsFile=/dev/null'
git clone git@bitbucket.org:mistakster/test-project.git ./repo
chown -R www-data:www-data ./repo

Так как мы используем специальный ключ для доступа к репозитории, то мы явно должны сообщить команде git о нём. Однако она не предоставляет соответствующей опции. Данная проблема решается через переменную окружения GIT_SSH_COMMAND. В ней можно указать любые настройки, которые нам нужны.

Подтягивание изменений

Когда мы запушим в репозиторий какие-то изменения, Git-сервер вызовет webhook и передаст информацию о них в виде JSON-объекта. В самом простом варианте, деплой можно запускать при любых изменениях. Но корректнее было бы отслеживать только изменения в нужной ветке, например, master.

Для парсинга JSON в bash-скрипте я буду использовать команду jq.

apt-get install jq

Обновим файл deploy.sh:

#!/bin/sh

echo "Content-type:text/plain\n"

if
  [ -n "$HTTP_X_HOOK_UUID" ] &&
  [ -n "$HTTP_X_REQUEST_UUID" ] &&
  [ "$HTTP_X_EVENT_KEY" = "repo:push" ] &&
  [ "$HTTP_CONTENT_TYPE" = "application/json" ] &&
  [ "$REQUEST_METHOD" = "POST" ]
then
  MASTER_COMMITS=`jq '[.push.changes[].new | select(.name == "master" and .type == "branch")] | length'`

  if [ "$MASTER_COMMITS" -gt 0 ]
  then
    cd ./repo

    GIT_SSH_COMMAND='ssh -i /var/www/deploy/access-key -o UserKnownHostsFile=/dev/null'
    git fetch --all 2>&1
    git reset --hard origin/master
    ./build.sh

    echo "Build complete"
  else
    echo "Build skipped"
  fi
else
  echo "Wrong request"
fi

Теперь мы ожидаем только POST-запрос с соответствующим заголовками и данными. Если в среди всех коммитов встретились изменения в ветке master, то запускается загрузка этих изменений из репозитория и вызов скрпита build.sh в корне проекта.

Фактические действия, необходимые для сборки, например, загрузка пакетов из npm-репозитория, запуск webpack, копирование артефактов и т.п., удобнее вынести именно в скрипт build.sh. Это вполне логично, так как для разных проектов требуются разные действия. К тому же он будет обновляться вместе с проектом и загружаться автоматически.

Теперь можно настроить webhook в Bitbucket.

Настройка webhook в Bitbucket

Заключение

Данное решение покрывает лишь минимально необходимые требования. Оно не является заменой популярных систем CI/CD. Тем не менее, оно стабильно, прекрасно подходит для экспериментальных проектов и заметно сокращает количество рутинных операций для небольшой команды разработчиков.

Оставте свой комментарий

util.promisify() для замены колбэков на промисы

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

Все асинхронные функции node.js и подавляющее большинство асинхронных функций внешних модулей на данный момент всё же используют колбэки, чтобы вернуть результат работы или сообщить об ошибке.

Технически, не сложно обернуть асинхронную функцию в промиз. Но, к сожалению, это не одна строчка кода.

const fs = require('fs');

function reader(path) {
    return new Promise((resolve, reject) => {
        fs.readdir(path, (err, list) => {
            if (err) {
                reject(err);
                return;
            }

            resolve(list);
        });
    });
}

reader('.')
    .then(list => {
        console.log(list);
        process.exit(0);
    })
    .catch(err => {
        console.error(err);
        process.exit(1);
    });

В стандартной библиотеке node.js 8 util появилась функция promisify(), которая как раз решает эту задачу.

const fs = require('fs');
const util = require('util');

const reader = util.promisify(fs.readdir);

reader('.')
    .then(list => {
        console.log(list);
        process.exit(0);
    })
    .catch(err => {
        console.error(err);
        process.exit(1);
    });

Функция promisify() придерживается соглашения:

  1. колбек в параметрах исходной функции всегда идет последним;
  2. первым параметром колбека приходит объект ошибки, если она есть.

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

function Timer(delay) {
    this._delay = delay;
}

Timer.prototype.start = function (callback) {
    setTimeout(callback, this._delay);
};

const timer = new Timer(1000);
timer.start(() => {
    console.log('Finish');
});

Из метода start() я хочу сделать новую функцию, возвращающую мне промиз.

const start = util.promisify(timer.start);
start().then(() => {
    console.log('Finish');
});

Но этот фрагмент кода перестаёт работать. Он начинает выдавать ошибку:

TypeError: Cannot read property '_delay' of undefined

Проблема кроется в том, что метод внутри себя использует ссылку на экземпляр класса Timer — в частности, он вытаскиваем величину задержки. А так как метод был вызван вне контекста экземпляра таймера, то this оказалось не определено.

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

const start = timer.start;
start(() => {
    console.log('Finish');
});

Исправить ситуацию можно несколькими путями.

Связать функцию с экземпляром

const start = util.promisify(timer.start).bind(timer);

Теперь не важно как мы вызовем функцию start(). Она всегда будет иметь экземпляр таймера в качестве контекста исполнения.

Сохранить функцию в экземпляр класса

timer.start = util.promisify(timer.start);
timer.start().then(() => {
    console.log('Finish');
});

Для данного экземпляра мы подменили реализацию метода из прототипа на новую функцию. Контекст исполнения будет передаваться естественным образом.

Развивая этот вариант дальше, новый метод можно сохранить в прототипе класса.

Timer.prototype.start = util.promisify(Timer.prototype.start);

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

Оставте свой комментарий

Как удалить произвольную копию диска в Time Machine

Для управления Time Machine из командной строки существует утилита tmutil. Нас интересуют методы получения списка бэкапов и удаления выбранного бэкапа.

Чтобы получить список бэкапов, выполним команду:

tmutil listbackups

Теперь, зная путь до конкретного архива, его можно удалить командой

tmutil delete /Volumes/Backup/Backups.backupdb/Slim/2016-10-01-031248

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

tmutil listbackups | grep '2016-0[4-9]' | sort -r | xargs -n 1 sudo tmutil delete

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

Оставте свой комментарий

Код состояния HTTP для неавторизованного пользователя

Среди официального списка кодов состояний HTTP есть такой код:

401 Unauthorized

The request requires user authentication.

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

В описании кода 401 ясно указано, что вместе с таким ответом сервер должен передать заголовок WWW-Authenticate с перечнем условий. А браузер может повторить запрос, включив в него заголовок Authorization с требуемыми для аутентификации данными.

Чаще всего в реальной жизни этот код можно встретить, если доступ к ресурсу защищён паролем. Например, в Apache это можно сделать добавив в конфигурацию следующие строки:

AuthName "Secure Area"
AuthType Basic
AuthUserFile "/usr/local/apache/passwd/passwords"
Require valid-user

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

Очевидно, что такая проверка уровня доступа пользователя к запрашиваемому ресурсу по протоколу HTTP никак не связана с разграничением прав пользователей внутри сервера приложения.

А как лучше?

По моему мнению, разумнее отвечать неавторизованному пользователю кодом 403 Forbiden

403 Forbidden

The server understood the request, but is refusing to fulfill it.

А в теле ответа передать JSON с описанием проблемы.

Комментарии к заметке: 4