diff --git a/plays/mysql.yml b/plays/mysql.yml new file mode 100644 index 0000000..74d37a2 --- /dev/null +++ b/plays/mysql.yml @@ -0,0 +1,17 @@ +--- +- name: Install & configure mysql server + hosts: all + vars: + custom_firewall_ports: + - 111 + - 2049 + - 3306 + - 6379 + apt_packages: + - nfs-kernel-server + install_redis_exporter: true + install_mysqld_exporter: true + description: 'database & filestorage node for hosting app.retailor.io' + roles: + - role: roles/firewall + - role: roles/mysql diff --git a/roles/mysql/defaults/main.yml b/roles/mysql/defaults/main.yml new file mode 100644 index 0000000..73daeb0 --- /dev/null +++ b/roles/mysql/defaults/main.yml @@ -0,0 +1,36 @@ +--- +# --------------------------------------------------------------------------- +mysql_root_password: "{{ lookup('env', 'MYSQL_ROOT_PASSWORD') | default('', true) }}" +mysql_replication_user: "repl" +mysql_replication_password: "{{ lookup('env', 'MYSQL_REPL_PASSWORD') | default('', true) }}" +mysql_app_user: "{{ lookup('env', 'MYSQL_APP_USER') | default('appuser', true) }}" +mysql_app_password: "{{ lookup('env', 'MYSQL_APP_PASSWORD') | default('', true) }}" +mysql_app_database: "{{ lookup('env', 'MYSQL_APP_DATABASE') | default('appdb', true) }}" + +mysql_version: "{{ lookup('env', 'MYSQL_VERSION') | default('8.0', true) }}" +mysql_server_id: "{{ lookup('env', 'HOSTNAME') | default('mysql', true) }}" + +# --- Sizing ------------------------------------------------------------ +# Set per-environment (override in host_vars if primary/replicas differ in size). +# Rule of thumb: innodb_buffer_pool_size ~= 70-75% of RAM on a dedicated DB host. +mysql_total_ram_mb: "{{ lookup('env', 'MYSQL_TOTAL_RAM_MB') | default(8192, true) | int }}" +mysql_innodb_buffer_pool_size: "{{ (mysql_total_ram_mb | int * 0.70) | round | int }}M" + +mysql_max_connections: 200 + +# --- Networking / security ---------------------------------------------- +mysql_bind_address: "0.0.0.0" # bound to all interfaces; ufw restricts who can reach it +mysql_port: 3306 +mysql_allowed_cidrs: # who is allowed to hit port 3306 via firewall + - "{{ lookup('env', 'MYSQL_ALLOWED_CIDR') | default('10.0.1.0/24', true) }}" + +# --- Replication ---------------------------------------------------------- +mysql_gtid_mode: "ON" +mysql_binlog_format: "ROW" +mysql_binlog_expire_logs_seconds: 604800 # 7 days + +# --- Backups --------------------------------------------------------------- +mysql_backup_enabled: "{{ lookup('env', 'MYSQL_BACKUP_ENABLED') | default('true', true) | bool }}" +mysql_backup_dir: /var/backups/mysql +mysql_backup_retention_days: 7 +mysql_backup_hour: 2 # 2am local, staggered per-host below if needed diff --git a/roles/mysql/handlers/main.yml b/roles/mysql/handlers/main.yml new file mode 100644 index 0000000..6b2f745 --- /dev/null +++ b/roles/mysql/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: restart mysql + systemd: + name: mysql + state: restarted + diff --git a/roles/mysql/tasks/main.yml b/roles/mysql/tasks/main.yml new file mode 100644 index 0000000..858c3f6 --- /dev/null +++ b/roles/mysql/tasks/main.yml @@ -0,0 +1,155 @@ +--- +- name: Install prerequisite packages + apt: + name: + - gnupg2 + - lsb-release + - python3-pymysql # required by Ansible's community.mysql modules + - ufw + state: present + update_cache: true + +- name: Install MySQL server + apt: + name: + - mysql-server-8.0 + - mysql-client-8.0 + state: present + notify: restart mysql + +- name: Ensure MySQL data/log directories exist with correct ownership + file: + path: "{{ item }}" + state: directory + owner: mysql + group: mysql + mode: '0750' + loop: + - /var/log/mysql + - "{{ mysql_backup_dir }}" + +- name: Deploy tuned my.cnf + template: + src: my.cnf.j2 + dest: /etc/mysql/mysql.conf.d/99-production.cnf + owner: root + group: root + mode: '0644' + # validate: 'mysqld --defaults-file=%s --validate-config' + notify: restart mysql + +- name: Start and enable MySQL + systemd: + name: mysql + state: started + enabled: true + +- name: Set MySQL root password (idempotent — only runs if not already set) + community.mysql.mysql_user: + name: root + password: "{{ mysql_root_password }}" + host: localhost + login_unix_socket: /var/run/mysqld/mysqld.sock + state: present + no_log: true + +- name: Create .my.cnf for root so future plays authenticate automatically + template: + src: root_my_cnf.j2 + dest: /root/.my.cnf + owner: root + group: root + mode: '0600' + no_log: true + +# --- mysql_secure_installation equivalent, done idempotently --------------- +- name: Remove anonymous MySQL users + community.mysql.mysql_user: + name: '' + host_all: true + state: absent + login_unix_socket: /var/run/mysqld/mysqld.sock + +- name: Remove test database + community.mysql.mysql_db: + name: test + state: absent + login_unix_socket: /var/run/mysqld/mysqld.sock + +- name: Disallow remote root login + community.mysql.mysql_user: + name: root + host: "{{ item }}" + state: absent + login_unix_socket: /var/run/mysqld/mysqld.sock + loop: + - "{{ ansible_hostname }}" + - "%" + ignore_errors: true # fine if they don't exist + +# --- Firewall: only allow MySQL port from known CIDRs ---------------------- +- name: Allow MySQL port from trusted CIDRs only + community.general.ufw: + rule: allow + port: "{{ mysql_port | string }}" + proto: tcp + from_ip: "{{ item }}" + loop: "{{ mysql_allowed_cidrs }}" + +- name: Reload ufw + community.general.ufw: + state: enabled + +# --- Application DB + user -------------------------------------------------- +- name: Create retailor database + community.mysql.mysql_db: + name: "{{ mysql_app_database }}" + state: present + login_unix_socket: /var/run/mysqld/mysqld.sock + +- name: Create forge user + community.mysql.mysql_user: + name: "{{ mysql_app_user }}" + password: "{{ mysql_app_password }}" + priv: "{{ mysql_app_database }}.*:ALL" + host: '%' + state: present + login_unix_socket: /var/run/mysqld/mysqld.sock + no_log: true + +- name: Create replication user + community.mysql.mysql_user: + name: "{{ mysql_replication_user }}" + password: "{{ mysql_replication_password }}" + priv: "*.*:REPLICATION SLAVE" + host: '%' + state: present + login_unix_socket: /var/run/mysqld/mysqld.sock + no_log: true + +- name: Deploy logrotate config + template: + src: logrotate_mysql.j2 + dest: /etc/logrotate.d/mysql-server + owner: root + group: root + mode: '0644' + +- name: Deploy backup script + template: + src: mysql_backup.sh.j2 + dest: /usr/local/bin/mysql_backup.sh + owner: root + group: root + mode: '0750' + when: mysql_backup_enabled + +- name: Schedule nightly backup cron + cron: + name: "mysql nightly backup" + hour: "{{ mysql_backup_hour }}" + minute: "0" + job: "/usr/local/bin/mysql_backup.sh >> /var/log/mysql/backup.log 2>&1" + user: root + when: mysql_backup_enabled + diff --git a/roles/mysql/templates/logrotate_mysql.j2 b/roles/mysql/templates/logrotate_mysql.j2 new file mode 100644 index 0000000..11ca97a --- /dev/null +++ b/roles/mysql/templates/logrotate_mysql.j2 @@ -0,0 +1,14 @@ +/var/log/mysql/*.log { + daily + rotate 14 + missingok + compress + delaycompress + notifempty + create 640 mysql adm + sharedscripts + postrotate + /usr/bin/mysqladmin flush-logs + endscript +} + diff --git a/roles/mysql/templates/my.cnf.j2 b/roles/mysql/templates/my.cnf.j2 new file mode 100644 index 0000000..f037b5b --- /dev/null +++ b/roles/mysql/templates/my.cnf.j2 @@ -0,0 +1,35 @@ +# Production MySQL config — generated by Ansible for retailor.io. Edit by hand might cause drift! + +[mysqld] +bind-address = {{ mysql_bind_address }} +port = {{ mysql_port }} +max_connections = {{ mysql_max_connections }} +server_id = {{ mysql_server_id }} + +# --- InnoDB ------------------------------------------------------------ +innodb_buffer_pool_size = {{ mysql_innodb_buffer_pool_size }} +innodb_log_file_size = 512M +innodb_flush_log_at_trx_commit = 1 # full ACID durability; set to 2 only if you accept losing ~1s of txns on crash +innodb_flush_method = O_DIRECT +innodb_file_per_table = 1 + +# --- Replication (GTID-based) ------------------------------------------- +gtid_mode = {{ mysql_gtid_mode }} +enforce_gtid_consistency = ON +log_bin = /var/log/mysql/mysql-bin.log +binlog_format = {{ mysql_binlog_format }} +binlog_expire_logs_seconds = {{ mysql_binlog_expire_logs_seconds }} +sync_binlog = 1 +read_only = {{ 'ON' if 'mysql_replica' in group_names else 'OFF' }} +relay_log = /var/log/mysql/mysql-relay-bin + +# --- Logging ------------------------------------------------------------- +slow_query_log = 1 +slow_query_log_file = /var/log/mysql/mysql-slow.log +long_query_time = 2 +log_error = /var/log/mysql/error.log + +# --- Security -------------------------------------------------------------- +local_infile = 0 +skip_name_resolve = 1 + diff --git a/roles/mysql/templates/root_my_cnf.j2 b/roles/mysql/templates/root_my_cnf.j2 new file mode 100644 index 0000000..f76f281 --- /dev/null +++ b/roles/mysql/templates/root_my_cnf.j2 @@ -0,0 +1,4 @@ +[client] +user = root +password = "{{ mysql_root_password }}" +