Мы продолжаем цикл обучающих статей для начинающих системных администраторов. В этом материале мы будем писать Ansible role для поднятия полноценного готового сервера. Отметим, что если вы являетесь опытным администратором, можете смело пропускать данный материал.

Любое написание ansible-роли сопровождается планом. В этот план необходимо будет включить все, что необходимо будет установить и настроить.

Вот наш план:

  1. Пункт 1. Первоначальная настройка сервера.

  2. Пункт 2. Установка LEMP.

  3. Пункт 3. Права и пользователь.

  4. Пункт 4. Настройка LEMP.

  5. Пункт 5. Перенос кода площадки и БД.

  6. Пункт 6. Тестирование.

  7. Пункт 7. Итог.

Так как если рассмотреть все пункты в одной статье, то материал получается слишком объемным, было принято решение разделить его на две части. В первой части обсудим 1, 2 и 3 пункты плана. В следующей - 4, 5, 6, 7 пункты.

На текущий момент в директории /var/ansible, которую мы создали и использовали в предыдущей статье, имеются файлы:

  1. hosts.txt - файл с ip адресами удаленных машин.

  2. playbook.yml - playbook из первой статьи.

Генерируем роль, роль будет называться LEMP (название можете использовать любое).

ansible-galaxy init LEMP

Открываем playbook.yml. Добавляем:

- name: Install LEMP server
  hosts: all
  become: yes
  roles:
    - LEMP
  1. name - название данного ansible проекта.

  2. hosts - на каких хостах запускать.

  3. become - использование sudo.

  4. roles - подключаем созданную роль.

Сохраняем файл и выходим. Больше данный файл нам не понадобится.

Структура директорий в директории ansible:

.
├── hosts.txt
├── LEMP
│   ├── defaults
│   │   └── main.yml
│   ├── files
│   ├── handlers
│   │   └── main.yml
│   ├── meta
│   │   └── main.yml
│   ├── README.md
│   ├── tasks
│   │   ├── main.yml
│   ├── templates
│   ├── tests
│   │   ├── inventory
│   │   └── test.yml
│   └── vars
│       └── main.yml
└── playbook.yml

Пункт 1. Первоначальная настройка сервера.

В данном пункте необходимо настроить сервер для работы веб приложений. Выполнить установку default приложений, настроить дату, название сервера и локализацию.

Необходимо установить приложения и пакеты:

  • dirmngr mc iotop htop telnet tcpdump nmap curl console-cyrillic hexedit sudo zip unzip patch pwgen vim less parted subversion ntp bzip2 lsof strace mutt s-nail ncdu smartmontools tree dnsutils logrotate rsyslog

Необходимо настроить:

  1. время

  2. hostname

  3. локализацию

Первое, что необходимо сделать, - зайти в директорию с заданиями по пути /var/ansible/LEMP/tasks/.

cd /var/ansible/LEMP/tasks/

Создаем yml-файл для пункта 1. Это необходимо, чтобы в дальнейшем удобнее было администрировать ansible-роль. Потому что все будет разделено по своим файлам и загружаться только тогда, когда нам это необходимо. Для каждого пункта статьи мы будем создавать отдельный файл конфигурации.

Создаем yml файл. Название можно выбрать любое:

touch default_settings.yml

Заходим в главный файл конфигурации в заданиях ./LEMP/tasks/main.yml.

Необходимо подгрузить выше созданный файл конфигурации. Для этого используется модуль include_tasks и название файла:

- include_tasks: default_settings.yml

Сохраняем изменения в main.yml.

Теперь начинаем редактировать default_settings.yml. Для установки нам потребуется использование shell модуль. Для начала необходимо обновить все репозитории. Добавляем: 

---

  - name: update repo.
    shell: apt update
  1. name - название задания.

  2. shell - модуль, с помощью которого выполняется команда.

Сохраняем и запускаем из директории, где лежит playbook.yml (/var/ansible/):

ansible-playbook playbook.yml

PLAY [Install LEMP server] **************************************************************************************************************************************************************************************

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

ok: [ansible2]

TASK [test : update repo.] **************************************************************************************************************************************************************************************

changed: [ansible2]

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

ansible2                   : ok=2    changed=1    unreachable=0    failed=0

Как видим из вывода, ошибки не наблюдается.

Начинаем устанавливать приложения и пакеты.

Также будем использовать модуль shell, так как нужных нам утилит достаточно много и устанавливать все через модуль apt будет достаточно долго и проблематично.

В файл default_settings.yml добавляем:

 - name: install default app.
     shell:
       cmd: "apt install -y dirmngr mc iotop htop telnet tcpdump nmap curl hexedit sudo zip unzip patch pwgen vim less parted subversion ntp bzip2 lsof strace mutt s-nail ncdu smartmontools tree dnsutils logrotate rsyslog"

cmd - ключ, в котором мы указываем команду.

Запускаем:

# ansible-playbook playbook.yml

PLAY [Install LEMP server] **************************************************************************************************************************************************************************************

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

ok: [ansible2]

TASK [test : update repo.] **************************************************************************************************************************************************************************************

changed: [ansible2]

TASK [test : install default app.] ******************************************************************************************************************************************************************************

changed: [ansible2]

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

ansible2                   : ok=3    changed=2    unreachable=0    failed=0

Все установлено.

Далее необходимо настроить время, локализацию и hostname.

Нам понадобятся команды:

Для установки времени:

timedatectl set-timezone Europe/Moscow

Для установки локализации:

locale-gen ru_RU.UTF-8
update-locale LANG=en_US.UTF-8 LC_TIME="ru_RU.UTF-8"

Для установки hostname:

hostnamectl set-hostname DOMAIN_NAME

Так как у нас используются плавающие переменные, мы добавим их в директорию vars. Чтобы в дальнейшем на других серверах нам было удобно их менять.

Плавающие переменные:

  • Europe/Moscow

  • ru_RU.UTF-8

  • en_US.UTF-8

  • DOMAIN_NAME

Открываем файл, где указываем переменные для всего проекта. Файл находится по адресу: /etc/ansible/test/vars/main.yml

Добавляем переменные:

DOMAIN_NAME: domain_name
locale1: ru_RU.UTF-8
locale2: en_US.UTF-8
time_zone: Europe/Moscow

В дальнейшем для вызова переменной необходимо будет использовать скобки {{переменная}}.

Идем в default_settings.yml и добавляем задания:

  - name: time
    shell:
      cmd: "timedatectl set-timezone {{time_zone}}"

  - name: locale settings
    shell:
      cmd: 'locale-gen {{locale1}} && update-locale LANG={{locale2}} LC_TIME="{{locale1}}"'

  - name: hostname
    shell:
      cmd: "hostnamectl set-hostname {{DOMAIN_NAME}}"

Запускаем и проверяем.

ansible-playbook playbook.yml

PLAY [Install LEMP server] **************************************************************************************************************************************************************************************

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

ok: [ansible2]

TASK [test : update repo.] **************************************************************************************************************************************************************************************

changed: [ansible2]

TASK [test : install default app.] ******************************************************************************************************************************************************************************

changed: [ansible2]

TASK [test : time] **********************************************************************************************************************************************************************************************

changed: [ansible2]

TASK [test : locale settings] ***********************************************************************************************************************************************************************************

changed: [ansible2]

TASK [test : hostname] ******************************************************************************************************************************************************************************************

changed: [ansible2]

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

ansible2                   : ok=6    changed=5    unreachable=0    failed=0

Для проверки можно отправить команду с помощью модуля shell.

ansible all -m shell -a "date && cat /etc/hostname"
ansible2 | CHANGED | rc=0 >>
Пн июн 20 14:41:49 MSK 2022
domainname

Все работает. Пункт 1 завершён.

Итоговый файл default_settings.yml:

  - name: update repo.
    shell: apt update

  - name: install default app.
    shell:
      cmd: "apt install -y dirmngr mc iotop htop telnet tcpdump nmap curl hexedit sudo zip unzip patch pwgen vim less parted subversion ntp bzip2 lsof strace mutt s-nail ncdu smartmontools tree dnsutils logrotate rsyslog"

  - name: time
    shell:
      cmd: "timedatectl set-timezone {{time_zone}}"

  - name: locale settings
    shell:
      cmd: 'locale-gen {{locale1}} && update-locale LANG={{locale2}} LC_TIME="{{locale1}}"'

  - name: hostname
    shell:
      cmd: "hostnamectl set-hostname {{DOMAIN_NAME}}"

Пункт 2. Установка LEMP.

В данном пункте необходимо установить:

  • nginx apache2 mysql exim4

Для установки этих пакетов будем использовать модуль apt.

Нам понадобится разделить установку пакетов по разным yml файлам. Так как в дальнейшем будет удобно добавлять и изменять конфигурации для каждого пакета по отдельности.

Создаем 4 файла:

cd /var/ansible/LEMP/tasks
touch mysql_install.yml nginx_install.yml apache2_install.yml exim4_install.yml

Подключаем загрузку задач в main.yml:

 #####install mysql
  - include_tasks: mysql_install.yml
  #####install nginx
  - include_tasks: nginx_install.yml
  #####install apache2
  - include_tasks: apache2_install.yml
  #####install exim4
  - include_tasks: exim4_install.yml

Добавляем в файл nginx_install.yml:

  - name: Install nginx
    apt:
      name: nginx
      state: latest
  1. - name - название задачи.

  2. apt - модуль.

  3. name - название пакета.

  4. state - версия пакета; в данном случае последняя доступная версия.

Добавляем в файл apache2_install.yml:

  - name: Install apache2
    apt:
      name: apache2
      state: latest

Добавляем в файл  exim4_install.yml:

  - name: Install exim4
    apt:
      name: exim4
      state: latest

При установке mysql необходимо будет подключить репозитории. Открываем mysql_install.yml. Используем модуль get_url для загрузки deb. файла с официального сайта.

- name: add mysql repo
  get_url:
    url: https://dev.mysql.com/get/mysql-apt-config_0.8.6-1_all.deb
    dest: "/tmp"
    mode: 0440
  1. get_url - модуль загрузки файла по ссылке, аналог wget.

  2. dest - место куда будет загружен файл.

  3. mode - присваиваем права загруженному файлу.

Следующим действием необходимо установить скачанный репозиторий.

- name: install mysql repo
    apt: "deb=/tmp/mysql-apt-config_0.8.6-1_all.deb"
    become: true

Добавляем ключ репозитория и обновляем репозитории:

  - name: add key mysql and update repo
    shell: "apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 467B942D3A79BD29 && apt update"

Устанавливаем python-mysqldb для дальнейшего взаимодействия.

 - name: install python-mysqldb
    apt:
      name: python-mysqldb
      state: present
      update_cache: yes

Проверяем доступную версию и выводим на экран для проверки:

- name: check latest version of mysql 5.7
  command: bash -c "apt-cache showpkg mysql-server|grep 5.7|head -1|cut -d' ' -f1"
  register: latestmysql57
- debug: msg="{{ latestmysql57.stdout }}"

Устанавливаем:

 - name: install mysql 57
   apt:
     name: mysql-server={{ latestmysql57.stdout }}
     state: present
     update_cache: yes

Запускаем:

ansible-playbook playbook.yml

TASK [LEMP : include_tasks] *************************************************************************************************************************************************************************************included: /var/ansible/LEMP/tasks/mysql_install.yml for ansible2

TASK [LEMP : add mysql repo] ************************************************************************************************************************************************************************************ok: [ansible2]

TASK [LEMP : install mysql repo] ********************************************************************************************************************************************************************************ok: [ansible2]

TASK [LEMP : add key mysql] *************************************************************************************************************************************************************************************changed: [ansible2]

TASK [LEMP : update repo] ***************************************************************************************************************************************************************************************changed: [ansible2]

TASK [LEMP : check latest version of mysql 5.7] *****************************************************************************************************************************************************************changed: [ansible2]

TASK [LEMP : debug] *********************************************************************************************************************************************************************************************ok: [ansible2] => {

    "msg": "5.7.38-1debian10"

}

TASK [LEMP : install mysql 57] **********************************************************************************************************************************************************************************changed: [ansible2]

TASK [LEMP : include_tasks] *************************************************************************************************************************************************************************************included: /var/ansible/LEMP/tasks/nginx_install.yml for ansible2

TASK [LEMP : Install nginx] *************************************************************************************************************************************************************************************ok: [ansible2]

TASK [LEMP : include_tasks] *************************************************************************************************************************************************************************************included: /var/ansible/LEMP/tasks/apache2_install.yml for ansible2

TASK [LEMP : Install apache2] ***********************************************************************************************************************************************************************************ok: [ansible2]

TASK [LEMP : include_tasks] *************************************************************************************************************************************************************************************included: /var/ansible/LEMP/tasks/exim4_install.yml for ansible2

TASK [LEMP : Install exim4] *************************************************************************************************************************************************************************************ok: [ansible2]

Все необходимые нам 4 сервиса установлены.

Для проверки можно отправить запрос на удаленный сервер с помощью shell модуля :

ansible all -m shell -a "systemctl status nginx apache2 mysql exim4"

Пункт 3. Права и пользователь.

Необходимо:

  1. Cоздать пользователя/домашнюю директорию.

  2. Настроить права директории /var/www.

  3. Создать БД и пользователя БД.

Создаем конфигурационный файл для данного пункта:

touch /var/ansible/LEMP/tasks/default_user_settings.yml

Подключаем конфигурацию в main-файле:

 #####default user settings
  - include_tasks: default_user_settings.yml

Создаем структуру директорий по аналогии с нашей первой статьей.

На данном этапе нам необходимо добавить переменные в /var/ansible/LEMP/vars, номер пользователя/группы в системе и пароль.

User_uid: 10000
Group_GID: 10000
user_password: password

Открываем default_user_settings.yml. Для добавление пользователя необходимо будет использовать модуль user и group. Добавляем группу:

  - name: add group
    group:
      name: "{{ DOMAIN_NAME }}"
      state: present
      gid: "{{ Group_GID }}"
  1. state - состояние группы, если группа будет имеется на сервер, то ansible не будет ее пересоздавать или пытаться изменить.

  2. gid - номер группы в системе

  3.  "{{ DOMAIN_NAME }}" - переменная которую мы добавляли в Пункте 1.

Добавляем пользователя:

  - name: add user
    user:
      name: "{{ DOMAIN_NAME }}"
      password: "{{ user_password | password_hash('sha512') }}"
      uid: "{{ User_uid }}"
      group: "{{ DOMAIN_NAME }}"
      state: present
      update_password: on_create
      home: "/var/www/{{ DOMAIN_NAME }}"
      shell: /bin/bash
  1. password: "{{ user_password | password_hash('sha512') }}" - так как ansible не может передавать не зашифрованный пароль, мы его шифруем с помощью password_hash('sha512').

  2. update_password: on_create - означает, что пароль будет добавлен только один раз при первом выполнении данной команды, то есть он не будет перезаписан в случае повторного запуска роли.

  3.   home: "/var/www/{{ DOMAIN_NAME }}" - домашняя директория пользователя.

Таким образом, пользователь и группы созданы. Теперь необходимо создать структуру директорий. Нам поможет модуль file:

 - name: create home directory
    file:
      path: "/var/www/{{ DOMAIN_NAME }}"
      owner: "{{ DOMAIN_NAME }}"
      group: "{{ DOMAIN_NAME }}"
      mode: 0751
      state: directory
  1. path: "/var/www/{{ DOMAIN_NAME }}" - адрес директории

  2. owner: "{{ DOMAIN_NAME }}" - Присваиваем директорию пользователю

  3. group: "{{ DOMAIN_NAME }}" - Присваиваем директорию группе.

  4. mode: 0751 - права директории

  5. state: directory - означает, что создается именно директория а не файл.

Структура директорий:

.└── domain_name

    ├── data

    ├── log

    │   ├── apache2

    │   └── nginx

    ├── sess

    ├── tmp

    └── upload


Следующим шагом необходимо создать базу данных и пользователя базы данных.

Так как настройка mysql ведется в определенном файле, будем использовать именно его (/var/ansible/LEMP/tasks/mysql_install.yml).

Во-первых, необходимо создать root пароль. Добавляем root пароль в переменные /var/ansible/LEMP/vars/main.yml.

mysql_root_password: password

Идем в /var/ansible/LEMP/tasks/mysql_install.yml. Добавляем:

 - name: update mysql root password for all root accounts
   become: true
   mysql_user:
     name: root
     host: "{{ item }}".
     password: "{{ mysql_root_password }}"
     login_user: root
     login_password: ''
     check_implicit_admin: yes
     priv: "*.*:ALL,GRANT"
     state: present
   with_items:
     - 127.0.0.1
     - ::1
     - localhost
  1. Используется модуль mysql_user.

  2.  name: root - имя пользователя которого используем в mysql.

  3.  host: "{{ item }}". - адреса хостов, на которых будет изменен пароль; все хосты указаны в   with_items.

  4.  password: "{{ mysql_root_password }}" - новый пароль.

  5. check_implicit_admin - производит проверку входа в mysql без пароля (если, например, пароль указан в .my.cnf в home директории); Если войти без пароля не удалось, будет использован пароль указанный в login_password.

  6. login_user: root и  login_password: '' пользователь и пароль, под которым мы заходим в mysql.

  7. priv: "*.*:ALL,GRANT" - какие привилегии присваиваем пользователю.

  8. state: present - присваиваем единоразово.

Далее добавляем базу данных. Добавляем переменные название базы данных и пользователя в /var/ansible/LEMP/vars/main.yml:

name_db: domain_name_db
user_db: domain_name_db
password_user_db: password

Идем обратно в /var/ansible/LEMP/tasks/mysql_install.yml. Добавляем базу данных:

  - name: Create a new database with name 'DOMAIN_NAME_DB'
    mysql_db:
      login_user: root
      login_password: "{{ mysql_root_password }}"
      name: "{{name_db}}"
      state: present
  1. Используем модуль mysql_db.

  2. name: "{{name_db}}" - название базы данных.

Добавляем пользователя и присваиваем права на базу данных.

  - name: add user DOMAIN_NAME_USR
    mysql_user:
      login_user: root
      login_password: "{{ mysql_root_password }}"
      host: localhost
      name: "{{user_db}}"
      password: "{{password_user_db}}"
      priv: '{{name_db}}.*:ALL,GRANT'
      state: present

Пользователь базы данных и база данных созданы. Итоговые файлы в рамках данного пункта следующие:


  - name: add group
    group:
      name: "{{ DOMAIN_NAME }}"
      state: present
      gid: "{{ Group_GID }}"

  - name: add user
    user:
      name: "{{ DOMAIN_NAME }}"
      password: "{{ user_password | password_hash('sha512') }}"
      uid: "{{ User_uid }}"
      group: "{{ DOMAIN_NAME }}"
      state: present
      update_password: on_create
      home: "/var/www/{{ DOMAIN_NAME }}"
      shell: /bin/bash

  - name: create home directory
    file:
      path: "/var/www/{{ DOMAIN_NAME }}"
      owner: "{{ DOMAIN_NAME }}"
      group: "{{ DOMAIN_NAME }}"
      mode: 0751
      state: directory

  - name: create other directory
    file:
      path: "/var/www/{{ DOMAIN_NAME }}/data"
      owner: "{{ DOMAIN_NAME }}"
      group: "{{ DOMAIN_NAME }}"
      mode: 0755
      state: directory

  - name: create other directory
    file:
      path: "/var/www/{{ DOMAIN_NAME }}/log"
      owner: "{{ DOMAIN_NAME }}"
      group: "{{ DOMAIN_NAME }}"
      mode: 0755
      state: directory

  - name: create other directory
    file:
      path: "/var/www/{{ DOMAIN_NAME }}/sess"
      owner: "{{ DOMAIN_NAME }}"
      group: "{{ DOMAIN_NAME }}"
      mode: 0755
      state: directory

  - name: create other directory
    file:
      path: "/var/www/{{ DOMAIN_NAME }}/tmp"
      owner: "{{ DOMAIN_NAME }}"
      group: "{{ DOMAIN_NAME }}"
      mode: 0755
      state: directory

  - name: create other directory
    file:
      path: "/var/www/{{ DOMAIN_NAME }}/upload"
      owner: "{{ DOMAIN_NAME }}"
      group: "{{ DOMAIN_NAME }}"
      mode: 0755
      state: directory

  - name: create other directory
    file:
      path: "/var/www/{{ DOMAIN_NAME }}/log/apache2"
      owner: "{{ DOMAIN_NAME }}"
      group: "{{ DOMAIN_NAME }}"
      mode: 0755
      state: directory

  - name: create other directory
    file:
      path: "/var/www/{{ DOMAIN_NAME }}/log/nginx"
      owner: "{{ DOMAIN_NAME }}"
      group: "{{ DOMAIN_NAME }}"
      mode: 0755
      state: directory

Файл mysql_install.yml:

  - name: add mysql repo
    get_url:
      url: https://dev.mysql.com/get/mysql-apt-config_0.8.6-1_all.deb
      dest: "/tmp"
      mode: 0440

  - name: install mysql repo
    apt: "deb=/tmp/mysql-apt-config_0.8.6-1_all.deb"
    become: true

  - name: add key mysql and update repo
    shell: "apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 467B942D3A79BD29 && apt update"

  - name: install python-mysqldb
    apt:
      name: python-mysqldb
      state: present
      update_cache: yes

  - name: check latest version of mysql 5.7
    command: bash -c "apt-cache showpkg mysql-server|grep 5.7|head -1|cut -d' ' -f1"
    register: latestmysql57
  - debug: msg="{{ latestmysql57.stdout }}"

  - name: install mysql 57
    apt:
      name: mysql-server={{ latestmysql57.stdout }}
      state: present
      update_cache: yes

  - name: update mysql root password for all root accounts
    become: true
    mysql_user:
      name: root
      host: "{{ item }}"
      password: "{{ mysql_root_password }}"
      login_user: root
      login_password: 12345
      check_implicit_admin: yes
      priv: "*.*:ALL,GRANT"
      state: present
    with_items:
      - 127.0.0.1
      - ::1
      - localhost

  - name: Create a new database with name 'DOMAIN_NAME_DB'
    mysql_db:
      login_user: root
      login_password: "{{ mysql_root_password }}"
      name: "{{name_db}}"
      state: present

  - name: add user DOMAIN_NAME_USR
    mysql_user:
      login_user: root
      login_password: "{{ mysql_root_password }}"
      host: localhost
      name: "{{user_db}}"
      password: "{{password_user_db}}"
      priv: '{{name_db}}.*:ALL,GRANT'
      state: present

Файл переменных:

DOMAIN_NAME: domain_name
locale1: ru_RU.UTF-8
locale2: en_US.UTF-8
time_zone: Europe/Moscow
User_uid: 10000
Group_GID: 10000
user_password: password
mysql_root_password: password
name_db: domain_name_db
user_db: domain_name_usr
password_user_db: password

Итог

В этой статье мы с вами рассмотрели первую половину практической части серии обучающих статей по ansible. Мы использовали на практике полученные знания из первой статьи. В следующей статье мы рассмотрим остальные пункты, после чего у нас будет готова полноценная ansible-роль для быстрого развертывания простых проектов. Также мы выложим данную ansible-роль на github для ознакомления. 

Если у вас остались вопросы, можете задавать их в комментариях. Ставьте лайки и подписывайтесь на нас - в дальнейшем будем публиковать еще больше обучающих статьей.

Также подписывайтесь на наш telegram-канал DevOps FM.

Рекомендации для чтения:

Зашита от dos/ddos.

Обучение docker.

10 частых ошибок в настройке nginx.

Настройка LEMP сервера с нуля.

Комментарии (5)


  1. nikweter
    22.06.2022 13:25
    +2

    Первая половина статьи - сплошной bashsible. Дальше нормально пошло, но почему сразу так не писать? Зачем столько shell и cmd?


    1. mc2
      23.06.2022 01:20
      +1

      DevOps эт не про думать, это про "х., х. и в прод".


  1. stalker_by
    22.06.2022 13:42
    +1

    А нельзя сразу нормально учить людей Configuration Management?

    В добавок к первому комментарию, почему не объяснить сразу людям про: идемпотентность (которой ваш код не отягощён), Molecule и ansible-lint?!

    Кстати да, ваш "код" ansible-lint видимо ненавидит...не показывайте ему ваш код, три новые статьи писать придется :D


  1. Tamerlan666
    22.06.2022 20:34
    +1

    Это что, прикол какой-то? Как напихать максимальное количество антипаттернов в ансибл-код?


  1. tnt4brain
    22.06.2022 21:51

    Видел стенд Nixys на DevOpsConf. Как-то это не вяжется с техническим уровнем статьи.