Ho creato il mio repository PyPI con autorizzazione e S3. Su Nginx

In questo articolo vorrei condividere la mia esperienza con NJS, un interprete JavaScript per Nginx sviluppato da Nginx Inc, descrivendone le principali funzionalità utilizzando un esempio reale. NJS è un sottoinsieme di JavaScript che consente di estendere le funzionalità di Nginx. Alla domanda perché il tuo interprete??? Dmitry Volyntsev ha risposto in dettaglio. In breve: NJS è nginx-way e JavaScript è più progressivo, “nativo” e senza GC, a differenza di Lua.

Tanto tempo fa…

Nel mio ultimo lavoro, ho ereditato gitlab con una serie di eterogenee pipeline CI/CD con docker-compose, dind e altre delizie, che sono state trasferite su binari Kaniko. Le immagini precedentemente utilizzate in CI sono state spostate nella loro forma originale. Hanno funzionato correttamente fino al giorno in cui il nostro IP gitlab è cambiato e CI si è trasformato in una zucca. Il problema era che una delle immagini docker che partecipavano a CI aveva git, che estraeva i moduli Python tramite ssh. Per ssh hai bisogno di una chiave privata e... era nell'immagine insieme aknown_hosts. E qualsiasi elemento della configurazione non è riuscito con un errore di verifica della chiave a causa di una mancata corrispondenza tra l'IP reale e quello specificato inknown_hosts. Una nuova immagine è stata rapidamente assemblata dai Dockfile esistenti e l'opzione è stata aggiunta StrictHostKeyChecking no. Ma il cattivo gusto rimaneva e c'era il desiderio di spostare le librerie in un repository PyPI privato. Un ulteriore vantaggio, dopo il passaggio al PyPI privato, era una pipeline più semplice e una normale descrizione di requisiti.txt

La scelta è stata fatta, Signori!

Eseguiamo tutto nei cloud e in Kubernetes e alla fine volevamo ottenere un piccolo servizio che fosse un contenitore stateless con storage esterno. Bene, poiché utilizziamo S3, gli è stata data la priorità. E, se possibile, con l'autenticazione in gitlab (puoi aggiungerla tu stesso se necessario).

Una rapida ricerca ha prodotto diversi risultati: s3pypi, pypicloud e un'opzione con creazione “manuale” di file html per le rape. L'ultima opzione è scomparsa da sola.

s3pypi: questa è una CLI per l'utilizzo dell'hosting S3. Carichiamo i file, generiamo l'html e lo carichiamo nello stesso bucket. Adatto per uso domestico.

pypicloud: Sembrava un progetto interessante, ma dopo aver letto la documentazione sono rimasto deluso. Nonostante la buona documentazione e la possibilità di espansione in base alle proprie esigenze, in realtà si è rivelato ridondante e difficile da configurare. Secondo le stime dell'epoca, la correzione del codice per adattarlo ai propri compiti avrebbe richiesto 3-5 giorni. Il servizio necessita anche di un database. L'abbiamo lasciato nel caso non avessimo trovato nient'altro.

Una ricerca più approfondita ha prodotto un modulo per Nginx, ngx_aws_auth. Il risultato dei suoi test è stato un XML visualizzato nel browser, che mostrava il contenuto del bucket S3. L'ultimo impegno al momento della ricerca risale a un anno fa. Il deposito sembrava abbandonato.

Andando alla fonte e leggendo PEP-503 Mi sono reso conto che XML può essere convertito in HTML al volo e assegnato a pip. Dopo aver cercato su Google un po' di più su Nginx e S3, mi sono imbattuto in un esempio di autenticazione in S3 scritto in JS per Nginx. È così che ho conosciuto NJS.

Prendendo come base questo esempio, un'ora dopo ho visto nel mio browser lo stesso XML di quando utilizzavo il modulo ngx_aws_auth, ma tutto era già scritto in JS.

Mi è piaciuta molto la soluzione nginx. In primo luogo, buona documentazione e molti esempi, in secondo luogo, otteniamo tutti i vantaggi di Nginx per lavorare con i file (fuori dagli schemi), in terzo luogo, chiunque sappia come scrivere configurazioni per Nginx sarà in grado di capire cosa è cosa. Anche il minimalismo è un vantaggio per me, rispetto a Python o Go (se scritto da zero), per non parlare del nexus.

TL;DR Dopo 2 giorni, la versione di prova di PyPi era già utilizzata in CI.

Come funziona?

Il modulo viene caricato in Nginx ngx_http_js_module, incluso nell'immagine docker ufficiale. Importiamo il nostro script utilizzando la direttiva js_importalla configurazione Nginx. La funzione viene chiamata da una direttiva js_content. La direttiva viene utilizzata per impostare le variabili js_set, che accetta come argomento solo la funzione descritta nello script. Ma possiamo eseguire sottoquery in NJS solo utilizzando Nginx, non qualsiasi XMLHttpRequest. Per fare ciò è necessario aggiungere la posizione corrispondente alla configurazione di Nginx. E lo script deve descrivere una sottorichiesta in questa posizione. Per poter accedere ad una funzione dalla configurazione Nginx, il nome della funzione deve essere esportato nello script stesso 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}

Quando richiesto nel browser http://localhost:8080/ entriamo location /in cui la direttiva js_content chiama una funzione request descritto nel nostro script script.js. A sua volta, nella funzione request viene eseguita una sottoquery location = /sub-query, con un metodo (nell'esempio corrente GET) ottenuto dall'argomento (r), passato implicitamente quando viene chiamata questa funzione. La risposta alla sottorichiesta verrà elaborata nella funzione call_back.

Provando S3

Per effettuare una richiesta allo storage privato S3, abbiamo bisogno di:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Dal metodo http utilizzato, dalla data/ora corrente, S3_NAME e URI, viene generato un determinato tipo di stringa, che viene firmata (HMAC_SHA1) utilizzando SECRET_KEY. La prossima è una riga come AWS $ACCESS_KEY:$HASH, può essere utilizzato nell'intestazione dell'autorizzazione. È necessario aggiungere all'intestazione la stessa data/ora utilizzata per generare la stringa nel passaggio precedente X-amz-date. Nel codice appare così:

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(Esempio di autorizzazione AWS Sign v2, modificato in stato deprecato)

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}

Una piccola spiegazione in merito _subrequest_uri: si tratta di una variabile che, a seconda dell'uri iniziale, forma una richiesta a S3. Se hai bisogno di ottenere il contenuto della “root”, allora devi creare una richiesta uri indicando il delimitatore delimiter, che restituirà un elenco di tutti gli elementi xml CommonPrefixes, corrispondenti alle directory (nel caso di PyPI, un elenco di tutti i pacchetti). Se è necessario ottenere un elenco dei contenuti di una directory specifica (un elenco di tutte le versioni del pacchetto), la richiesta uri deve contenere un campo prefisso con il nome della directory (pacchetto) che termina necessariamente con una barra /. Altrimenti sono possibili collisioni, ad esempio quando si richiede il contenuto di una directory. Ci sono directory aiohttp-request e aiohttp-requests e se la richiesta specifica /?prefix=aiohttp-request, la risposta conterrà il contenuto di entrambe le directory. Se c'è una barra alla fine, /?prefix=aiohttp-request/, la risposta conterrà solo la directory richiesta. E se richiediamo un file, l'URI risultante non dovrebbe differire da quello originale.

Salva e riavvia Nginx. Nel browser inseriamo l'indirizzo del nostro Nginx, il risultato della richiesta sarà XML, ad esempio:

Elenco delle directory

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

Dall'elenco delle directory avrai bisogno solo degli elementi CommonPrefixes.

Aggiungendo la directory di cui abbiamo bisogno al nostro indirizzo nel browser, riceveremo anche il suo contenuto in formato XML:

Elenco dei file in una directory

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

Dall'elenco dei file prenderemo solo gli elementi Key.

Tutto ciò che resta è analizzare l'XML risultante e inviarlo come HTML, dopo aver prima sostituito l'intestazione Content-Type con 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="/it/${reValue.groups.v}">${a_text}</a>`);
    }
  }

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

Provando PyPI

Controlliamo che nulla si rompa da nessuna parte sui pacchetti che funzionano.

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

Ripetiamo con le nostre librerie.

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

In CI, la creazione e il caricamento di un pacchetto si presenta così:

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

autenticazione

In Gitlab è possibile utilizzare JWT per l'autenticazione/autorizzazione di servizi esterni. Utilizzando la direttiva auth_request in Nginx, reindirizzeremo i dati di autenticazione a una sottorichiesta contenente una chiamata di funzione nello script. Lo script farà un'altra sottorichiesta all'url di Gitlab e se i dati di autenticazione sono stati specificati correttamente, Gitlab restituirà il codice 200 e sarà consentito l'upload/download del pacchetto. Perché non utilizzare una sottoquery e inviare immediatamente i dati a Gitlab? Perché in questo caso dovremo modificare il file di configurazione di Nginx ogni volta che apportiamo modifiche all'autorizzazione, e questo è un compito piuttosto noioso. Inoltre, se Kubernetes utilizza una policy del filesystem root di sola lettura, ciò aggiunge ancora più complessità quando si sostituisce nginx.conf tramite configmap. E diventa assolutamente impossibile configurare Nginx tramite configmap utilizzando contemporaneamente policy che vietano la connessione di volumi (pvc) e filesystem root di sola lettura (succede anche questo).

Utilizzando l'intermedio NJS, abbiamo l'opportunità di modificare i parametri specificati nella configurazione nginx utilizzando variabili di ambiente ed eseguire alcuni controlli nello script (ad esempio, un URL specificato in modo errato).

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}

Molto probabilmente la domanda sorge spontanea: -Perché non utilizzare moduli già pronti? Lì è già stato fatto tutto! Ad esempio, var AWS = require('aws-sdk') e non è necessario scrivere una "bici" con l'autenticazione S3!

Passiamo ai contro

Per me, l'impossibilità di importare moduli JS esterni è diventata una caratteristica spiacevole, ma prevista. Descritto nell'esempio sopra require('crypto') è moduli integrati e richiedono solo lavori per loro. Inoltre, non è possibile riutilizzare il codice dagli script ed è necessario copiarlo e incollarlo in file diversi. Spero che un giorno questa funzionalità venga implementata.

Anche la compressione deve essere disabilitata per il progetto corrente in Nginx gzip off;

Perché non esiste un modulo gzip in NJS ed è impossibile collegarlo; quindi non c'è modo di lavorare con dati compressi. È vero, questo non è proprio un aspetto negativo per questo caso. Non c'è molto testo e i file trasferiti sono già compressi e una compressione aggiuntiva non sarà di grande aiuto. Inoltre, questo non è un servizio così carico o critico da doverti preoccupare di fornire contenuti qualche millisecondo più velocemente.

Il debug dello script richiede molto tempo ed è possibile solo tramite "stampe" in error.log. A seconda del livello di registrazione impostato, info, avviso o errore, è possibile utilizzare rispettivamente 3 metodi r.log, r.warn, r.error. Provo a eseguire il debug di alcuni script in Chrome (v8) o nello strumento console njs, ma non tutto può essere controllato lì. Durante il debug del codice, ovvero il test funzionale, la cronologia assomiglia a questa:

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

e possono esserci centinaia di tali sequenze.

Scrivere codice utilizzando sottoquery e variabili si trasforma in un groviglio intricato. A volte inizi a correre tra diverse finestre IDE cercando di capire la sequenza di azioni del tuo codice. Non è difficile, ma a volte è molto fastidioso.

Non esiste un supporto completo per ES6.

Potrebbero esserci altre carenze, ma non ho riscontrato nient'altro. Condividi le informazioni se hai esperienze negative con NJS.

conclusione

NJS è un interprete open source leggero che ti consente di implementare vari script JavaScript in Nginx. Durante il suo sviluppo è stata prestata grande attenzione alle prestazioni. Naturalmente manca ancora molto, ma il progetto è stato sviluppato da un piccolo team che sta aggiungendo attivamente nuove funzionalità e correggendo bug. Spero che un giorno NJS ti permetta di connettere moduli esterni, il che renderà le funzionalità di Nginx quasi illimitate. Ma c'è NGINX Plus e molto probabilmente non ci saranno funzionalità!

Repository con il codice completo per l'articolo

njs-pypi con supporto AWS Sign v4

Descrizione delle direttive del modulo ngx_http_js_module

Archivio ufficiale NJS и la documentazione

Esempi di utilizzo di NJS di Dmitry Volintsev

njs: script JavaScript nativo in nginx / Discorso di Dmitry Volnyev al Saint HighLoad++ 2019

NJS in produzione / Discorso di Vasily Soshnikov all'HighLoad++ 2019

Firma e autenticazione delle richieste REST in AWS

Fonte: habr.com