feat: 纳管共享 etcd 与 k3s 外 PostgreSQL 高可用及备份
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
---
|
||||
- name: 读取独立管理凭据元数据
|
||||
ansible.builtin.uri:
|
||||
url: "{{ pg_bao_url }}/v1/kv/metadata/infra/postgresql/ayatori/{{ pg_ayatori_instance }}"
|
||||
headers: {X-Vault-Token: "{{ pg_bao_token }}"}
|
||||
status_code: [200, 404]
|
||||
register: pg_admin_metadata
|
||||
no_log: true
|
||||
- name: 只在明确缺失时写入独立的用户名密码
|
||||
ansible.builtin.uri:
|
||||
url: "{{ pg_bao_url }}/v1/kv/data/infra/postgresql/ayatori/{{ pg_ayatori_instance }}"
|
||||
method: POST
|
||||
headers: {X-Vault-Token: "{{ pg_bao_token }}"}
|
||||
body_format: json
|
||||
body:
|
||||
options: {cas: 0}
|
||||
data:
|
||||
username: "{{ pg_loaded_credentials[pg_ayatori_instance].ayatori_username }}"
|
||||
password: "{{ pg_loaded_credentials[pg_ayatori_instance].ayatori_password }}"
|
||||
status_code: 200
|
||||
when: pg_admin_metadata.status == 404
|
||||
changed_when: true
|
||||
no_log: true
|
||||
- name: 回读已有管理凭据
|
||||
ansible.builtin.uri:
|
||||
url: "{{ pg_bao_url }}/v1/kv/data/infra/postgresql/ayatori/{{ pg_ayatori_instance }}"
|
||||
headers: {X-Vault-Token: "{{ pg_bao_token }}"}
|
||||
status_code: 200
|
||||
register: pg_admin_readback
|
||||
no_log: true
|
||||
- name: 不隐式覆盖或轮换不一致凭据
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- pg_admin_readback.json.data.data.keys() | sort == ['password', 'username']
|
||||
- pg_admin_readback.json.data.data.username == pg_loaded_credentials[pg_ayatori_instance].ayatori_username
|
||||
- pg_admin_readback.json.data.data.password == pg_loaded_credentials[pg_ayatori_instance].ayatori_password
|
||||
no_log: true
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
- name: 设置实例初始化参数
|
||||
ansible.builtin.set_fact:
|
||||
pg_profile: "{{ pg_profiles[pg_instance] }}"
|
||||
pg_parent_dir: "{{ pg_data_root }}/{{ pg_instance }}"
|
||||
pg_data_dir: "{{ pg_data_root }}/{{ pg_instance }}/data"
|
||||
pg_config_dir: "{{ pg_config_root }}/{{ pg_instance }}"
|
||||
pg_bootstrap_password: "{{ hostvars['localhost'].pg_loaded_credentials[pg_instance].ayatori_password }}"
|
||||
pg_socket_dir: "/run/homelab-postgresql-{{ pg_instance }}"
|
||||
no_log: true
|
||||
- name: 检查版本标记
|
||||
ansible.builtin.stat:
|
||||
path: "{{ pg_data_dir }}/PG_VERSION"
|
||||
register: pg_version_file
|
||||
- name: 核对未初始化目录为空
|
||||
ansible.builtin.find:
|
||||
paths: "{{ pg_data_dir }}"
|
||||
hidden: true
|
||||
file_type: any
|
||||
register: pg_initial_files
|
||||
when: not pg_version_file.stat.exists
|
||||
- name: 禁止覆盖不完整或未知数据
|
||||
ansible.builtin.assert:
|
||||
that: pg_version_file.stat.exists or pg_initial_files.matched == 0
|
||||
- name: 初始化独立开发实例
|
||||
when: pg_instance == 'dev' and not pg_version_file.stat.exists
|
||||
block:
|
||||
- name: 临时写入开发管理员密码
|
||||
ansible.builtin.copy:
|
||||
content: "{{ hostvars['localhost'].pg_loaded_credentials.dev.superuser_password }}\n"
|
||||
dest: "{{ pg_parent_dir }}/.init-password"
|
||||
owner: "{{ pg_profile.user }}"
|
||||
mode: '0600'
|
||||
no_log: true
|
||||
- name: 仅在空目录初始化
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- "{{ pg_bin_dir }}/initdb"
|
||||
- -D
|
||||
- "{{ pg_data_dir }}"
|
||||
- --username={{ pg_profile.user }}
|
||||
- --pwfile={{ pg_parent_dir }}/.init-password
|
||||
- --auth-local=peer
|
||||
- --auth-host=scram-sha-256
|
||||
- --encoding=UTF8
|
||||
- --locale=C.UTF-8
|
||||
- --data-checksums
|
||||
become: true
|
||||
become_user: "{{ pg_profile.user }}"
|
||||
changed_when: true
|
||||
no_log: true
|
||||
always:
|
||||
- name: 删除临时初始化密码文件
|
||||
ansible.builtin.file:
|
||||
path: "{{ pg_parent_dir }}/.init-password"
|
||||
state: absent
|
||||
- name: 登记专属数据目录(启动失败后仍可辨认来源)
|
||||
ansible.builtin.copy:
|
||||
content: "shared-postgresql/{{ pg_instance }}\n"
|
||||
dest: "{{ pg_parent_dir }}/.homelab-owned"
|
||||
owner: root
|
||||
mode: '0644'
|
||||
- name: 启动配置完成的新实例
|
||||
ansible.builtin.systemd_service:
|
||||
name: "homelab-postgresql-{{ pg_instance }}"
|
||||
daemon_reload: true
|
||||
enabled: true
|
||||
state: started
|
||||
- name: 等待本机 SQL 可连接
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- '{{ pg_bin_dir }}/psql'
|
||||
- -X
|
||||
- -At
|
||||
- -h
|
||||
- '{{ pg_socket_dir }}'
|
||||
- -p
|
||||
- '{{ pg_profile.port | string }}'
|
||||
- -d
|
||||
- postgres
|
||||
- -c
|
||||
- SELECT 1
|
||||
become: true
|
||||
become_user: "{{ pg_profile.user }}"
|
||||
register: pg_ready
|
||||
until: pg_ready.rc == 0
|
||||
retries: 30
|
||||
delay: 2
|
||||
changed_when: false
|
||||
- name: 核对本节点是否可写
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- '{{ pg_bin_dir }}/psql'
|
||||
- -X
|
||||
- -At
|
||||
- -h
|
||||
- '{{ pg_socket_dir }}'
|
||||
- -p
|
||||
- '{{ pg_profile.port | string }}'
|
||||
- -d
|
||||
- postgres
|
||||
- -c
|
||||
- SELECT NOT pg_is_in_recovery()
|
||||
become: true
|
||||
become_user: "{{ pg_profile.user }}"
|
||||
register: pg_is_primary
|
||||
changed_when: false
|
||||
- name: 在可写实例创建 Ayatori 最小管理账号
|
||||
when: pg_is_primary.stdout | trim == 't'
|
||||
block:
|
||||
- name: 查询 Ayatori 账号
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- '{{ pg_bin_dir }}/psql'
|
||||
- -X
|
||||
- -At
|
||||
- -h
|
||||
- '{{ pg_socket_dir }}'
|
||||
- -p
|
||||
- '{{ pg_profile.port | string }}'
|
||||
- -d
|
||||
- postgres
|
||||
- -c
|
||||
- SELECT count(*) FROM pg_roles WHERE rolname = 'ayatori'
|
||||
become: true
|
||||
become_user: "{{ pg_profile.user }}"
|
||||
register: pg_ayatori_exists
|
||||
changed_when: false
|
||||
- name: 首次建立受限角色(不静默轮换已存在密码)
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- '{{ pg_bin_dir }}/psql'
|
||||
- -X
|
||||
- -v
|
||||
- ON_ERROR_STOP=1
|
||||
- -h
|
||||
- '{{ pg_socket_dir }}'
|
||||
- -p
|
||||
- '{{ pg_profile.port | string }}'
|
||||
- -d
|
||||
- postgres
|
||||
stdin: >-
|
||||
CREATE ROLE ayatori LOGIN NOSUPERUSER CREATEDB CREATEROLE NOREPLICATION NOBYPASSRLS
|
||||
PASSWORD '{{ pg_bootstrap_password | replace("'", "''") }}';
|
||||
become: true
|
||||
become_user: "{{ pg_profile.user }}"
|
||||
when: pg_ayatori_exists.stdout | trim == '0'
|
||||
changed_when: true
|
||||
no_log: true
|
||||
- name: 以实际保存密码及 verify-full 验证权限
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- "{{ pg_bin_dir }}/psql"
|
||||
- -X
|
||||
- -At
|
||||
- -c
|
||||
- >-
|
||||
SELECT NOT rolsuper AND rolcreatedb AND rolcreaterole AND NOT pg_is_in_recovery()
|
||||
FROM pg_roles WHERE rolname = current_user
|
||||
environment:
|
||||
PGHOST: "{{ pg_profile.dns }}"
|
||||
PGHOSTADDR: "{{ pg_address }}"
|
||||
PGPORT: "{{ pg_profile.port | string }}"
|
||||
PGDATABASE: postgres
|
||||
PGUSER: ayatori
|
||||
PGPASSWORD: "{{ hostvars['localhost'].pg_loaded_credentials[pg_instance].ayatori_password }}"
|
||||
PGSSLMODE: verify-full
|
||||
PGSSLROOTCERT: "{{ pg_config_dir }}/ca.crt"
|
||||
register: pg_ayatori_check
|
||||
changed_when: false
|
||||
failed_when: pg_ayatori_check.rc != 0 or pg_ayatori_check.stdout | trim != 't'
|
||||
no_log: true
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
- name: 核对秘密元数据(非 404 错误不能视为不存在)
|
||||
ansible.builtin.uri:
|
||||
url: "{{ pg_bao_url }}/v1/kv/metadata/{{ pg_secret_paths[pg_secret_instance] }}"
|
||||
headers:
|
||||
X-Vault-Token: "{{ pg_bao_token }}"
|
||||
status_code: [200, 404]
|
||||
register: pg_secret_metadata
|
||||
no_log: true
|
||||
- name: 明确不存在时创建一次
|
||||
when: pg_secret_metadata.status == 404
|
||||
block:
|
||||
- name: 拒绝为已有实例重造丢失秘密
|
||||
ansible.builtin.assert:
|
||||
that: >-
|
||||
groups['pg_hosts'] | map('extract', hostvars, 'pg_has_data')
|
||||
| select('equalto', true) | list | length == 0
|
||||
- name: 随机生成只在本次执行内存存在的值
|
||||
ansible.builtin.set_fact:
|
||||
pg_new_secret:
|
||||
ayatori_username: ayatori
|
||||
ayatori_password: "{{ lookup('password', '/dev/null', length=48, chars=['ascii_letters', 'digits']) }}"
|
||||
superuser_password: "{{ lookup('password', '/dev/null', length=48, chars=['ascii_letters', 'digits']) }}"
|
||||
replication_password: "{{ lookup('password', '/dev/null', length=48, chars=['ascii_letters', 'digits']) }}"
|
||||
rest_password: "{{ lookup('password', '/dev/null', length=48, chars=['ascii_letters', 'digits']) }}"
|
||||
no_log: true
|
||||
- name: 使用 KV v2 CAS=0 防覆盖写入
|
||||
ansible.builtin.uri:
|
||||
url: "{{ pg_bao_url }}/v1/kv/data/{{ pg_secret_paths[pg_secret_instance] }}"
|
||||
method: POST
|
||||
headers:
|
||||
X-Vault-Token: "{{ pg_bao_token }}"
|
||||
body_format: json
|
||||
body:
|
||||
options: {cas: 0}
|
||||
data: "{{ pg_new_secret }}"
|
||||
status_code: 200
|
||||
changed_when: true
|
||||
no_log: true
|
||||
- name: 确认已有秘密数据可读,软删除必须失败
|
||||
ansible.builtin.uri:
|
||||
url: "{{ pg_bao_url }}/v1/kv/data/{{ pg_secret_paths[pg_secret_instance] }}"
|
||||
headers:
|
||||
X-Vault-Token: "{{ pg_bao_token }}"
|
||||
status_code: 200
|
||||
no_log: true
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
- name: 从受管容器配置定位资源
|
||||
ansible.builtin.command:
|
||||
argv: [pct, config, "{{ pg_lxc_id }}"]
|
||||
register: pg_resync_config
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
- name: 核对容器和专属挂载
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- pg_lxc_id in [150, 151]
|
||||
- "'shared-etcd' in pg_resync_config.stdout"
|
||||
- "('mp=' ~ pg_lxc_mount ~ ',') in pg_resync_config.stdout"
|
||||
- "pg_resync_config.stdout | regex_findall('(?m)^mp0: pve-rg-hdd:(pm-[0-9a-f]+)_') | length == 1"
|
||||
- name: 提取明确匹配的 LINSTOR resource definition
|
||||
ansible.builtin.set_fact:
|
||||
pg_resync_resource: "{{ (pg_resync_config.stdout | regex_findall('(?m)^mp0: pve-rg-hdd:(pm-[0-9a-f]+)_'))[0] }}"
|
||||
- name: 读取本资源已有配置
|
||||
ansible.builtin.command:
|
||||
argv: [linstor, resource-definition, list-properties, "{{ pg_resync_resource }}"]
|
||||
register: pg_resync_properties
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
- name: 每个新卷将自适应同步速率上限设为 10 MiB/s
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- linstor
|
||||
- resource-definition
|
||||
- set-property
|
||||
- "{{ pg_resync_resource }}"
|
||||
- DrbdOptions/PeerDevice/c-max-rate
|
||||
- '10240'
|
||||
when: pg_resync_properties.stdout is not search('c-max-rate\s*│\s*10240\s*│')
|
||||
changed_when: true
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
- name: 检查现有 PGDATA
|
||||
ansible.builtin.stat:
|
||||
path: "{{ pg_data_root }}/{{ pg_instance }}/data/PG_VERSION"
|
||||
register: pg_existing_version
|
||||
- name: 检查本项目所有权标记
|
||||
ansible.builtin.stat:
|
||||
path: "{{ pg_data_root }}/{{ pg_instance }}/.homelab-owned"
|
||||
register: pg_owned_marker
|
||||
- name: 拒绝接管未知现有实例
|
||||
ansible.builtin.assert:
|
||||
that: not pg_existing_version.stat.exists or pg_owned_marker.stat.exists
|
||||
fail_msg: 发现未登记 PGDATA,停止;导入和恢复必须使用独立流程。
|
||||
- name: 核对已登记实例
|
||||
when: pg_owned_marker.stat.exists
|
||||
block:
|
||||
- name: 读取实例标记
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ pg_data_root }}/{{ pg_instance }}/.homelab-owned"
|
||||
register: pg_marker_content
|
||||
- name: 验证实例身份
|
||||
ansible.builtin.assert:
|
||||
that: (pg_marker_content.content | b64decode | trim) == ('shared-postgresql/' ~ pg_instance)
|
||||
- name: 核对已有实例主版本,禁止隐式升级
|
||||
when: pg_existing_version.stat.exists
|
||||
block:
|
||||
- name: 读取 PG_VERSION
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ pg_data_root }}/{{ pg_instance }}/data/PG_VERSION"
|
||||
register: pg_existing_version_content
|
||||
- name: 主版本必须匹配
|
||||
ansible.builtin.assert:
|
||||
that: (pg_existing_version_content.content | b64decode | trim) == (pg_major | string)
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
- name: 设置现有实例路径
|
||||
ansible.builtin.set_fact:
|
||||
pg_profile: "{{ pg_profiles[pg_instance] }}"
|
||||
pg_config_dir: "{{ pg_config_root }}/{{ pg_instance }}"
|
||||
pg_socket_dir: "/run/homelab-postgresql-{{ pg_instance }}"
|
||||
pg_certificates_changed: false
|
||||
- name: 续签前必须存在运行中的受管服务
|
||||
ansible.builtin.command:
|
||||
argv: [systemctl, is-active, "homelab-postgresql-{{ pg_instance }}.service"]
|
||||
changed_when: false
|
||||
- name: 检查服务器证书续签
|
||||
ansible.builtin.include_role:
|
||||
name: pg_instance
|
||||
tasks_from: certificate
|
||||
vars:
|
||||
pg_cert_name: server
|
||||
pg_cert_cn: "{{ pg_profile.dns }}"
|
||||
pg_cert_role: "homelab-pg-{{ pg_instance }}"
|
||||
pg_cert_eku: [serverAuth]
|
||||
- name: 检查生产 DCS 客户端证书续签
|
||||
ansible.builtin.include_role:
|
||||
name: pg_instance
|
||||
tasks_from: certificate
|
||||
vars:
|
||||
pg_cert_name: etcd
|
||||
pg_cert_cn: ''
|
||||
pg_cert_role: homelab-etcd-client
|
||||
pg_cert_eku: [clientAuth]
|
||||
when: pg_instance == 'prod'
|
||||
- name: 重载已续签实例(Patroni 或 PostgreSQL 主进程接收 HUP)
|
||||
ansible.builtin.command:
|
||||
argv: [systemctl, kill, --kill-whom=main, --signal=HUP, "homelab-postgresql-{{ pg_instance }}.service"]
|
||||
when: pg_certificates_changed
|
||||
changed_when: true
|
||||
- name: 检查重载后的本机 SQL 可用
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- "{{ pg_bin_dir }}/psql"
|
||||
- -X
|
||||
- -At
|
||||
- -h
|
||||
- "{{ pg_socket_dir }}"
|
||||
- -p
|
||||
- "{{ pg_profile.port | string }}"
|
||||
- -d
|
||||
- postgres
|
||||
- -c
|
||||
- SELECT 1
|
||||
become: true
|
||||
become_user: "{{ pg_profile.user }}"
|
||||
changed_when: false
|
||||
register: pg_renew_sql
|
||||
retries: 6
|
||||
delay: 2
|
||||
until: pg_renew_sql.rc == 0 and pg_renew_sql.stdout | trim == '1'
|
||||
Reference in New Issue
Block a user