feat: 纳管共享 etcd 与 k3s 外 PostgreSQL 高可用及备份
yaml / yaml (pull_request) Successful in 41s
ansible / collection-test (pull_request) Successful in 2m41s
terraform / validate (pull_request) Successful in 2m41s
ansible / lint (pull_request) Successful in 4m36s

This commit is contained in:
2026-09-25 19:34:48 +00:00
parent acd4b55722
commit 3a2fe5fa0c
92 changed files with 4211 additions and 0 deletions
@@ -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'