Mi faris mian propran PyPI-deponejon kun rajtigo kaj S3. Sur Nginx

En ĉi tiu artikolo mi ŝatus kunhavigi mian sperton kun NJS, JavaScript-interpretisto por Nginx evoluigita de Nginx Inc, priskribante ĝiajn ĉefajn kapablojn uzante realan ekzemplon. NJS estas subaro de JavaScript, kiu permesas vin etendi la funkciecon de Nginx. Al la demando kial via propra interpretisto??? Dmitrij Volyntsev detale respondis. Mallonge: NJS estas nginx-maniero, kaj JavaScript estas pli progresema, "denaska" kaj sen GC, male al Lua.

Antaŭ longe…

Ĉe mia lasta laboro, mi heredis gitlab kun kelkaj diversaj CI/CD-duktoj kun docker-compose, dind kaj aliaj ĝojoj, kiuj estis translokigitaj al kaniko-reloj. La bildoj kiuj antaŭe estis uzitaj en CI estis movitaj en sia originala formo. Ili funkciis ĝuste ĝis la tago, kiam nia gitlab IP ŝanĝiĝis kaj CI fariĝis kukurbo. La problemo estis, ke unu el la docker-bildoj kiuj partoprenis en CI havis git, kiu tiris Python-modulojn per ssh. Por ssh vi bezonas privatan ŝlosilon kaj... ĝi estis en la bildo kune kun known_hosts. Kaj iu ajn CI malsukcesis kun ŝlosila kontrola eraro pro miskongruo inter la reala IP kaj tiu specifita en known_hosts. Nova bildo estis rapide kunvenita de la ekzistantaj Dockfiles kaj la opcio estis aldonita StrictHostKeyChecking no. Sed la malbona gusto restis kaj estis deziro movi la libojn al privata PyPI-deponejo. Kroma gratifiko, post ŝanĝado al privata PyPI, estis pli simpla dukto kaj normala priskribo de requirements.txt

La elekto estas farita, Sinjoroj!

Ni kuras ĉion en la nuboj kaj Kubernetes, kaj finfine ni volis akiri malgrandan servon, kiu estis sennacia ujo kun ekstera stokado. Nu, ĉar ni uzas S3, prioritato ricevis al ĝi. Kaj, se eble, kun aŭtentigo en gitlab (vi povas aldoni ĝin mem se necese).

Rapida serĉo donis plurajn rezultojn: s3pypi, pypicloud kaj opcio kun "manlibro" kreado de html-dosieroj por rapoj. La lasta opcio malaperis per si mem.

s3pypi: Ĉi tio estas kli por uzi S3-gastigadon. Ni alŝutas la dosierojn, generas la html-on kaj alŝutas ĝin al la sama sitelo. Taŭga por hejma uzo.

pypicloud: Ĝi ŝajnis interesa projekto, sed leginte la dokumentadon mi estis seniluziigita. Malgraŭ bona dokumentado kaj la kapablo vastiĝi laŭ viaj bezonoj, fakte ĝi montriĝis superflua kaj malfacile agordebla. Korekti la kodon laŭ viaj taskoj, laŭ taksoj tiutempe, estus preninta 3-5 tagojn. La servo ankaŭ bezonas datumbazon. Ni lasis ĝin, se ni ne trovis ion alian.

Pli profunda serĉo donis modulon por Nginx, ngx_aws_auth. La rezulto de lia testado estis XML montrita en la retumilo, kiu montris la enhavon de la S3 sitelo. La lasta transdono en la momento de la serĉo estis antaŭ jaro. La deponejo aspektis forlasita.

Iru al la fonto kaj legante PEP-503 Mi rimarkis, ke XML povas esti transformita al HTML sur la flugo kaj donita al pip. Post guglo iom pli pri Nginx kaj S3, mi trovis ekzemplon de aŭtentigo en S3 skribita en JS por Nginx. Tiel mi renkontis NJS.

Prenante ĉi tiun ekzemplon kiel bazon, horon poste mi vidis en mia retumilo la saman XML kiel kiam oni uzis la modulon ngx_aws_auth, sed ĉio estis jam skribita en JS.

Mi tre ŝatis la solvon de nginx. Unue, bona dokumentado kaj multaj ekzemploj, due, ni ricevas ĉiujn bonaĵojn de Nginx por labori kun dosieroj (el la skatolo), trie, ĉiu, kiu scias kiel skribi agordojn por Nginx, povos eltrovi kio estas kio. Minimumismo ankaŭ estas pluso por mi, kompare kun Python aŭ Go (se skribite de nulo), por ne mencii ligon.

TL;DR Post 2 tagoj, la testa versio de PyPi jam estis uzata en CI.

Kiel ĝi funkcias?

La modulo estas ŝarĝita en Nginx ngx_http_js_module, inkluzivita en la oficiala docker-bildo. Ni importas nian skripton uzante la direktivon js_importal la agordo Nginx. La funkcio estas vokita de direktivo js_content. La direktivo estas uzata por agordi variablojn js_set, kiu prenas kiel argumenton nur la funkcion priskribitan en la skripto. Sed ni povas efektivigi subdemandojn en NJS nur uzante Nginx, ne ajnan XMLHttpRequest. Por fari tion, la responda loko devas esti aldonita al la agordo Nginx. Kaj la skripto devas priskribi subpeton al ĉi tiu loko. Por povi aliri funkcion de la Nginx-agordo, la funkcionomo devas esti eksportita en la skripto mem 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}

Kiam oni petas en la retumilo http://localhost:8080/ ni eniras location /en kiu la direktivo js_content nomas funkcion request priskribita en nia skripto script.js. Siavice, en la funkcio request subdemando estas farita al location = /sub-query, kun metodo (en la nuna ekzemplo GET) akirita de la argumento (r), pasita implicite kiam ĉi tiu funkcio estas vokita. La subpeta respondo estos procesita en la funkcio call_back.

Provante S3

Por fari peton al privata S3-stokado, ni bezonas:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

De la uzata http-metodo, la nuna dato/tempo, S3_NAME kaj URI, certa tipo de ĉeno estas generita, kiu estas subskribita (HMAC_SHA1) uzante SECRET_KEY. Poste estas linio kiel AWS $ACCESS_KEY:$HASH, uzeblas en la rajtiga kaplinio. La sama dato/tempo, kiu estis uzata por generi la ĉenon en la antaŭa paŝo, devas esti aldonita al la kaplinio X-amz-date. En kodo ĝi aspektas jene:

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 rajtigo ekzemplo, ŝanĝita al malrekomendita stato)

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}

Eta klarigo pri _subrequest_uri: ĉi tiu estas variablo kiu, depende de la komenca uri, formas peton al S3. Se vi bezonas akiri la enhavon de la "radiko", tiam vi devas krei uri-peton indikantan la limigilon. delimiter, kiu resendos liston de ĉiuj CommonPrefixes xml-elementoj, respondaj al dosierujoj (en la kazo de PyPI, listo de ĉiuj pakaĵoj). Se vi bezonas ricevi liston de enhavo en specifa dosierujo (listo de ĉiuj pakaĵversioj), tiam la uri-peto devas enhavi prefiksan kampon kun la nomo de la dosierujo (pakaĵo) nepre finiĝanta per oblikvo /. Alie, kolizioj eblas kiam oni petas la enhavon de dosierujo, ekzemple. Estas dosierujoj aiohttp-request kaj aiohttp-requests kaj se la peto precizigas /?prefix=aiohttp-request, tiam la respondo enhavos la enhavon de ambaŭ dosierujoj. Se estas oblikvo ĉe la fino, /?prefix=aiohttp-request/, tiam la respondo enhavos nur la bezonatan dosierujon. Kaj se ni petas dosieron, tiam la rezulta uri ne diferencu de la originala.

Konservu kaj rekomencu Nginx. En la retumilo ni enigas la adreson de nia Nginx, la rezulto de la peto estos XML, ekzemple:

Listo de dosierujoj

<?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>

El la listo de dosierujoj vi bezonos nur la elementojn CommonPrefixes.

Aldonante la dosierujon, kiun ni bezonas al nia adreso en la retumilo, ni ricevos ankaŭ ĝian enhavon en XML-formo:

Listo de dosieroj en dosierujo

<?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>

El la listo de dosieroj ni prenos nur elementojn Key.

Restas nur analizi la rezultan XML kaj sendi ĝin kiel HTML, unue anstataŭiginte la kaplinion Content-Type per teksto/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="/eo/${reValue.groups.v}">${a_text}</a>`);
    }
  }

  return '<html><body>n' + out.join('</br>n') + 'n</html></body>'
}

Provante PyPI

Ni kontrolas, ke nenio rompas ie ajn sur pakaĵoj, kiuj estas konataj por funkcii.

# Создаем для тестов новое окружение
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

Ni ripetas kun niaj liboj.

# Создаем для тестов новое окружение
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

En CI, krei kaj ŝarĝi pakaĵon aspektas jene:

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}"

Aŭtentigo

En Gitlab eblas uzi JWT por aŭtentigo/rajtigo de eksteraj servoj. Uzante la auth_request-direktivon en Nginx, ni redirektos la aŭtentigajn datumojn al subpeto enhavanta funkciovokon en la skripto. La skripto faros alian subpeton al la Gitlab-url kaj se la aŭtentikigdatenoj estis ĝuste specifitaj, tiam Gitlab resendos kodon 200 kaj la alŝuto/elŝuto de la pakaĵo estos permesita. Kial ne uzi unu subdemandon kaj tuj sendi la datumojn al Gitlab? Ĉar tiam ni devos redakti la agordan dosieron de Nginx ĉiufoje kiam ni faras ajnajn ŝanĝojn en rajtigo, kaj ĉi tio estas sufiĉe teda tasko. Ankaŭ, se Kubernetes uzas nurlegeblan radikan dosiersisteman politikon, tiam ĉi tio aldonas eĉ pli da komplekseco kiam oni anstataŭigas nginx.conf per konfigmapo. Kaj fariĝas absolute neeble agordi Nginx per konfigmapo samtempe uzante politikojn malpermesantajn konekton de volumoj (pvc) kaj nurlegebla radika dosiersistemo (ĉi tio ankaŭ okazas).

Uzante la mezan NJS, ni ricevas la ŝancon ŝanĝi la specifitajn parametrojn en la nginx-agordo uzante mediajn variablojn kaj fari kelkajn kontrolojn en la skripto (ekzemple, malĝuste specifita 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}

Plej verŝajne la demando kreiĝas: -Kial ne uzi pretajn modulojn? Ĉio jam estas farita tie! Ekzemple, var AWS = require('aws-sdk') kaj ne necesas skribi "biciklon" kun S3-aŭtentigo!

Ni transiru al la malavantaĝoj

Por mi, la malkapablo importi eksterajn JS-modulojn fariĝis malagrabla, sed atendata trajto. Priskribita en la supra ekzemplo require('crypto') estas enkonstruitaj moduloj kaj postulas nur verkojn por ili. Ankaŭ ne ekzistas maniero reuzi kodon de skriptoj kaj vi devas kopii kaj alglui ĝin en malsamajn dosierojn. Mi esperas, ke iam ĉi tiu funkcio estos efektivigita.

Kunpremado ankaŭ devas esti malŝaltita por la nuna projekto en Nginx gzip off;

Ĉar ne ekzistas gzip-modulo en NJS kaj estas neeble konekti ĝin; tial ne ekzistas maniero labori kun kunpremitaj datumoj. Vere, ĉi tio ne vere estas minusaĵo por ĉi tiu kazo. Ne estas multe da teksto, kaj la translokigitaj dosieroj jam estas kunpremitaj kaj plia kunpremado ne multe helpos ilin. Ankaŭ ĉi tio ne estas tia ŝarĝita aŭ kritika servo, ke vi devas ĝeni liveri enhavon kelkajn milisekundojn pli rapide.

Sencimigi la skripton daŭras longan tempon kaj eblas nur per "presaĵoj" en error.log. Depende de la fiksita ensaluta nivelo informoj, averto aŭ eraro, eblas uzi 3 metodojn r.log, r.warn, r.error respektive. Mi provas sencimigi kelkajn skriptojn en Chrome (v8) aŭ la konzolo njs, sed ne ĉio povas esti kontrolita tie. Kiam vi elpurigas kodon, alinome funkcian testadon, historio aspektas kiel ĉi tio:

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

kaj povas esti centoj da tiaj sekvencoj.

Skribi kodon uzante subdemandojn kaj variablojn por ili fariĝas implikita implikaĵo. Foje vi komencas rapidi ĉirkaŭ malsamaj IDE-fenestroj provante eltrovi la sekvencon de agoj de via kodo. Ĝi ne estas malfacila, sed foje ĝi estas tre ĝena.

Ne ekzistas plena subteno por ES6.

Eble estas iuj aliaj mankoj, sed mi renkontis nenion alian. Kunhavigu informojn se vi havas negativan sperton uzante NJS.

konkludo

NJS estas malpeza malfermfonta interpretisto, kiu ebligas al vi efektivigi diversajn JavaScript-skriptojn en Nginx. Dum ĝia evoluo, granda atento estis pagita al agado. Kompreneble, ankoraŭ multe mankas, sed la projekto estas disvolvita de malgranda teamo kaj ili aktive aldonas novajn funkciojn kaj riparas cimojn. Mi esperas, ke iam NJS permesos vin konekti eksterajn modulojn, kio faros Nginx-funkcion preskaŭ senlima. Sed ekzistas NGINX Plus kaj plej verŝajne ne estos funkcioj!

Deponejo kun plena kodo por la artikolo

njs-pypi kun subteno de AWS Sign v4

Priskribo de la direktivoj de la modulo ngx_http_js_module

Oficiala NJS-deponejo и dokumentado

Ekzemploj de uzado de NJS de Dmitry Volintsev

njs - denaska JavaScript-skripto en nginx / Parolado de Dmitry Volnyev ĉe Saint HighLoad++ 2019

NJS en produktado / Parolado de Vasilij Soŝnikov ĉe HighLoad++ 2019

Subskribi kaj Aŭtentikigi REST-Petojn en AWS

fonto: www.habr.com