Я зрабіў свой PyPI-рэпазітар з аўтарызацыяй і S3. На Nginx

У дадзеным артыкуле жадаю падзеліцца досведам працы з NJS, інтэрпрэтатара JavaScript для Nginx распрацоўванага ў кампаніі Nginx Inc, апісаўшы на рэальным прыкладзе яго асноўныя магчымасці. NJS гэта падмноства ЯП JavaScript, якое дазваляе пашырыць функцыянальнасць Nginx. На пытанне навошта свой інтэрпрэтатар??? падрабязна адказаў Зміцер Валынцаў. Калі сцісла: NJS гэта nginx-way, а JavaScript больш прагрэсіўны, "родны" і без GC у адрозненні ад Lua.

Даўным даўно…

На мінулай працы ў спадчыну мне дастаўся 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. Апошні коміт, на момант пошуку, быў год таму. Рэпазітар выглядаў закінутым.

Звярнуўшыся да першакрыніцы і прачытаўшы ПЭП-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-выява. Імпартуем наш скрыпт з дапамогай дырэктывы. 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_content imported_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 + 'nnnn';

  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://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://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="/be/${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; done

rm -f *.whl

# Устанавливаем из приватной репы
pip install aiohttp -i http://localhost:8080

Паўтараем з нашымі лібамі.

# Создаем для тестов новое окружение
python3 -m venv venv
. ./venv/bin/activate

pip install setuptools wheel
python setup.py bdist_wheel
for wheel in dist/*.whl; do curl -T $wheel http://localhost:8080/${wheel%%-*}/$wheel; done

pip install our_pkg --extra-index-url http://localhost:8080

У CI, стварэнне і загрузка пакета выглядае так:

pip install setuptools wheel
python setup.py bdist_wheel

curl -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 nginx
curl 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

Крыніца: habr.com