---
# database role: installs and starts MySQL or PostgreSQL, selected by the
# `db_type` variable ('mysql' | 'postgresql'), and sets the root/postgres
# admin password using the ansible.mysql / community.postgresql collections
# (bundled into the agent CLI's Docker image via `ansible-galaxy collection
# install`, see docker/Dockerfile).
#
# `ansible.mysql.mysql_user` (not `community.mysql.mysql_user`, which as of
# community.mysql 5.x is deprecated in favor of this collection and slated
# for removal in community.mysql 6.0.0) is used for MySQL.
#
# IMPORTANT: do not build the password-setting SQL as a raw string with the
# password Jinja-interpolated directly into it (e.g.
# `mysql -u root -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '{{ db_root_password }}'"`
# via `ansible.builtin.command`). A password containing a single quote would
# break out of the SQL string literal there, letting arbitrary SQL run as
# root/postgres. The mysql_user / postgresql_user modules below instead pass
# the password as a bound module parameter, never as literal SQL text, so no
# password value can escape its parameter position regardless of its
# content.
#
# A task that passes a password to a module MUST be marked `no_log: true` so
# the value never reaches Ansible's own stdout/stderr or the JSON callback
# output consumed by src/server-setup/server-setup-runner.ts — but `no_log`
# also erases the reason the task failed (the result becomes `censored` and
# loses `msg` entirely), so every such task registers its result and a
# separate non-`no_log` task reports the failure. `assert` and `copy` do not
# need `no_log` at all: an unlooped assert echoes no value, and `copy`
# redacts `content` in its own argument spec.
#
# `db_type` is validated as an enum ('mysql' | 'postgresql') by
# `ALLOWED_STEP_PARAMS`/`STEP_PARAM_ENUM_VALUES` in
# src/server-setup/server-setup-runner.ts before this playbook ever runs, so
# the `assert` below should never fire in practice. It exists as a second,
# independent safety net: every task in this file is gated by a plain
# `when: db_type == '...'` string comparison, so a value that doesn't match
# ANY known engine (a typo, wrong case, ...) would otherwise match none of
# them, causing every task to silently skip while `ansible-playbook` still
# exits 0 — i.e. `runServerSetup` would report success despite installing
# nothing.

- name: "database : Validate db_type is supported"
  ansible.builtin.assert:
    that:
      - db_type in ['mysql', 'postgresql']
    fail_msg: >-
      db_type must be one of 'mysql' or 'postgresql': got {{ db_type | to_json }}
  when: db_type is defined

- name: "database : Install MySQL server"
  ansible.builtin.apt:
    name:
      - mysql-server
    state: present
    update_cache: true
  when: (db_type | default('')) == 'mysql'

- name: "database : Enable and start MySQL"
  ansible.builtin.service:
    name: mysql
    state: started
    enabled: true
  when: (db_type | default('')) == 'mysql'

- name: "database : Install PyMySQL (required by ansible.mysql.mysql_user)"
  ansible.builtin.apt:
    name: python3-pymysql
    state: present
    update_cache: true
  when: (db_type | default('')) == 'mysql' and db_root_password is defined

# `check_implicit_admin: true` + the explicit `login_user`/`login_password`
# fallback make this task idempotent across re-runs. On a fresh MySQL
# install, root@localhost authenticates via the `auth_socket` plugin (no
# password) over the unix socket — the "implicit admin" connection
# `check_implicit_admin` tries first. Setting `password` here switches
# root@localhost to password auth, so a *second* run can no longer connect
# as the implicit admin; `check_implicit_admin` then falls back to
# connecting as `login_user`/`login_password` (the same, now-current,
# `db_root_password`), which succeeds and is a no-op since the password
# already matches. Without this, a second run would fail outright once the
# implicit-admin connection stops working. This mirrors the "Handle multiple
# non-idempotent password changed states" example in the upstream
# community.mysql.mysql_user documentation (ansible.mysql.mysql_user shares
# the same module interface).
- name: "database : Set MySQL root password"
  # `no_log` is required (the password is a module parameter), but a `no_log`
  # failure is reported as a bare "<task> failed": Ansible replaces the whole
  # result with `censored` and drops `msg` entirely, so the runner has nothing
  # left to show. Run to completion instead and let the next task report the
  # reason — the same "no_log + separate diagnostic" pattern the codex /
  # gitlab_runner / github_runner / tailscale / k3s tasks already use.
  ansible.mysql.mysql_user:
    name: root
    host: localhost
    password: "{{ db_root_password }}"
    login_unix_socket: /var/run/mysqld/mysqld.sock
    check_implicit_admin: true
    login_user: root
    login_password: "{{ db_root_password }}"
  when: (db_type | default('')) == 'mysql' and db_root_password is defined
  register: db_mysql_root_password_result
  # `ignore_errors`, NOT `failed_when: false`: the latter forces the task's
  # failure verdict to false and that verdict is what lands in the registered
  # result, so `.failed` would read False even when the module failed and the
  # diagnostic below would never fire (verified: a failing module registered
  # with `failed_when: false` reports `failed=False`, with `ignore_errors: true`
  # it reports `failed=True`). The shell-based tasks in the other roles can use
  # `failed_when: false` because they inspect the raw `.rc` instead; a Python
  # module has no rc.
  ignore_errors: true
  no_log: true

- name: "database : Fail if the MySQL root password could not be set (password-safe diagnostic)"
  # Not `no_log`, so this message is actually delivered. It reports the module's
  # own error, which describes the connection/auth problem ("Access denied ...",
  # "unable to connect ...") and never echoes the password back — mysql_user
  # takes the password as a parameter and does not include parameters in its
  # error text.
  ansible.builtin.fail:
    msg: >-
      Setting the MySQL root password failed. Module error:
      {{ db_mysql_root_password_result.msg | default('(no message)') }} —
      check that mysqld is running and reachable on
      /var/run/mysqld/mysqld.sock, that python3-pymysql is installed, and that
      db_root_password matches the password root already has (a re-run with a
      different password cannot authenticate).
  when:
    - (db_type | default('')) == 'mysql' and db_root_password is defined
    - db_mysql_root_password_result.failed | default(false)

- name: "database : Install PostgreSQL server"
  ansible.builtin.apt:
    name:
      - postgresql
    state: present
    update_cache: true
  when: (db_type | default('')) == 'postgresql'

- name: "database : Enable and start PostgreSQL"
  ansible.builtin.service:
    name: postgresql
    state: started
    enabled: true
  when: (db_type | default('')) == 'postgresql'

- name: "database : Install psycopg2 (required by community.postgresql.postgresql_user)"
  ansible.builtin.apt:
    name: python3-psycopg2
    state: present
    update_cache: true
  when: (db_type | default('')) == 'postgresql' and db_root_password is defined

- name: "database : Set PostgreSQL postgres user password"
  # Same reasoning as the MySQL task above: keep `no_log` for the parameter, but
  # do not let it swallow the reason for the failure.
  become_user: postgres
  community.postgresql.postgresql_user:
    name: postgres
    password: "{{ db_root_password }}"
  when: (db_type | default('')) == 'postgresql' and db_root_password is defined
  register: db_postgres_password_result
  # See the MySQL task above for why this is `ignore_errors`, not `failed_when`.
  ignore_errors: true
  no_log: true

- name: "database : Fail if the PostgreSQL password could not be set (password-safe diagnostic)"
  ansible.builtin.fail:
    msg: >-
      Setting the postgres user password failed. Module error:
      {{ db_postgres_password_result.msg | default('(no message)') }} —
      check that the postgresql service is running, that python3-psycopg2 is
      installed, and that the postgres OS user can reach the local socket
      (this task runs as become_user: postgres).
  when:
    - (db_type | default('')) == 'postgresql' and db_root_password is defined
    - db_postgres_password_result.failed | default(false)
