Fixen o meu propio repositorio PyPI con autorización e S3. En Nginx

Neste artigo gustaríame compartir a miña experiencia con NJS, un intérprete de JavaScript para Nginx desenvolvido por Nginx Inc, describindo as súas principais capacidades usando un exemplo real. NJS é un subconxunto de JavaScript que che permite ampliar a funcionalidade de Nginx. Á pregunta por que o teu propio intérprete??? Dmitry Volyntsev respondeu en detalle. En resumo: NJS é nginx-way e JavaScript é máis progresivo, "nativo" e sen GC, a diferenza de Lua.

Hai moito tempo…

No meu último traballo, herdei gitlab cunha serie de condutos CI/CD abigarrados con docker-compose, dind e outras delicias, que foron transferidos aos carriles kaniko. As imaxes que se usaban anteriormente en CI movéronse na súa forma orixinal. Funcionaron correctamente ata o día en que cambiou a nosa IP de gitlab e CI se converteu nunha cabaza. O problema foi que unha das imaxes docker que participaron en CI tiña git, que extraía módulos de Python a través de ssh. Para ssh necesitas unha clave privada e... estaba na imaxe xunto con known_hosts. E calquera CI fallou cun erro de verificación de clave debido a unha falta de coincidencia entre a IP real e a especificada en known_hosts. Montouse rapidamente unha nova imaxe a partir dos Dockfiles existentes e engadiuse a opción StrictHostKeyChecking no. Pero o mal gusto mantívose e había o desexo de mover as bibliotecas a un repositorio privado de PyPI. Unha bonificación adicional, despois de cambiar a PyPI privado, foi unha canalización máis sinxela e unha descrición normal de requirements.txt

A elección foi feita, señores!

Executamos todo nas nubes e Kubernetes e, ao final, queriamos conseguir un pequeno servizo que fose un contedor sen estado con almacenamento externo. Ben, xa que usamos S3, deuse prioridade a el. E, se é posible, con autenticación en gitlab (pode engadila vostede mesmo se é necesario).

Unha busca rápida deu varios resultados: s3pypi, pypicloud e unha opción con creación “manual” de ficheiros html para nabos. A última opción desapareceu por si mesma.

s3pypi: este é un cli para usar aloxamento S3. Subimos os ficheiros, xeramos o html e subímolo ao mesmo bucket. Adecuado para uso doméstico.

pypicloud: Pareceume un proxecto interesante, pero despois de ler a documentación quedei decepcionado. A pesar da boa documentación e da posibilidade de expandirse para adaptarse ás súas necesidades, en realidade resultou ser redundante e difícil de configurar. Corrixir o código para adaptalo ás súas tarefas, segundo as estimacións daquela época, levaría de 3 a 5 días. O servizo tamén precisa dunha base de datos. Deixámolo por se non atopamos nada máis.

Unha busca máis profunda deu un módulo para Nginx, ngx_aws_auth. O resultado das súas probas foi o XML que se mostraba no navegador, que mostraba o contido do bucket S3. O último compromiso no momento da busca foi hai un ano. O repositorio parecía abandonado.

Indo á fonte e lendo PEP-503 Decateime de que XML pódese converter a HTML sobre a marcha e darlle a pip. Despois de buscar en Google un pouco máis sobre Nginx e S3, atopeime cun exemplo de autenticación en S3 escrito en JS para Nginx. Foi así como coñecín a NJS.

Tomando este exemplo como base, unha hora despois vin no meu navegador o mesmo XML que cando usaba o módulo ngx_aws_auth, pero xa estaba todo escrito en JS.

Gustoume moito a solución nginx. En primeiro lugar, boa documentación e moitos exemplos, en segundo lugar, obtemos todas as vantaxes de Nginx para traballar con ficheiros (fóra da caixa), en terceiro lugar, calquera que saiba como escribir configuracións para Nginx poderá descubrir que é o que é. O minimalismo tamén é unha vantaxe para min, en comparación con Python ou Go (se se escribe desde cero), por non falar do nexo.

TL;DR Despois de 2 días, a versión de proba de PyPi xa se utilizou en CI.

Como funciona isto?

O módulo está cargado en Nginx ngx_http_js_module, incluído na imaxe oficial do docker. Importamos o noso script usando a directiva js_importá configuración de Nginx. A función é chamada por unha directiva js_content. A directiva úsase para establecer variables js_set, que toma como argumento só a función descrita no script. Pero podemos executar subconsultas en NJS só usando Nginx, non calquera XMLHttpRequest. Para iso, a localización correspondente debe engadirse á configuración de Nginx. E o script debe describir unha subsolicitude a esta localización. Para poder acceder a unha función desde a configuración de Nginx, o nome da función debe exportarse no propio script 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}

Cando se solicite no navegador http://localhost:8080/ metemos location /no que a directiva js_content chama unha función request descrito no noso guión script.js. Á súa vez, na función request realízase unha subconsulta location = /sub-query, cun método (no exemplo actual GET) obtido a partir do argumento (r), pasado implícitamente cando se chama esta función. A resposta da subsolicitude procesarase na función call_back.

Probando S3

Para facer unha solicitude de almacenamento privado S3, necesitamos:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

A partir do método http utilizado, a data/hora actual, S3_NAME e URI, xérase un determinado tipo de cadea, que se asina (HMAC_SHA1) mediante SECRET_KEY. O seguinte é unha liña como AWS $ACCESS_KEY:$HASH, pódese usar na cabeceira de autorización. A mesma data/hora que se utilizou para xerar a cadea no paso anterior debe engadirse á cabeceira X-amz-date. No código parece isto:

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(Exemplo de autorización de AWS Sign v2, cambiou ao estado obsoleto)

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}

Unha pequena explicación sobre _subrequest_uri: esta é unha variable que, dependendo da uri inicial, forma unha solicitude a S3. Se precisas obter o contido da "raíz", debes crear unha solicitude uri indicando o delimitador delimiter, que devolverá unha lista de todos os elementos xml de CommonPrefixes, correspondentes aos directorios (no caso de PyPI, unha lista de todos os paquetes). Se precisa obter unha lista de contidos nun directorio específico (unha lista de todas as versións do paquete), entón a solicitude uri debe conter un campo de prefixo co nome do directorio (paquete) que remate necesariamente cunha barra /. En caso contrario, son posibles colisións cando se solicita o contido dun directorio, por exemplo. Hai directorios aiohttp-request e aiohttp-requests e se a solicitude o especifica /?prefix=aiohttp-request, entón a resposta conterá o contido de ambos os directorios. Se hai unha barra ao final, /?prefix=aiohttp-request/, entón a resposta conterá só o directorio necesario. E se solicitamos un ficheiro, o uri resultante non debería diferir do orixinal.

Garda e reinicia Nginx. No navegador introducimos o enderezo do noso Nginx, o resultado da solicitude será XML, por exemplo:

Lista de directorios

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

Da lista de directorios só necesitarás os elementos CommonPrefixes.

Ao engadir o directorio que necesitamos ao noso enderezo no navegador, tamén recibiremos o seu contido en formato XML:

Lista de ficheiros nun directorio

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

Da lista de ficheiros colleremos só elementos Key.

Todo o que queda é analizar o XML resultante e envialo como HTML, substituíndo primeiro a cabeceira Content-Type por 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="/gl/${reValue.groups.v}">${a_text}</a>`);
    }
  }

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

Probando PyPI

Comprobamos que nada se rompe en ningún lugar dos paquetes que se sabe que funcionan.

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

Repetimos coas nosas bibliotecas.

# Создаем для тестов новое окружение
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, crear e cargar un paquete é así:

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

Autenticación

En Gitlab é posible usar JWT para a autenticación/autorización de servizos externos. Usando a directiva auth_request en Nginx, redirixiremos os datos de autenticación a unha subsolicitude que contén unha chamada de función no script. O script fará outra solicitude secundaria ao URL de Gitlab e, se os datos de autenticación se especificaron correctamente, Gitlab devolverá o código 200 e permitirase a carga/descarga do paquete. Por que non usar unha subconsulta e enviar inmediatamente os datos a Gitlab? Porque entón teremos que editar o ficheiro de configuración de Nginx cada vez que fagamos algún cambio na autorización, e esta é unha tarefa bastante tediosa. Ademais, se Kubernetes usa unha política de sistema de ficheiros raíz de só lectura, isto engade aínda máis complexidade ao substituír nginx.conf a través de configmap. E faise absolutamente imposible configurar Nginx mediante configmap mentres se usan simultaneamente políticas que prohiben a conexión de volumes (pvc) e o sistema de ficheiros raíz de só lectura (isto tamén ocorre).

Usando o intermedio NJS, temos a oportunidade de cambiar os parámetros especificados na configuración de nginx usando variables de ambiente e facer algunhas comprobacións no script (por exemplo, un URL especificado incorrectamente).

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}

O máis probable é que a pregunta se acerque: -Por que non usar módulos xa preparados? Xa está todo feito alí! Por exemplo, var AWS = require('aws-sdk') e non é necesario escribir unha "bicicleta" con autenticación S3!

Pasemos aos contras

Para min, a incapacidade de importar módulos JS externos converteuse nunha característica desagradable, pero esperada. Descrito no exemplo anterior require('crypto') is módulos incorporados e requiren só obras para eles. Tampouco hai forma de reutilizar o código dos scripts e tes que copialo e pegalo en diferentes ficheiros. Espero que algún día se implemente esta funcionalidade.

A compresión tamén debe estar desactivada para o proxecto actual en Nginx gzip off;

Porque non hai ningún módulo gzip en NJS e é imposible conectalo; polo tanto, non hai forma de traballar con datos comprimidos. É certo, isto non é realmente un inconveniente para este caso. Non hai moito texto e os ficheiros transferidos xa están comprimidos e a compresión adicional non lles axudará moito. Ademais, este non é un servizo tan cargado ou crítico que teña que preocuparse en entregar contido uns milisegundos máis rápido.

A depuración do script leva moito tempo e só é posible a través de "impresións" en error.log. Dependendo da información do nivel de rexistro establecido, aviso ou erro, é posible utilizar 3 métodos r.log, r.warn, r.error respectivamente. Intento depurar algúns scripts en Chrome (v8) ou na ferramenta da consola njs, pero alí non se pode comprobar todo. Ao depurar o código, tamén coñecido como probas funcionais, o historial ten un aspecto así:

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

e pode haber centos de tales secuencias.

Escribir código usando subconsultas e variables para elas convértese nun enredo. Ás veces comezas a correr por diferentes ventás IDE intentando descubrir a secuencia de accións do teu código. Non é difícil, pero ás veces é moi molesto.

Non hai soporte completo para ES6.

Pode haber outras deficiencias, pero non atopei outra cousa. Comparte información se tes unha experiencia negativa usando NJS.

Conclusión

NJS é un intérprete lixeiro de código aberto que che permite implementar varios scripts JavaScript en Nginx. Durante o seu desenvolvemento, prestouse gran atención ao rendemento. Por suposto, aínda falta moito, pero o proxecto está a ser desenvolvido por un pequeno equipo e están engadindo novas funcións e corrixindo erros activamente. Espero que algún día NJS che permita conectar módulos externos, o que fará que a funcionalidade de Nginx sexa case ilimitada. Pero hai NGINX Plus e o máis probable é que non haxa funcións.

Repositorio con código completo para o artigo

njs-pypi con compatibilidade con AWS Sign v4

Descrición das directivas do módulo ngx_http_js_module

Repositorio oficial de NJS и a documentación

Exemplos de uso de NJS de Dmitry Volintsev

njs - script nativo de JavaScript en nginx / Discurso de Dmitry Volnyev en Saint HighLoad++ 2019

NJS en produción / Discurso de Vasily Soshnikov en HighLoad++ 2019

Asinar e autenticar solicitudes REST en AWS

Fonte: www.habr.com