Configuración de un servidor para implementar una aplicación Rails mediante Ansible

No hace mucho necesitaba escribir varios manuales de Ansible para preparar el servidor para implementar una aplicación Rails. Y, sorprendentemente, no encontré un manual sencillo paso a paso. No quería copiar el manual de otra persona sin entender lo que estaba pasando, y al final tuve que leer la documentación, recopilando todo yo mismo. Quizás pueda ayudar a alguien a acelerar este proceso con la ayuda de este artículo.

Lo primero que debe comprender es que ansible le proporciona una interfaz conveniente para realizar una lista predefinida de acciones en un servidor remoto a través de SSH. No hay magia aquí, no puedes instalar un complemento y obtener una implementación sin tiempo de inactividad de tu aplicación con Docker, monitoreo y otras ventajas listas para usar. Para escribir un libro de jugadas, debes saber qué es exactamente lo que quieres hacer y cómo hacerlo. Es por eso que no estoy satisfecho con los manuales de GitHub ya preparados o con artículos como: "Copia y ejecuta, funcionará".

Que necesitamos

Como ya dije, para escribir un libro de jugadas necesitas saber qué quieres hacer y cómo hacerlo. Decidamos qué necesitamos. Para una aplicación Rails necesitaremos varios paquetes del sistema: nginx, postgresql (redis, etc). Además, necesitamos una versión específica de Ruby. Lo mejor es instalarlo a través de rbenv (rvm, asdf...). Ejecutar todo esto como usuario root siempre es una mala idea, por lo que es necesario crear un usuario independiente y configurar sus derechos. Después de esto, debe cargar nuestro código en el servidor, copiar las configuraciones para nginx, postgres, etc. e iniciar todos estos servicios.

Como resultado, la secuencia de acciones es la siguiente:

  1. Iniciar sesión como root
  2. instalar paquetes del sistema
  3. crear un nuevo usuario, configurar derechos, clave ssh
  4. configurar paquetes del sistema (nginx, etc.) y ejecutarlos
  5. Creamos un usuario en la base de datos (puede crear una base de datos inmediatamente)
  6. Iniciar sesión como nuevo usuario
  7. Instalar rbenv y ruby
  8. Instalación del paquete
  9. Subiendo el código de la aplicación
  10. Lanzando el servidor Puma

Además, las últimas etapas se pueden realizar usando capistrano, al menos listo para usar, puede copiar el código en los directorios de versiones, cambiar la versión con un enlace simbólico tras una implementación exitosa, copiar configuraciones de un directorio compartido, reiniciar puma, etc. Todo esto se puede hacer usando Ansible, pero ¿por qué?

Estructura de archivos

Ansible tiene estricto estructura de archivos para todos sus archivos, por lo que es mejor mantenerlos todos en un directorio separado. Además, no es tan importante si estará en la aplicación de los rieles o por separado. Puede almacenar archivos en un repositorio git separado. Personalmente, me resultó más conveniente crear un directorio ansible en el directorio /config de la aplicación Rails y almacenar todo en un repositorio.

Libro de jugadas sencillo

Playbook es un archivo yml que, utilizando una sintaxis especial, describe qué debe hacer Ansible y cómo. Creemos el primer libro de jugadas que no hace nada:

---
- name: Simple playbook
  hosts: all

Aquí simplemente decimos que nuestro libro de jugadas se llama Simple Playbook y que su contenido debe ejecutarse para todos los hosts. Podemos guardarlo en el directorio /ansible con el nombre playbook.yml e intenta ejecutar:

ansible-playbook ./playbook.yml

PLAY [Simple Playbook] ************************************************************************************************************************************
skipping: no hosts matched

Ansible dice que no conoce ningún host que coincida con la lista completa. Deben estar listados en un especial. archivo de inventario.

Creémoslo en el mismo directorio ansible:

123.123.123.123

Así es como simplemente especificamos el host (idealmente el host de nuestro VPS para realizar pruebas, o puede registrar localhost) y lo guardamos con el nombre inventory.
Puedes intentar ejecutar ansible con un archivo de inventario:

ansible-playbook ./playbook.yml -i inventory
PLAY [Simple Playbook] ************************************************************************************************************************************

TASK [Gathering Facts] ************************************************************************************************************************************

PLAY RECAP ************************************************************************************************************************************

Si tiene acceso ssh al host especificado, ansible se conectará y recopilará información sobre el sistema remoto. (TAREA predeterminada [Recopilación de hechos]) después de lo cual dará un breve informe sobre la ejecución (REPRODUCIR RESUMEN).

De forma predeterminada, la conexión utiliza el nombre de usuario con el que inició sesión en el sistema. Lo más probable es que no esté en el host. En el archivo del libro de jugadas, puede especificar qué usuario usar para conectarse usando la directiva usuario_remoto. Además, la información sobre un sistema remoto a menudo puede resultarle innecesaria y no debería perder el tiempo recogiéndola. Esta tarea también se puede desactivar:

---
- name: Simple playbook
  hosts: all
  remote_user: root
  become: true
  gather_facts: no

Intente ejecutar el libro de jugadas nuevamente y asegúrese de que la conexión esté funcionando. (Si especificó el usuario root, también debe especificar la directiva convert: true para obtener derechos elevados. Como está escrito en la documentación: become set to ‘true’/’yes’ to activate privilege escalation. aunque no está del todo claro por qué).

Quizás reciba un error causado por el hecho de que ansible no puede determinar el intérprete de Python, entonces puede especificarlo manualmente:

ansible_python_interpreter: /usr/bin/python3 

Puedes averiguar dónde tienes Python con el comando whereis python.

Instalación de paquetes del sistema

La distribución estándar de Ansible incluye muchos módulos para trabajar con varios paquetes del sistema, por lo que no tenemos que escribir scripts bash por ningún motivo. Ahora necesitamos uno de estos módulos para actualizar el sistema e instalar paquetes del sistema. Tengo Ubuntu Linux en mi VPS, así que para instalar paquetes utilizo apt-get и módulo para ello. Si está utilizando un sistema operativo diferente, es posible que necesite un módulo diferente (recuerde, dije al principio que necesitamos saber de antemano qué y cómo lo haremos). Sin embargo, lo más probable es que la sintaxis sea similar.

Complementemos nuestro manual con las primeras tareas:

---
- name: Simple playbook
  hosts: all
  remote_user: root
  become: true
  gather_facts: no

  tasks:
    - name: Update system
      apt: update_cache=yes
    - name: Install system dependencies
      apt:
        name: git,nginx,redis,postgresql,postgresql-contrib
        state: present

La tarea es exactamente la tarea que Ansible realizará en servidores remotos. Le damos un nombre a la tarea para que podamos rastrear su ejecución en el registro. Y describimos, utilizando la sintaxis de un módulo específico, lo que debe hacer. En este caso apt: update_cache=yes - dice actualizar los paquetes del sistema usando el módulo apt. El segundo comando es un poco más complicado. Pasamos una lista de paquetes al módulo apt y decimos que son state debe convertirse present, es decir, decimos instalar estos paquetes. De manera similar podemos decirles que los eliminen, o que los actualicen simplemente cambiando state. Tenga en cuenta que para que Rails funcione con postgresql necesitamos el paquete postgresql-contrib, que estamos instalando ahora. Nuevamente, necesita saber y hacer esto; ansible por sí solo no lo hará.

Intente ejecutar el libro de estrategias nuevamente y verifique que los paquetes estén instalados.

Creando nuevos usuarios.

Para trabajar con usuarios, Ansible también tiene un módulo: usuario. Agreguemos una tarea más (oculté las partes ya conocidas del libro de jugadas detrás de los comentarios para no copiarlo por completo cada vez):

---
- name: Simple playbook
  # ...
  tasks:
    # ...
    - name: Add a new user
      user:
        name: my_user
        shell: /bin/bash
        password: "{{ 123qweasd | password_hash('sha512') }}"

Creamos un nuevo usuario, le configuramos un esquema y una contraseña. Y luego nos topamos con varios problemas. ¿Qué pasa si los nombres de usuario deben ser diferentes para diferentes hosts? Y almacenar la contraseña en texto claro en el libro de jugadas es una muy mala idea. Para empezar, coloquemos el nombre de usuario y la contraseña en variables y hacia el final del artículo mostraré cómo cifrar la contraseña.

---
- name: Simple playbook
  # ...
  tasks:
    # ...
    - name: Add a new user
      user:
        name: "{{ user }}"
        shell: /bin/bash
        password: "{{ user_password | password_hash('sha512') }}"

Las variables se establecen en libros de jugadas utilizando llaves dobles.

Indicaremos los valores de las variables en el archivo de inventario:

123.123.123.123

[all:vars]
user=my_user
user_password=123qweasd

Tenga en cuenta la directiva. [all:vars] - dice que el siguiente bloque de texto son variables (vars) y son aplicables a todos los hosts (todos).

El diseño también es interesante. "{{ user_password | password_hash('sha512') }}". El caso es que ansible no instala al usuario a través de user_add como lo harías manualmente. Y guarda todos los datos directamente, por lo que también debemos convertir la contraseña en un hash previamente, que es lo que hace este comando.

Agreguemos nuestro usuario al grupo sudo. Sin embargo, antes de esto debemos asegurarnos de que exista dicho grupo porque nadie hará esto por nosotros:

---
- name: Simple playbook
  # ...
  tasks:
    # ...
    - name: Ensure a 'sudo' group
      group:
        name: sudo
        state: present
    - name: Add a new user
      user:
        name: "{{ user }}"
        shell: /bin/bash
        password: "{{ user_password | password_hash('sha512') }}"
        groups: "sudo"

Todo es bastante sencillo, también tenemos un módulo group para crear grupos, con una sintaxis muy similar a apt. Entonces basta con registrar este grupo al usuario (groups: "sudo").
También es útil agregar una clave ssh a este usuario para que podamos iniciar sesión usándolo sin contraseña:

---
- name: Simple playbook
  # ...
  tasks:
    # ...
    - name: Ensure a 'sudo' group
      group:
      name: sudo
        state: present
    - name: Add a new user
      user:
        name: "{{ user }}"
        shell: /bin/bash
        password: "{{ user_password | password_hash('sha512') }}"
        groups: "sudo"
    - name: Deploy SSH Key
      authorized_key:
        user: "{{ user }}"
        key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
        state: present

En este caso el diseño es interesante. "{{ lookup('file', '~/.ssh/id_rsa.pub') }}" — copia el contenido del archivo id_rsa.pub (su nombre puede ser diferente), es decir, la parte pública de la clave ssh y la sube a la lista de claves autorizadas para el usuario en el servidor.

Roles

Las tres tareas para crear uso se pueden clasificar fácilmente en un grupo de tareas, y sería una buena idea almacenar este grupo por separado del manual principal para que no crezca demasiado. Para este propósito, Ansible tiene roles.
De acuerdo con la estructura de archivos indicada al principio, los roles deben colocarse en un directorio de roles separado, para cada rol hay un directorio separado con el mismo nombre, dentro del directorio de tareas, archivos, plantillas, etc.
Creemos una estructura de archivos: ./ansible/roles/user/tasks/main.yml (principal es el archivo principal que se cargará y ejecutará cuando un rol se conecte al libro de jugadas; se pueden conectar otros archivos de roles). Ahora puedes transferir todas las tareas relacionadas con el usuario a este archivo:

# Create user and add him to groups
- name: Ensure a 'sudo' group
  group:
    name: sudo
    state: present

- name: Add a new user
  user:
    name: "{{ user }}"
    shell: /bin/bash
    password: "{{ user_password | password_hash('sha512') }}"
    groups: "sudo"

- name: Deploy SSH Key
  authorized_key:
    user: "{{ user }}"
    key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
    state: present

En el libro de jugadas principal, debe especificar el uso del rol de usuario:

---
- name: Simple playbook
  hosts: all
  remote_user: root
  gather_facts: no

  tasks:
    - name: Update system
      apt: update_cache=yes
    - name: Install system dependencies
      apt:
        name: git,nginx,redis,postgresql,postgresql-contrib
        state: present

  roles:
    - user

Además, puede tener sentido actualizar el sistema antes que todas las demás tareas; para ello, puede cambiar el nombre del bloque tasks en el que se definen en pre_tasks.

Configurando nginx

Ya deberíamos tener Nginx instalado, necesitamos configurarlo y ejecutarlo. Hagámoslo de inmediato en el papel. Creemos una estructura de archivos:

- ansible
  - roles
    - nginx
      - files
      - tasks
        - main.yml
      - templates

Ahora necesitamos archivos y plantillas. La diferencia entre ellos es que ansible copia los archivos directamente, tal como están. Y las plantillas deben tener la extensión j2 y pueden usar valores variables usando las mismas llaves dobles.

Habilitemos nginx en main.yml archivo. Para ello disponemos de un módulo systemd:

# Copy nginx configs and start it
- name: enable service nginx and start
  systemd:
    name: nginx
    state: started
    enabled: yes

Aquí no solo decimos que se debe iniciar nginx (es decir, lo iniciamos), sino que inmediatamente decimos que debe estar habilitado.
Ahora copiemos los archivos de configuración:

# Copy nginx configs and start it
- name: enable service nginx and start
  systemd:
    name: nginx
    state: started
    enabled: yes

- name: Copy the nginx.conf
  copy:
    src: nginx.conf
    dest: /etc/nginx/nginx.conf
    owner: root
    group: root
    mode: '0644'
    backup: yes

- name: Copy template my_app.conf
  template:
    src: my_app_conf.j2
    dest: /etc/nginx/sites-available/my_app.conf
    owner: root
    group: root
    mode: '0644'

Creamos el archivo de configuración principal de nginx (puede tomarlo directamente del servidor o escribirlo usted mismo). Y también el archivo de configuración de nuestra aplicación en el directorio sites_available (esto no es necesario pero sí útil). En el primer caso, usamos el módulo de copia para copiar archivos (el archivo debe estar en /ansible/roles/nginx/files/nginx.conf). En el segundo copiamos la plantilla, sustituyendo los valores de las variables. La plantilla debe estar en /ansible/roles/nginx/templates/my_app.j2). Y podría verse así:

upstream {{ app_name }} {
  server unix:{{ app_path }}/shared/tmp/sockets/puma.sock;
}

server {
  listen 80;
  server_name {{ server_name }} {{ inventory_hostname }};
  root {{ app_path }}/current/public;

  try_files $uri/index.html $uri.html $uri @{{ app_name }};
  ....
}

Presta atención a las inserciones. {{ app_name }}, {{ app_path }}, {{ server_name }}, {{ inventory_hostname }} — estas son todas las variables cuyos valores Ansible sustituirá en la plantilla antes de copiar. Esto es útil si utiliza un libro de estrategias para diferentes grupos de anfitriones. Por ejemplo, podemos agregar nuestro archivo de inventario:

[production]
123.123.123.123

[staging]
231.231.231.231

[all:vars]
user=my_user
user_password=123qweasd

[production:vars]
server_name=production
app_path=/home/www/my_app
app_name=my_app

[staging:vars]
server_name=staging
app_path=/home/www/my_stage
app_name=my_stage_app

Si ahora iniciamos nuestro libro de jugadas, realizará las tareas especificadas para ambos hosts. Pero al mismo tiempo, para un host de prueba, las variables serán diferentes de las de producción, y no solo en roles y libros de jugadas, sino también en configuraciones de nginx. {{ inventory_hostname }} No es necesario especificarlo en el archivo de inventario; esto variable ansible especial y el host para el cual se está ejecutando actualmente el libro de jugadas se almacena allí.
Si desea tener un archivo de inventario para varios hosts, pero ejecutarlo solo para un grupo, puede hacerlo con el siguiente comando:

ansible-playbook -i inventory ./playbook.yml -l "staging"

Otra opción es tener archivos de inventario separados para diferentes grupos. O puede combinar los dos enfoques si tiene muchos hosts diferentes.

Volvamos a configurar nginx. Después de copiar los archivos de configuración, necesitamos crear un enlace simbólico en sitest_enabled a my_app.conf desde sites_available. Y reinicie nginx.

... # old code in mail.yml

- name: Create symlink to sites-enabled
  file:
    src: /etc/nginx/sites-available/my_app.conf
    dest: /etc/nginx/sites-enabled/my_app.conf
    state: link

- name: restart nginx
  service:
    name: nginx
    state: restarted

Aquí todo es simple: nuevamente módulos ansibles con una sintaxis bastante estándar. Pero hay un punto. No tiene sentido reiniciar nginx cada vez. ¿Has notado que no escribimos comandos como: "haz esto así", la sintaxis se parece más a "esto debería tener este estado". Y la mayoría de las veces así es exactamente como funciona ansible. Si el grupo ya existe, o el paquete del sistema ya está instalado, ansible lo comprobará y omitirá la tarea. Además, los archivos no se copiarán si coinciden completamente con lo que ya está en el servidor. Podemos aprovechar esto y reiniciar nginx solo si se han cambiado los archivos de configuración. Para ello existe una directiva de registro:

# Copy nginx configs and start it
- name: enable service nginx and start
  systemd:
    name: nginx
    state: started
    enabled: yes

- name: Copy the nginx.conf
  copy:
    src: nginx.conf
    dest: /etc/nginx/nginx.conf
    owner: root
    group: root
    mode: '0644'
    backup: yes
  register: restart_nginx

- name: Copy template my_app.conf
  template:
    src: my_app_conf.j2
    dest: /etc/nginx/sites-available/my_app.conf
    owner: root
    group: root
    mode: '0644'
  register: restart_nginx

- name: Create symlink to sites-enabled
  file:
    src: /etc/nginx/sites-available/my_app.conf
    dest: /etc/nginx/sites-enabled/my_app.conf
    state: link

- name: restart nginx
  service:
    name: nginx
    state: restarted
  when: restart_nginx.changed

Si uno de los archivos de configuración cambia, se realizará una copia y se registrará la variable. restart_nginx. Y sólo si se ha registrado esta variable se reiniciará el servicio.

Y, por supuesto, debes agregar el rol de nginx al manual principal.

Configurando postgresql

Necesitamos habilitar postgresql usando systemd de la misma manera que lo hicimos con nginx, y también crear un usuario que usaremos para acceder a la base de datos y a la base de datos misma.
Creemos un rol /ansible/roles/postgresql/tasks/main.yml:

# Create user in postgresql
- name: enable postgresql and start
  systemd:
    name: postgresql
    state: started
    enabled: yes

- name: Create database user
  become_user: postgres
  postgresql_user:
    name: "{{ db_user }}"
    password: "{{ db_password }}"
    role_attr_flags: SUPERUSER

- name: Create database
  become_user: postgres
  postgresql_db:
    name: "{{ db_name }}"
    encoding: UTF-8
    owner: "{{ db_user }}"

No describiré cómo agregar variables al inventario, esto ya se ha hecho muchas veces, así como la sintaxis de los módulos postgresql_db y postgresql_user. Puede encontrar más información en la documentación. La directiva más interesante aquí es become_user: postgres. El hecho es que, de forma predeterminada, solo el usuario de Postgres tiene acceso a la base de datos de PostgreSQL y solo de forma local. Esta directiva nos permite ejecutar comandos en nombre de este usuario (si tenemos acceso, claro).
Además, es posible que deba agregar una línea a pg_hba.conf para permitir que un nuevo usuario acceda a la base de datos. Esto se puede hacer de la misma manera que cambiamos la configuración de nginx.

Y, por supuesto, debe agregar la función de postgresql al manual principal.

Instalando Ruby a través de rbenv

Ansible no tiene módulos para trabajar con rbenv, pero se instala clonando un repositorio git. Por tanto, este problema se convierte en el menos estándar. Creemos un papel para ella. /ansible/roles/ruby_rbenv/main.yml y comencemos a llenarlo:

# Install rbenv and ruby
- name: Install rbenv
  become_user: "{{ user }}"
  git: repo=https://github.com/rbenv/rbenv.git dest=~/.rbenv

Nuevamente utilizamos la directiva convert_user para trabajar con el usuario que creamos para estos fines. Dado que rbenv está instalado en su directorio de inicio y no globalmente. Y también usamos el módulo git para clonar el repositorio, especificando repo y dest.

A continuación, debemos registrar rbenv init en bashrc y agregar rbenv a PATH allí. Para esto tenemos el módulo lineinfile:

- name: Add rbenv to PATH
  become_user: "{{ user }}"
  lineinfile:
    path: ~/.bashrc
    state: present
    line: 'export PATH="${HOME}/.rbenv/bin:${PATH}"'

- name: Add rbenv init to bashrc
  become_user: "{{ user }}"
  lineinfile:
    path: ~/.bashrc
    state: present
    line: 'eval "$(rbenv init -)"'

Entonces necesitas instalar ruby_build:

- name: Install ruby-build
  become_user: "{{ user }}"
  git: repo=https://github.com/rbenv/ruby-build.git dest=~/.rbenv/plugins/ruby-build

Y finalmente instale Ruby. Esto se hace mediante rbenv, es decir, simplemente con el comando bash:

- name: Install ruby
  become_user: "{{ user }}"
  shell: |
    export PATH="${HOME}/.rbenv/bin:${PATH}"
    eval "$(rbenv init -)"
    rbenv install {{ ruby_version }}
  args:
    executable: /bin/bash

Decimos qué comando ejecutar y con qué. Sin embargo, aquí nos encontramos con el hecho de que ansible no ejecuta el código contenido en bashrc antes de ejecutar los comandos. Esto significa que rbenv deberá definirse directamente en el mismo script.

El siguiente problema se debe al hecho de que el comando de shell no tiene estado desde un punto de vista ansible. Es decir, no habrá una comprobación automática de si esta versión de Ruby está instalada o no. Podemos hacerlo nosotros mismos:

- name: Install ruby
  become_user: "{{ user }}"
  shell: |
    export PATH="${HOME}/.rbenv/bin:${PATH}"
    eval "$(rbenv init -)"
    if ! rbenv versions | grep -q {{ ruby_version }}
      then rbenv install {{ ruby_version }} && rbenv global {{ ruby_version }}
    fi
  args:
    executable: /bin/bash

Todo lo que queda es instalar el paquete:

- name: Install bundler
  become_user: "{{ user }}"
  shell: |
    export PATH="${HOME}/.rbenv/bin:${PATH}"
    eval "$(rbenv init -)"
    gem install bundler

Y nuevamente, agregue nuestro rol ruby_rbenv al manual principal.

Archivos compartidos.

En general, la configuración podría completarse aquí. A continuación, todo lo que queda es ejecutar capistrano y copiará el código, creará los directorios necesarios e iniciará la aplicación (si todo está configurado correctamente). Sin embargo, capistrano a menudo requiere archivos de configuración adicionales, como database.yml o .env Se pueden copiar como archivos y plantillas para nginx. Sólo hay una sutileza. Antes de copiar archivos, necesita crear una estructura de directorios para ellos, algo como esto:

# Copy shared files for deploy
- name: Ensure shared dir
  become_user: "{{ user }}"
  file:
    path: "{{ app_path }}/shared/config"
    state: directory

especificamos solo un directorio y ansible creará automáticamente directorios principales si es necesario.

Bóveda Ansible

Ya nos hemos encontrado con el hecho de que las variables pueden contener datos secretos como la contraseña del usuario. Si has creado .env expediente de la solicitud, y database.yml entonces debe haber aún más datos críticos de este tipo. Sería bueno ocultarlos de miradas indiscretas. Para este fin se utiliza bóveda ansible.

Creemos un archivo para variables. /ansible/vars/all.yml (aquí puede crear diferentes archivos para diferentes grupos de hosts, como en el archivo de inventario: production.yml, staging.yml, etc.).
Todas las variables que deben cifrarse deben transferirse a este archivo utilizando la sintaxis yml estándar:

# System vars
user_password: 123qweasd
db_password: 123qweasd

# ENV vars
aws_access_key_id: xxxxx
aws_secret_access_key: xxxxxx
aws_bucket: bucket_name
rails_secret_key_base: very_secret_key_base

Después de lo cual este archivo se puede cifrar con el comando:

ansible-vault encrypt ./vars/all.yml

Naturalmente, al cifrar, deberá establecer una contraseña para descifrar. Puede ver lo que habrá dentro del archivo después de llamar a este comando.

Por medio de ansible-vault decrypt el archivo se puede descifrar, modificar y luego cifrar nuevamente.

No es necesario descifrar el archivo para que funcione. Lo guardas cifrado y ejecutas el libro de jugadas con el argumento. --ask-vault-pass. Ansible solicitará la contraseña, recuperará las variables y ejecutará las tareas. Todos los datos permanecerán cifrados.

El comando completo para varios grupos de hosts y bóveda ansible se verá así:

ansible-playbook -i inventory ./playbook.yml -l "staging" --ask-vault-pass

Pero no te daré el texto completo de los manuales y roles, escríbelo tú mismo. Porque ansible es así: si no comprende lo que se debe hacer, no lo hará por usted.

Fuente: habr.com

Añadir un comentario