J'ai créé mon propre référentiel PyPI avec autorisation et S3. Sur Nginx

Dans cet article, je voudrais partager mon expérience avec NJS, un interpréteur JavaScript pour Nginx développé par Nginx Inc, décrivant ses principales fonctionnalités à l'aide d'un exemple réel. NJS est un sous-ensemble de JavaScript qui vous permet d'étendre les fonctionnalités de Nginx. À la question pourquoi ton propre interprète ??? Dmitry Volyntsev a répondu en détail. En bref : NJS est à la manière de Nginx, et JavaScript est plus progressif, « natif » et sans GC, contrairement à Lua.

Il y a longtemps…

Lors de mon dernier emploi, j'ai hérité de gitlab avec un certain nombre de pipelines CI/CD hétéroclites avec docker-compose, dind et autres délices, qui ont été transférés sur des rails kaniko. Les images précédemment utilisées dans CI ont été déplacées dans leur forme originale. Ils ont fonctionné correctement jusqu'au jour où notre IP gitlab a changé et CI s'est transformé en citrouille. Le problème était que l'une des images Docker participant à CI avait git, qui extrayait les modules Python via ssh. Pour ssh, vous avez besoin d'une clé privée et... elle était dans l'image avec known_hosts. Et tout CI a échoué avec une erreur de vérification de clé en raison d'une incompatibilité entre l'adresse IP réelle et celle spécifiée dans known_hosts. Une nouvelle image a été rapidement assemblée à partir des Dockfiles existants et l'option a été ajoutée StrictHostKeyChecking no. Mais le mauvais goût est resté et il y avait une volonté de déplacer les bibliothèques vers un référentiel PyPI privé. Un bonus supplémentaire, après le passage à PyPI privé, était un pipeline plus simple et une description normale du fichier requis.txt

Le choix est fait, Messieurs !

Nous gérons tout dans les nuages ​​et Kubernetes, et au final, nous voulions obtenir un petit service qui était un conteneur sans état avec stockage externe. Eh bien, puisque nous utilisons S3, la priorité lui a été donnée. Et, si possible, avec authentification dans gitlab (vous pouvez l'ajouter vous-même si nécessaire).

Une recherche rapide a donné plusieurs résultats : s3pypi, pypicloud et une option de création « manuelle » de fichiers html pour les navets. La dernière option a disparu d’elle-même.

s3pypi : Il s'agit d'un cli pour utiliser l'hébergement S3. Nous téléchargeons les fichiers, générons le code HTML et le téléchargeons dans le même compartiment. Convient pour un usage domestique.

pypicloud : Cela semblait être un projet intéressant, mais après avoir lu la documentation, j'ai été déçu. Malgré une bonne documentation et la possibilité d'évoluer en fonction de vos besoins, il s'est en réalité avéré redondant et difficile à configurer. Selon les estimations de l'époque, la correction du code en fonction de vos tâches aurait pris 3 à 5 jours. Le service a également besoin d'une base de données. Nous l'avons laissé au cas où nous ne trouverions rien d'autre.

Une recherche plus approfondie a donné un module pour Nginx, ngx_aws_auth. Le résultat de ses tests était XML affiché dans le navigateur, qui montrait le contenu du compartiment S3. Le dernier commit au moment de la recherche remonte à il y a un an. Le dépôt semblait abandonné.

En allant à la source et en lisant PEP-503 J'ai réalisé que XML pouvait être converti en HTML à la volée et donné à pip. Après avoir cherché un peu plus sur Nginx et S3 sur Google, je suis tombé sur un exemple d'authentification en S3 écrit en JS pour Nginx. C'est comme ça que j'ai rencontré NJS.

En prenant cet exemple comme base, une heure plus tard, j'ai vu dans mon navigateur le même XML que lors de l'utilisation du module ngx_aws_auth, mais tout était déjà écrit en JS.

J'ai vraiment aimé la solution nginx. Premièrement, une bonne documentation et de nombreux exemples, deuxièmement, nous obtenons tous les avantages de Nginx pour travailler avec des fichiers (prêts à l'emploi), troisièmement, quiconque sait écrire des configurations pour Nginx sera capable de comprendre de quoi il s'agit. Le minimalisme est aussi un plus pour moi, par rapport à Python ou Go (s'ils sont écrits à partir de zéro), sans parler de Nexus.

TL;DR Après 2 jours, la version test de PyPi était déjà utilisée en CI.

Comment ça marche?

Le module est chargé dans Nginx ngx_http_js_module, inclus dans l'image officielle du docker. Nous importons notre script en utilisant la directive js_importà la configuration Nginx. La fonction est appelée par une directive js_content. La directive est utilisée pour définir des variables js_set, qui prend en argument uniquement la fonction décrite dans le script. Mais nous pouvons exécuter des sous-requêtes dans NJS uniquement en utilisant Nginx, et non n'importe quel XMLHttpRequest. Pour ce faire, l'emplacement correspondant doit être ajouté à la configuration Nginx. Et le script doit décrire une sous-requête à cet emplacement. Pour pouvoir accéder à une fonction depuis la config Nginx, le nom de la fonction doit être exporté dans le script lui-même 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}

Lorsque demandé dans le navigateur http://localhost:8080/ nous entrons dans location /dans lequel la directive js_content appelle une fonction request décrit dans notre script script.js. À son tour, dans la fonction request une sous-requête est effectuée pour location = /sub-query, avec une méthode (dans l'exemple actuel GET) obtenue à partir de l'argument (r), passé implicitement lorsque cette fonction est appelée. La réponse à la sous-requête sera traitée dans la fonction call_back.

Essayer S3

Pour faire une demande sur le stockage privé S3, nous avons besoin de :

ACCESS_KEY

SECRET_KEY

S3_BUCKET

À partir de la méthode http utilisée, de la date/heure actuelle, de S3_NAME et de l'URI, un certain type de chaîne est généré, qui est signé (HMAC_SHA1) à l'aide de SECRET_KEY. Vient ensuite une ligne comme AWS $ACCESS_KEY:$HASH, peut être utilisé dans l’en-tête d’autorisation. La même date/heure que celle utilisée pour générer la chaîne à l'étape précédente doit être ajoutée à l'en-tête. X-amz-date. Dans le code, cela ressemble à ceci :

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(Exemple d'autorisation AWS Sign v2, passé au statut obsolète)

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}

Une petite explication sur _subrequest_uri: c'est une variable qui, en fonction de l'uri initial, forme une requête vers S3. Si vous avez besoin d'obtenir le contenu de la « racine », alors vous devez créer une requête uri indiquant le délimiteur delimiter, qui renverra une liste de tous les éléments XML CommonPrefixes, correspondant aux répertoires (dans le cas de PyPI, une liste de tous les packages). Si vous avez besoin d'obtenir une liste du contenu d'un répertoire spécifique (une liste de toutes les versions du package), alors la demande uri doit contenir un champ de préfixe avec le nom du répertoire (package) se terminant nécessairement par une barre oblique /. Sinon, des collisions sont possibles lors de la requête du contenu d'un répertoire, par exemple. Il existe des répertoires aiohttp-request et aiohttp-requests et si la requête précise /?prefix=aiohttp-request, alors la réponse contiendra le contenu des deux répertoires. S'il y a une barre oblique à la fin, /?prefix=aiohttp-request/, alors la réponse contiendra uniquement le répertoire requis. Et si nous demandons un fichier, l'URI résultant ne doit pas différer de celui d'origine.

Enregistrez et redémarrez Nginx. Dans le navigateur nous entrons l'adresse de notre Nginx, le résultat de la requête sera XML, par exemple :

Liste des répertoires

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

Dans la liste des répertoires vous n'aurez besoin que des éléments CommonPrefixes.

En ajoutant le répertoire dont nous avons besoin à notre adresse dans le navigateur, nous recevrons également son contenu sous forme XML :

Liste des fichiers dans un répertoire

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

De la liste des fichiers nous ne prendrons que les éléments Key.

Il ne reste plus qu'à analyser le XML résultant et à l'envoyer au format HTML, après avoir d'abord remplacé l'en-tête Content-Type par 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="/fr/${reValue.groups.v}">${a_text}</a>`);
    }
  }

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

Essayer PyPI

Nous vérifions que rien ne casse nulle part sur les packages connus pour fonctionner.

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

Nous répétons avec nos bibliothèques.

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

Dans CI, la création et le chargement d'un package ressemblent à ceci :

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

Authentification

Dans Gitlab, il est possible d'utiliser JWT pour l'authentification/autorisation de services externes. En utilisant la directive auth_request dans Nginx, nous redirigerons les données d'authentification vers une sous-requête contenant un appel de fonction dans le script. Le script fera une autre sous-requête à l'URL de Gitlab et si les données d'authentification ont été spécifiées correctement, alors Gitlab renverra le code 200 et le téléchargement/téléchargement du package sera autorisé. Pourquoi ne pas utiliser une sous-requête et envoyer immédiatement les données à Gitlab ? Parce qu'alors nous devrons éditer le fichier de configuration Nginx à chaque fois que nous apportons des modifications à l'autorisation, et c'est une tâche plutôt fastidieuse. De plus, si Kubernetes utilise une politique de système de fichiers racine en lecture seule, cela ajoute encore plus de complexité lors du remplacement de nginx.conf via configmap. Et il devient absolument impossible de configurer Nginx via configmap tout en utilisant simultanément des politiques interdisant la connexion de volumes (pvc) et un système de fichiers racine en lecture seule (cela arrive également).

En utilisant l'intermédiaire NJS, nous avons la possibilité de modifier les paramètres spécifiés dans la configuration nginx à l'aide de variables d'environnement et d'effectuer quelques vérifications dans le script (par exemple, une URL mal spécifiée).

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}

La question se pose très probablement : -Pourquoi ne pas utiliser des modules prêts à l'emploi ? Là, tout a déjà été fait ! Par exemple, var AWS = require('aws-sdk') et il n'est pas nécessaire d'écrire un « bike » avec l'authentification S3 !

Passons aux inconvénients

Pour moi, l'impossibilité d'importer des modules JS externes est devenue une fonctionnalité désagréable mais attendue. Décrit dans l'exemple ci-dessus, require('crypto') est modules intégrés et ne demandent que des travaux pour eux. Il n’existe également aucun moyen de réutiliser le code des scripts et vous devez le copier et le coller dans différents fichiers. J'espère qu'un jour cette fonctionnalité sera implémentée.

La compression doit également être désactivée pour le projet en cours dans Nginx gzip off;

Parce qu'il n'y a pas de module gzip dans NJS et qu'il est impossible de le connecter ; il n'y a donc aucun moyen de travailler avec des données compressées. Certes, ce n'est pas vraiment un inconvénient dans ce cas. Il n'y a pas beaucoup de texte et les fichiers transférés sont déjà compressés et une compression supplémentaire ne les aidera pas beaucoup. De plus, il ne s’agit pas d’un service si chargé ou critique que vous deviez vous soucier de fournir du contenu quelques millisecondes plus rapidement.

Le débogage du script prend beaucoup de temps et n'est possible que via des « impressions » dans error.log. En fonction des informations de niveau de journalisation définies, warn ou error, il est possible d'utiliser respectivement 3 méthodes r.log, r.warn, r.error. J'essaie de déboguer certains scripts dans Chrome (v8) ou dans l'outil de console njs, mais tout ne peut pas y être vérifié. Lors du débogage du code, c'est-à-dire des tests fonctionnels, l'historique ressemble à ceci :

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

et il peut y avoir des centaines de telles séquences.

L'écriture de code à l'aide de sous-requêtes et de variables correspondantes se transforme en un enchevêtrement enchevêtré. Parfois, vous commencez à vous précipiter dans différentes fenêtres de l'EDI pour essayer de comprendre la séquence d'actions de votre code. Ce n'est pas difficile, mais c'est parfois très ennuyeux.

Il n’y a pas de support complet pour ES6.

Il y a peut-être d’autres défauts, mais je n’ai rien rencontré d’autre. Partagez des informations si vous avez une expérience négative avec NJS.

Conclusion

NJS est un interpréteur open source léger qui vous permet d'implémenter divers scripts JavaScript dans Nginx. Lors de son développement, une grande attention a été portée à la performance. Bien sûr, il manque encore beaucoup de choses, mais le projet est développé par une petite équipe qui ajoute activement de nouvelles fonctionnalités et corrige des bugs. J'espère qu'un jour NJS vous permettra de connecter des modules externes, ce qui rendra les fonctionnalités de Nginx presque illimitées. Mais il existe NGINX Plus et il n'y aura probablement aucune fonctionnalité !

Dépôt avec le code complet de l'article

njs-pypi avec prise en charge d'AWS Sign v4

Description des directives du module ngx_http_js_module

Dépôt officiel NJS и documentation

Exemples d'utilisation de NJS de Dmitry Volintsev

njs - scripts JavaScript natifs dans nginx / Discours de Dmitry Volnyev à Saint HighLoad++ 2019

NJS en production / Discours de Vasily Soshnikov à HighLoad++ 2019

Signature et authentification des requêtes REST dans AWS

Source: habr.com