Corredor de shell de GitLab. Lance de forma competitiva servicios comprobables con Docker Compose

Corredor de shell de GitLab. Lance de forma competitiva servicios comprobables con Docker Compose

Este artículo será de interés tanto para probadores como para desarrolladores, pero está destinado más a los automatizadores que se enfrentan al problema de configurar GitLab CI/CD para pruebas de integración frente a recursos de infraestructura insuficientes y/o la ausencia de una orquestación de contenedores. plataforma. Te diré cómo configurar el despliegue de entornos de prueba usando docker compose en un solo runner de GitLab shell y para que al desplegar varios entornos, los servicios lanzados no interfieran entre sí.


contenido

Prerrequisitos

  1. En mi práctica, a menudo sucedió que "trataba" las pruebas de integración en proyectos. Y, a menudo, el primer y más importante problema es la canalización de CI, en la que las pruebas de integración desarrollado los servicios están alojados en el entorno dev/stage. Esto causó bastantes problemas:

    • Debido a defectos en un servicio en particular durante la prueba de integración, el bucle de prueba puede corromperse con datos rotos. Hubo casos en los que el envío de una solicitud con un formato JSON roto colgó el servicio, lo que provocó que el soporte quedara completamente inoperativo.
    • La desaceleración del circuito de prueba con el crecimiento de los datos de prueba. Creo que no tiene sentido describir un ejemplo con la limpieza/deshacer la base de datos. En mi práctica, no he conocido un proyecto en el que este procedimiento transcurriera sin problemas.
    • El riesgo de interrumpir el rendimiento del circuito de prueba al probar la configuración general del sistema. Por ejemplo, usuario/grupo/contraseña/política de aplicación.
    • Los datos de prueba de las pruebas automáticas dificultan la vida de los probadores manuales.

    Alguien dirá que las buenas pruebas automáticas deberían limpiar los datos por sí mismas. Tengo argumentos en contra:

    • Los soportes dinámicos son muy cómodos de usar.
    • No todos los objetos se pueden eliminar del sistema a través de la API. Por ejemplo, no se implementa una llamada para eliminar un objeto, ya que contradice la lógica comercial.
    • Al crear un objeto a través de la API, se puede crear una gran cantidad de metadatos que es problemático eliminar.
    • Si las pruebas dependen unas de otras, el proceso de limpiar los datos después de ejecutar las pruebas se convierte en un dolor de cabeza.
    • Llamadas adicionales (y, en mi opinión, no justificadas) a la API.
    • Y el argumento principal: cuando los datos de prueba comienzan a limpiarse directamente de la base de datos. ¡Esto se está convirtiendo en un verdadero circo PK/FK! Escuchamos de los desarrolladores: "Acabo de agregar/eliminar/cambiar el nombre del letrero, ¿por qué fracasaron las pruebas de integración 100500?"

    En mi opinión, la solución más óptima es un entorno dinámico.

  2. Muchas personas usan docker-compose para ejecutar un entorno de prueba, pero pocas usan docker-compose cuando realizan pruebas de integración en CI/CD. Y aquí no tengo en cuenta kubernetes, swarm y otras plataformas de orquestación de contenedores. No todas las empresas los tienen. Sería bueno si docker-compose.yml fuera universal.
  3. Incluso si tenemos nuestro propio corredor de control de calidad, ¿cómo podemos asegurarnos de que los servicios lanzados a través de docker-compose no interfieran entre sí?
  4. ¿Cómo recopilar registros de servicios probados?
  5. ¿Cómo limpiar el corredor?

Tengo mi propio ejecutor de GitLab para mis proyectos y me encontré con estos problemas mientras desarrollaba Cliente Java para carril de prueba. Más específicamente, al ejecutar pruebas de integración. Aquí continuaremos resolviendo estos problemas con ejemplos de este proyecto.

Al contenido

Corredor de shell de GitLab

Para un corredor, recomiendo una máquina virtual Linux con 4 vCPU, 4 GB de RAM, 50 GB de disco duro.
Hay mucha información sobre cómo configurar gitlab-runner en Internet, así que en resumen:

  • Vamos a la máquina vía SSH
  • Si tiene menos de 8 GB de RAM, le recomiendo hacer swap 10 GBpara que no venga el OOM killer y nos mate tareas por falta de memoria RAM. Esto puede suceder cuando se ejecutan más de 5 tareas al mismo tiempo. Las tareas serán más lentas, pero estables.

    Ejemplo con asesino OOM

    Si en los registros de tareas ves bash: line 82: 26474 Killed, luego solo ejecuta en el corredor sudo dmesg | grep 26474

    [26474]  1002 26474  1061935   123806     339        0             0 java
    Out of memory: Kill process 26474 (java) score 127 or sacrifice child
    Killed process 26474 (java) total-vm:4247740kB, anon-rss:495224kB, file-rss:0kB, shmem-rss:0kB

    Y si la imagen se parece a esto, agregue intercambio o agregue RAM.

  • Establecer corredor de gitlab, estibador, docker-componer, fabricar.
  • Agregar un usuario gitlab-runner al grupo docker
    sudo groupadd docker
    sudo usermod -aG docker gitlab-runner
  • Registro corredor de gitlab.
  • Abrir para editar /etc/gitlab-runner/config.toml y añadir

    concurrent=20
    [[runners]]
      request_concurrency = 10

    Esto le permitirá ejecutar tareas paralelas en el mismo corredor. Leer más aquí.
    Si tiene una máquina más poderosa, por ejemplo, 8 vCPU, 16 GB de RAM, entonces estos números se pueden hacer al menos 2 veces más grandes. Pero todo depende de qué se lanzará exactamente en este corredor y en qué cantidad.

Eso es suficiente

Al contenido

Preparando docker-compose.yml

La tarea principal es un archivo docker-compose.yml universal que los desarrolladores/evaluadores pueden usar tanto localmente como en la canalización de CI.

En primer lugar, creamos nombres de servicio únicos para CI. Una de las variables únicas en GitLab CI es la variable CI_JOB_ID. si especificas container_name con valor "service-${CI_JOB_ID:-local}", entonces en el caso:

  • si CI_JOB_ID no definido en las variables de entorno,
    entonces el nombre del servicio será service-local
  • si CI_JOB_ID definido en variables de entorno (por ejemplo, 123),
    entonces el nombre del servicio será service-123

En segundo lugar, creamos una red común para ejecutar servicios. Esto nos brinda aislamiento a nivel de red cuando se ejecutan múltiples entornos de prueba.

networks:
  default:
    external:
      name: service-network-${CI_JOB_ID:-local}

En realidad, este es el primer paso hacia el éxito =)

Un ejemplo de mi docker-compose.yml con comentarios

version: "3"

# Для корректной работы web (php) и fmt нужно, 
# чтобы контейнеры имели общий исполняемый контент.
# В нашем случае, это директория /var/www/testrail
volumes:
  static-content:

# Изолируем окружение на сетевом уровне
networks:
  default:
    external:
      name: testrail-network-${CI_JOB_ID:-local}

services:
  db:
    image: mysql:5.7.22
    # Каждый container_name содержит ${CI_JOB_ID:-local}
    container_name: "testrail-mysql-${CI_JOB_ID:-local}"
    environment:
      MYSQL_HOST: db
      MYSQL_DATABASE: mydb
      MYSQL_ROOT_PASSWORD: 1234
      SKIP_GRANT_TABLES: 1
      SKIP_NETWORKING: 1
      SERVICE_TAGS: dev
      SERVICE_NAME: mysql
    networks:
    - default

  migration:
    image: registry.gitlab.com/touchbit/image/testrail/migration:latest
    container_name: "testrail-migration-${CI_JOB_ID:-local}"
    links:
    - db
    depends_on:
    - db
    networks:
    - default

  fpm:
    image: registry.gitlab.com/touchbit/image/testrail/fpm:latest
    container_name: "testrail-fpm-${CI_JOB_ID:-local}"
    volumes:
    - static-content:/var/www/testrail
    links:
    - db
    networks:
    - default

  web:
    image: registry.gitlab.com/touchbit/image/testrail/web:latest
    container_name: "testrail-web-${CI_JOB_ID:-local}"
    # Если переменные TR_HTTP_PORT или TR_HTTPS_PORTS не определены,
    # то сервис поднимается на 80 и 443 порту соответственно.
    ports:
      - ${TR_HTTP_PORT:-80}:80
      - ${TR_HTTPS_PORT:-443}:443
    volumes:
      - static-content:/var/www/testrail
    links:
      - db
      - fpm
    networks:
      - default

Ejemplo de ejecución local

docker-compose -f docker-compose.yml up -d
Starting   testrail-mysql-local     ... done
Starting   testrail-migration-local ... done
Starting   testrail-fpm-local       ... done
Recreating testrail-web-local       ... done

Pero no todo es tan sencillo con el lanzamiento en CI.

Al contenido

Preparando el Makefile

Utilizo Makefile porque es bastante útil tanto para la gestión del entorno local como para la CI. Más comentarios en línea

# У меня в проектах все вспомогательные вещи лежат в директории `.indirect`,
# в том числе и `docker-compose.yml`

# Использовать bash с опцией pipefail 
# pipefail - фейлит выполнение пайпа, если команда выполнилась с ошибкой
SHELL=/bin/bash -o pipefail

# Останавливаем контейнеры и удаляем сеть
docker-kill:
    docker-compose -f $${CI_JOB_ID:-.indirect}/docker-compose.yml kill
    docker network rm network-$${CI_JOB_ID:-testrail} || true

# Предварительно выполняем docker-kill 
docker-up: docker-kill
    # Создаем сеть для окружения 
    docker network create network-$${CI_JOB_ID:-testrail}
    # Забираем последние образы из docker-registry
    docker-compose -f $${CI_JOB_ID:-.indirect}/docker-compose.yml pull
    # Запускаем окружение
    # force-recreate - принудительное пересоздание контейнеров
    # renew-anon-volumes - не использовать volumes предыдущих контейнеров
    docker-compose -f $${CI_JOB_ID:-.indirect}/docker-compose.yml up --force-recreate --renew-anon-volumes -d
    # Ну и, на всякий случай, вывести что там у нас в принципе запущено на машинке
    docker ps

# Коллектим логи сервисов
docker-logs:
    mkdir ./logs || true
    docker logs testrail-web-$${CI_JOB_ID:-local}       >& logs/testrail-web.log
    docker logs testrail-fpm-$${CI_JOB_ID:-local}       >& logs/testrail-fpm.log
    docker logs testrail-migration-$${CI_JOB_ID:-local} >& logs/testrail-migration.log
    docker logs testrail-mysql-$${CI_JOB_ID:-local}     >& logs/testrail-mysql.log

# Очистка раннера
docker-clean:
    @echo Останавливаем все testrail-контейнеры
    docker kill $$(docker ps --filter=name=testrail -q) || true
    @echo Очистка докер контейнеров
    docker rm -f $$(docker ps -a -f --filter=name=testrail status=exited -q) || true
    @echo Очистка dangling образов
    docker rmi -f $$(docker images -f "dangling=true" -q) || true
    @echo Очистка testrail образов
    docker rmi -f $$(docker images --filter=reference='registry.gitlab.com/touchbit/image/testrail/*' -q) || true
    @echo Очистка всех неиспользуемых volume
    docker volume rm -f $$(docker volume ls -q) || true
    @echo Очистка всех testrail сетей
    docker network rm $(docker network ls --filter=name=testrail -q) || true
    docker ps

Cheque

hacer docker-up

$ make docker-up 
docker-compose -f ${CI_JOB_ID:-.indirect}/docker-compose.yml kill
Killing testrail-web-local   ... done
Killing testrail-fpm-local   ... done
Killing testrail-mysql-local ... done
docker network rm network-${CI_JOB_ID:-testrail} || true
network-testrail
docker network create network-${CI_JOB_ID:-testrail}
d2ec063324081c8bbc1b08fd92242c2ea59d70cf4025fab8efcbc5c6360f083f
docker-compose -f ${CI_JOB_ID:-.indirect}/docker-compose.yml pull
Pulling db        ... done
Pulling migration ... done
Pulling fpm       ... done
Pulling web       ... done
docker-compose -f ${CI_JOB_ID:-.indirect}/docker-compose.yml up --force-recreate --renew-anon-volumes -d
Recreating testrail-mysql-local ... done
Recreating testrail-fpm-local       ... done
Recreating testrail-migration-local ... done
Recreating testrail-web-local       ... done
docker ps
CONTAINER ID  PORTS                                     NAMES
a845d3cb0e5a  0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp  testrail-web-local
19d8ef001398  9000/tcp                                  testrail-fpm-local
e28840a2369c  3306/tcp, 33060/tcp                       testrail-migration-local
0e7900c23f37  3306/tcp                                  testrail-mysql-local

hacer registros docker

$ make docker-logs
mkdir ./logs || true
mkdir: cannot create directory ‘./logs’: File exists
docker logs testrail-web-${CI_JOB_ID:-local}       >& logs/testrail-web.log
docker logs testrail-fpm-${CI_JOB_ID:-local}       >& logs/testrail-fpm.log
docker logs testrail-migration-${CI_JOB_ID:-local} >& logs/testrail-migration.log
docker logs testrail-mysql-${CI_JOB_ID:-local}     >& logs/testrail-mysql.log

Corredor de shell de GitLab. Lance de forma competitiva servicios comprobables con Docker Compose

Al contenido

Preparando .gitlab-ci.yml

Ejecución de pruebas de integración

Integration:
  stage: test
  tags:
    - my-shell-runner
  before_script:
    # Аутентифицируемся в registry
    - docker login -u gitlab-ci-token -p ${CI_JOB_TOKEN} ${CI_REGISTRY}
    # Генерируем псевдоуникальные TR_HTTP_PORT и TR_HTTPS_PORT
    - export TR_HTTP_PORT=$(shuf -i10000-60000 -n1)
    - export TR_HTTPS_PORT=$(shuf -i10000-60000 -n1)
    # создаем директорию с идентификатором задачи
    - mkdir ${CI_JOB_ID}
    # копируем в созданную директорию наш docker-compose.yml
    # чтобы контекст был разный для каждой задачи
    - cp .indirect/docker-compose.yml ${CI_JOB_ID}/docker-compose.yml
  script:
    # поднимаем наше окружение
    - make docker-up
    # запускаем тесты исполняемым jar (у меня так)
    - java -jar itest.jar --http-port ${TR_HTTP_PORT} --https-port ${TR_HTTPS_PORT}
    # или в контейнере
    - docker run --network=testrail-network-${CI_JOB_ID:-local} --rm itest
  after_script:
    # собираем логи
    - make docker-logs
    # останавливаем окружение
    - make docker-kill
  artifacts:
    # сохраняем логи
    when: always
    paths:
      - logs
    expire_in: 30 days

Como resultado de ejecutar dicha tarea, el directorio de registros en artefactos contendrá registros de servicios y pruebas. Lo cual es muy útil en caso de errores. Cada prueba en paralelo escribe su propio registro, pero hablaré de esto por separado.

Corredor de shell de GitLab. Lance de forma competitiva servicios comprobables con Docker Compose

Al contenido

limpieza del corredor

La tarea se ejecutará solo de acuerdo con el cronograma.

stages:
- clean
- build
- test

Clean runner:
  stage: clean
  only:
    - schedules
  tags:
    - my-shell-runner
  script:
    - make docker-clean

A continuación, vaya a nuestro proyecto GitLab -> CI/CD -> Horarios -> Nuevo horario y agregue un nuevo horario

Corredor de shell de GitLab. Lance de forma competitiva servicios comprobables con Docker Compose

Al contenido

resultado

Ejecutar 4 tareas en GitLab CI
Corredor de shell de GitLab. Lance de forma competitiva servicios comprobables con Docker Compose

En los registros de la última tarea con pruebas de integración, vemos contenedores de diferentes tareas

CONTAINER ID  NAMES
c6b76f9135ed  testrail-web-204645172
01d303262d8e  testrail-fpm-204645172
2cdab1edbf6a  testrail-migration-204645172
826aaf7c0a29  testrail-mysql-204645172
6dbb3fae0322  testrail-web-204645084
3540f8d448ce  testrail-fpm-204645084
70fea72aa10d  testrail-mysql-204645084
d8aa24b2892d  testrail-web-204644881
6d4ccd910fad  testrail-fpm-204644881
685d8023a3ec  testrail-mysql-204644881
1cdfc692003a  testrail-web-204644793
6f26dfb2683e  testrail-fpm-204644793
029e16b26201  testrail-mysql-204644793
c10443222ac6  testrail-web-204567103
04339229397e  testrail-fpm-204567103
6ae0accab28d  testrail-mysql-204567103
b66b60d79e43  testrail-web-204553690
033b1f46afa9  testrail-fpm-204553690
a8879c5ef941  testrail-mysql-204553690
069954ba6010  testrail-web-204553539
ed6b17d911a5  testrail-fpm-204553539
1a1eed057ea0  testrail-mysql-204553539

Registro más detallado

$ docker login -u gitlab-ci-token -p ${CI_JOB_TOKEN} ${CI_REGISTRY}
WARNING! Using --password via the CLI is insecure. Use --password-stdin.
WARNING! Your password will be stored unencrypted in /home/gitlab-runner/.docker/config.json.
Configure a credential helper to remove this warning. See
https://docs.docker.com/engine/reference/commandline/login/#credentials-store
Login Succeeded
$ export TR_HTTP_PORT=$(shuf -i10000-60000 -n1)
$ export TR_HTTPS_PORT=$(shuf -i10000-60000 -n1)
$ mkdir ${CI_JOB_ID}
$ cp .indirect/docker-compose.yml ${CI_JOB_ID}/docker-compose.yml
$ make docker-up
docker-compose -f ${CI_JOB_ID:-.indirect}/docker-compose.yml kill
docker network rm testrail-network-${CI_JOB_ID:-local} || true
Error: No such network: testrail-network-204645172
docker network create testrail-network-${CI_JOB_ID:-local}
0a59552b4464b8ab484de6ae5054f3d5752902910bacb0a7b5eca698766d0331
docker-compose -f ${CI_JOB_ID:-.indirect}/docker-compose.yml pull
Pulling web       ... done
Pulling fpm       ... done
Pulling migration ... done
Pulling db        ... done
docker-compose -f ${CI_JOB_ID:-.indirect}/docker-compose.yml up --force-recreate --renew-anon-volumes -d
Creating volume "204645172_static-content" with default driver
Creating testrail-mysql-204645172 ... 
Creating testrail-mysql-204645172 ... done
Creating testrail-migration-204645172 ... done
Creating testrail-fpm-204645172       ... done
Creating testrail-web-204645172       ... done
docker ps
CONTAINER ID        IMAGE                                                          COMMAND                  CREATED              STATUS              PORTS                                           NAMES
c6b76f9135ed        registry.gitlab.com/touchbit/image/testrail/web:latest         "nginx -g 'daemon of…"   13 seconds ago       Up 1 second         0.0.0.0:51148->80/tcp, 0.0.0.0:25426->443/tcp   testrail-web-204645172
01d303262d8e        registry.gitlab.com/touchbit/image/testrail/fpm:latest         "docker-php-entrypoi…"   16 seconds ago       Up 13 seconds       9000/tcp                                        testrail-fpm-204645172
2cdab1edbf6a        registry.gitlab.com/touchbit/image/testrail/migration:latest   "docker-entrypoint.s…"   16 seconds ago       Up 13 seconds       3306/tcp, 33060/tcp                             testrail-migration-204645172
826aaf7c0a29        mysql:5.7.22                                                   "docker-entrypoint.s…"   18 seconds ago       Up 16 seconds       3306/tcp                                        testrail-mysql-204645172
6dbb3fae0322        registry.gitlab.com/touchbit/image/testrail/web:latest         "nginx -g 'daemon of…"   36 seconds ago       Up 22 seconds       0.0.0.0:44202->80/tcp, 0.0.0.0:20151->443/tcp   testrail-web-204645084
3540f8d448ce        registry.gitlab.com/touchbit/image/testrail/fpm:latest         "docker-php-entrypoi…"   38 seconds ago       Up 35 seconds       9000/tcp                                        testrail-fpm-204645084
70fea72aa10d        mysql:5.7.22                                                   "docker-entrypoint.s…"   40 seconds ago       Up 37 seconds       3306/tcp                                        testrail-mysql-204645084
d8aa24b2892d        registry.gitlab.com/touchbit/image/testrail/web:latest         "nginx -g 'daemon of…"   About a minute ago   Up 53 seconds       0.0.0.0:31103->80/tcp, 0.0.0.0:43872->443/tcp   testrail-web-204644881
6d4ccd910fad        registry.gitlab.com/touchbit/image/testrail/fpm:latest         "docker-php-entrypoi…"   About a minute ago   Up About a minute   9000/tcp                                        testrail-fpm-204644881
685d8023a3ec        mysql:5.7.22                                                   "docker-entrypoint.s…"   About a minute ago   Up About a minute   3306/tcp                                        testrail-mysql-204644881
1cdfc692003a        registry.gitlab.com/touchbit/image/testrail/web:latest         "nginx -g 'daemon of…"   About a minute ago   Up About a minute   0.0.0.0:44752->80/tcp, 0.0.0.0:23540->443/tcp   testrail-web-204644793
6f26dfb2683e        registry.gitlab.com/touchbit/image/testrail/fpm:latest         "docker-php-entrypoi…"   About a minute ago   Up About a minute   9000/tcp                                        testrail-fpm-204644793
029e16b26201        mysql:5.7.22                                                   "docker-entrypoint.s…"   About a minute ago   Up About a minute   3306/tcp                                        testrail-mysql-204644793
c10443222ac6        registry.gitlab.com/touchbit/image/testrail/web:latest         "nginx -g 'daemon of…"   5 hours ago          Up 5 hours          0.0.0.0:57123->80/tcp, 0.0.0.0:31657->443/tcp   testrail-web-204567103
04339229397e        registry.gitlab.com/touchbit/image/testrail/fpm:latest         "docker-php-entrypoi…"   5 hours ago          Up 5 hours          9000/tcp                                        testrail-fpm-204567103
6ae0accab28d        mysql:5.7.22                                                   "docker-entrypoint.s…"   5 hours ago          Up 5 hours          3306/tcp                                        testrail-mysql-204567103
b66b60d79e43        registry.gitlab.com/touchbit/image/testrail/web:latest         "nginx -g 'daemon of…"   5 hours ago          Up 5 hours          0.0.0.0:56321->80/tcp, 0.0.0.0:58749->443/tcp   testrail-web-204553690
033b1f46afa9        registry.gitlab.com/touchbit/image/testrail/fpm:latest         "docker-php-entrypoi…"   5 hours ago          Up 5 hours          9000/tcp                                        testrail-fpm-204553690
a8879c5ef941        mysql:5.7.22                                                   "docker-entrypoint.s…"   5 hours ago          Up 5 hours          3306/tcp                                        testrail-mysql-204553690
069954ba6010        registry.gitlab.com/touchbit/image/testrail/web:latest         "nginx -g 'daemon of…"   5 hours ago          Up 5 hours          0.0.0.0:32869->80/tcp, 0.0.0.0:16066->443/tcp   testrail-web-204553539
ed6b17d911a5        registry.gitlab.com/touchbit/image/testrail/fpm:latest         "docker-php-entrypoi…"   5 hours ago          Up 5 hours          9000/tcp                                        testrail-fpm-204553539
1a1eed057ea0        mysql:5.7.22                                                   "docker-entrypoint.s…"   5 hours ago          Up 5 hours          3306/tcp                                        testrail-mysql-204553539

Todas las tareas completadas con éxito

Los artefactos de tareas contienen registros de servicios y pruebas
Corredor de shell de GitLab. Lance de forma competitiva servicios comprobables con Docker Compose

Corredor de shell de GitLab. Lance de forma competitiva servicios comprobables con Docker Compose

Todo parece ser hermoso, pero hay un matiz. La canalización se puede cancelar por la fuerza mientras se ejecutan las pruebas de integración, en cuyo caso no se detendrá la ejecución de contenedores. De vez en cuando es necesario limpiar el corredor. Desafortunadamente, la tarea de revisión en GitLab CE todavía está en el estado Abierto

Pero hemos agregado un inicio de tarea programado, y nadie nos prohíbe iniciarlo manualmente.
Vaya a nuestro proyecto -> CI/CD -> Horarios y ejecute la tarea Clean runner

Corredor de shell de GitLab. Lance de forma competitiva servicios comprobables con Docker Compose

Total:

  • Tenemos un corredor de conchas.
  • No hay conflictos entre las tareas y el entorno.
  • Tenemos un lanzamiento paralelo de tareas con pruebas de integración.
  • Puede ejecutar pruebas de integración tanto localmente como en un contenedor.
  • Los registros de servicio y prueba se recopilan y adjuntan a la tarea de canalización.
  • Es posible limpiar el corredor de imágenes acoplables antiguas.

El tiempo de preparación es de ~2 horas.
Eso, de hecho, es todo. Estaré encantado de comentarios.

Al contenido

Fuente: habr.com

Añadir un comentario