feat: 纳管共享 etcd 与 k3s 外 PostgreSQL 高可用及备份
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
FROM postgres:18-bookworm
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3-venv pgbackrest openssh-client haproxy && rm -rf /var/lib/apt/lists/*
|
||||
RUN python3 -m venv /opt/patroni && /opt/patroni/bin/pip install --no-cache-dir 'patroni[etcd3,psycopg3]==4.1.5'
|
||||
ENTRYPOINT []
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""真实 PG/Patroni/HAProxy/pgBackRest;仅 loopback、临时测试 CA/密码,不访问生产。"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import ssl
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import jinja2
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ETCD = Path(os.environ.get('ETCD_TEST_BIN', '/tmp/etcd-v3.7.2-linux-amd64'))
|
||||
IMAGE = os.environ.get('PG_TEST_IMAGE', 'homelab-pg-test:18-patroni4.1.5')
|
||||
|
||||
|
||||
def run(args, **kw):
|
||||
return subprocess.run([str(x) for x in args], capture_output=True, text=True, check=True, **kw)
|
||||
|
||||
|
||||
def wait_for(check, timeout=90):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
result = check()
|
||||
if result:
|
||||
return result
|
||||
except (subprocess.CalledProcessError, OSError, ValueError):
|
||||
pass
|
||||
time.sleep(1)
|
||||
raise RuntimeError('timed out waiting for fixture state')
|
||||
|
||||
|
||||
def main():
|
||||
os.environ['NO_PROXY'] = '*'
|
||||
processes, handles, containers = [], [], []
|
||||
with tempfile.TemporaryDirectory(prefix='shared-pg-test-') as tmp:
|
||||
w = Path(tmp)
|
||||
pg = w/'pg'; pg.mkdir(); pg.chmod(0o755)
|
||||
prefix = f'shared-pg-test-{os.getpid()}'
|
||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(ROOT/'ansible/roles/pg_instance/templates'), undefined=jinja2.StrictUndefined)
|
||||
env.filters['to_json'] = json.dumps
|
||||
try:
|
||||
run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', w/'ca.key', '-out', w/'ca.crt', '-days', '1', '-subj', '/CN=pg-isolated-ca'])
|
||||
|
||||
def cert(directory, name, cn, eku, sans=''):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
run(['openssl','req','-new','-newkey','rsa:2048','-nodes','-keyout',directory/f'{name}.key','-out',directory/f'{name}.csr','-subj',f'/CN={cn}' if cn else '/'])
|
||||
ext = directory/f'{name}.ext'; ext.write_text(f'extendedKeyUsage={eku}\n'+(f'subjectAltName={sans}\n' if sans else ''))
|
||||
run(['openssl','x509','-req','-in',directory/f'{name}.csr','-CA',w/'ca.crt','-CAkey',w/'ca.key','-CAcreateserial','-out',directory/f'{name}.crt','-days','1','-extfile',ext])
|
||||
if directory != w:
|
||||
shutil.copy(w/'ca.crt',directory/'ca.crt')
|
||||
|
||||
# 独立三成员 DCS,绝不复用生产 prefix 或端点。
|
||||
names = ['e1','e2','e3']
|
||||
hosts = {n:{'etcd_address':f'127.0.1.{i+1}'} for i,n in enumerate(names)}
|
||||
et = jinja2.Environment(loader=jinja2.FileSystemLoader(ROOT.parent/'etcd/ansible/roles/shared_etcd/templates'),undefined=jinja2.StrictUndefined)
|
||||
et.filters['to_json']=json.dumps
|
||||
defaults=yaml.safe_load((ROOT.parent/'etcd/ansible/roles/shared_etcd/defaults/main.yml').read_text())
|
||||
cert(w,'admin','root','clientAuth')
|
||||
for name in names:
|
||||
d=w/name; ip=hosts[name]['etcd_address']
|
||||
cert(d,'server',name,'serverAuth',f'IP:{ip},IP:127.0.0.1')
|
||||
cert(d,'peer','homelab-etcd-peer','serverAuth,clientAuth',f'IP:{ip},IP:127.0.0.1')
|
||||
cert(d,'gateway','','clientAuth')
|
||||
cfg=et.get_template('etcd.yml.j2').render(**dict(defaults,inventory_hostname=name,groups={'etcd':names},hostvars=hosts,etcd_address=ip,etcd_config_dir=str(d),etcd_data_dir=str(d/'data'),etcd_client_port=23379,etcd_peer_port=23380,etcd_metrics_urls=f'http://{ip}:23381'))
|
||||
(d/'config.yml').write_text(cfg); log=(d/'log').open('w');handles.append(log)
|
||||
processes.append(subprocess.Popen([str(ETCD/'etcd'),'--config-file='+str(d/'config.yml')],stdout=log,stderr=log))
|
||||
ctlbase=[ETCD/'etcdctl','--endpoints=https://127.0.1.1:23379','--cacert='+str(w/'ca.crt'),'--cert='+str(w/'admin.crt'),'--key='+str(w/'admin.key'),'--command-timeout=3s']
|
||||
wait_for(lambda: run(ctlbase+['endpoint','health']))
|
||||
run(ctlbase+['user','add','root','--no-password']);run(ctlbase+['user','grant-role','root','root']);run(ctlbase+['auth','enable'])
|
||||
run(ctlbase+['user','add','patroni-pg-prod','--interactive=false'],input='fixture-etcd-password\n')
|
||||
run(ctlbase+['role','add','patroni-pg-prod']);run(ctlbase+['role','grant-permission','patroni-pg-prod','readwrite','/homelab/patroni/pg-prod/','--prefix=true']);run(ctlbase+['user','grant-role','patroni-pg-prod','patroni-pg-prod'])
|
||||
values={}
|
||||
for index,name in enumerate(['p1','p2','dev']):
|
||||
d=pg/name;cfg=d/'config';cfg.mkdir(parents=True)
|
||||
address=f'127.0.2.{index+1}'
|
||||
cert(cfg,'server','pg-fixture.test','serverAuth',f'DNS:pg-fixture.test,IP:{address}')
|
||||
cert(cfg,'etcd','','clientAuth','DNS:etcd-patroni-pg-prod')
|
||||
v=dict(pg_instance='dev' if name=='dev' else 'prod',pg_member=name,pg_address=address,
|
||||
pg_profile=dict(user='postgres',port=25432,api_port=28008,shared_buffers='32MB',max_connections=30),
|
||||
pg_credentials=dict(superuser_password='fixture-superuser-password',replication_password='fixture-replication-password',rest_password='fixture-rest-password'),
|
||||
pg_etcd_credentials=dict(username='patroni-pg-prod',password='fixture-etcd-password'),
|
||||
pg_etcd_endpoints=[f'{h["etcd_address"]}:23379' for h in hosts.values()],
|
||||
pg_config_dir='/node/config',pg_parent_dir='/node',pg_data_dir='/node/data',pg_socket_dir='/node/socket',
|
||||
pg_bin_dir='/usr/lib/postgresql/18/bin',pg_replication_addresses=['127.0.0.0/8'],pg_client_cidrs=['127.0.0.0/8'],
|
||||
pg_repo_remote=False,pg_repo_path='/repo')
|
||||
values[name]=v
|
||||
for template in (['postgresql.conf','pg_hba.conf'] if name=='dev' else ['patroni.yml','pgbackrest.conf']):
|
||||
(cfg/template).write_text(env.get_template(template+'.j2').render(**v))
|
||||
for folder in ['socket','backup-lock','backup-spool']:(d/folder).mkdir()
|
||||
(pg/'repo').mkdir();(pg/'restore').mkdir();(pg/'pitr').mkdir()
|
||||
uid=os.getuid();gid=os.getgid()
|
||||
run(['docker','run','--rm','-v',f'{pg}:/fixture',IMAGE,'chown','-R','999:999','/fixture'])
|
||||
|
||||
def start(name, command):
|
||||
cname=prefix+'-'+name
|
||||
args=['docker','run','-d','--name',cname,'--network','host','--user','999:999','--memory','512m','--cpus','1',
|
||||
'-v',f'{pg/name}:/node','-v',f'{pg/"repo"}:/repo','-v',f'{pg/"restore"}:/restore','-v',f'{pg/"pitr"}:/pitr',IMAGE,*command]
|
||||
run(args);containers.append(cname);return cname
|
||||
|
||||
def sql(name, query, tcp=False, password='fixture-superuser-password', user='postgres'):
|
||||
command=['docker','exec','-i',prefix+'-'+name,'psql','-X','-v','ON_ERROR_STOP=1','-At']
|
||||
if tcp:
|
||||
command+=['host=pg-fixture.test hostaddr='+values[name]['pg_address']+' port=25432 user='+user+' dbname=postgres sslmode=verify-full sslrootcert=/node/config/ca.crt password='+password]
|
||||
else: command+=['-h','/node/socket','-p','25432','-U','postgres','postgres']
|
||||
return run(command,input=query).stdout.strip()
|
||||
|
||||
start('p1',['/opt/patroni/bin/patroni','/node/config/patroni.yml'])
|
||||
wait_for(lambda: sql('p1','SELECT NOT pg_is_in_recovery()')=='t')
|
||||
start('p2',['/opt/patroni/bin/patroni','/node/config/patroni.yml'])
|
||||
wait_for(lambda: sql('p2','SELECT pg_is_in_recovery()')=='t')
|
||||
assert sql('p1','SHOW max_connections')=='30'
|
||||
print('PASS: PG 18 primary and streaming standby through authenticated mTLS etcd3',flush=True)
|
||||
# 开发实例直接使用同套配置模板;与生产不共享数据目录或 DCS。
|
||||
run(['docker','run','--rm','--user','999:999','-v',f'{pg/"dev"}:/node',IMAGE,'/usr/lib/postgresql/18/bin/initdb','-D','/node/data','--username=postgres','--auth-local=peer','--auth-host=scram-sha-256'])
|
||||
start('dev',['/usr/lib/postgresql/18/bin/postgres','-D','/node/data','-c','config_file=/node/config/postgresql.conf'])
|
||||
wait_for(lambda: sql('dev','SELECT 1')=='1')
|
||||
sql('p1',"CREATE TABLE probe(id integer primary key); INSERT INTO probe VALUES (1); CREATE ROLE ayatori LOGIN NOSUPERUSER CREATEDB CREATEROLE PASSWORD 'fixture-ayatori-password';")
|
||||
assert sql('p1',"SELECT NOT rolsuper AND rolcreatedb AND rolcreaterole FROM pg_roles WHERE rolname=current_user",True,'fixture-ayatori-password','ayatori')=='t'
|
||||
sql('p1',"CREATE ROLE tenant_owner NOLOGIN; GRANT tenant_owner TO ayatori WITH SET TRUE; CREATE DATABASE tenant_probe OWNER tenant_owner;",True,'fixture-ayatori-password','ayatori')
|
||||
assert sql('dev',"SELECT to_regclass('public.probe') IS NULL")=='t'
|
||||
wait_for(lambda: sql('p2','SELECT count(*) FROM probe')=='1')
|
||||
# 相同 stanza 在两节点上使用同一隔离测试仓库;生产 SSH 传输另行验收。
|
||||
def backrest(name,*args):return run(['docker','exec',prefix+'-'+name,'pgbackrest','--config=/node/config/pgbackrest.conf','--stanza=prod',*args])
|
||||
backrest('p1','stanza-create');backrest('p1','check');backrest('p1','--type=full','backup')
|
||||
initial_backup=json.loads(backrest('p1','--output=json','info').stdout)[0]['backup'][-1]['label']
|
||||
recovery_target=sql('p1','SELECT clock_timestamp()::text')
|
||||
print('PASS: separate dev, TLS verify-full, non-superuser Ayatori native privileges, initial backup',flush=True)
|
||||
proxycfg=w/'proxy.cfg'
|
||||
# 文件父目录仍由调用者拥有,新增代理配置不触碰 PG 文件。
|
||||
proxycfg.write_text(env.get_template('haproxy.cfg.j2').render(pg_proxy_bind='127.0.2.10:26432',pg_proxy_members=[dict(name=n,address=values[n]['pg_address'],port=25432,api_port=28008) for n in ['p1','p2']]))
|
||||
cname=prefix+'-proxy';run(['docker','run','-d','--name',cname,'--network','host','-v',f'{proxycfg}:/proxy.cfg:ro',IMAGE,'haproxy','-f','/proxy.cfg','-db']);containers.append(cname)
|
||||
def proxy_sql():return run(['docker','exec',prefix+'-dev','psql','-X','-At','host=127.0.2.10 port=26432 user=ayatori dbname=postgres sslmode=verify-ca sslrootcert=/node/config/ca.crt password=fixture-ayatori-password','-c','SELECT pg_is_in_recovery()']).stdout.strip()
|
||||
wait_for(lambda: proxy_sql()=='f')
|
||||
began=time.monotonic();run(['docker','kill',prefix+'-p1'])
|
||||
wait_for(lambda: sql('p2','SELECT NOT pg_is_in_recovery()')=='t')
|
||||
wait_for(lambda: proxy_sql()=='f')
|
||||
rto=time.monotonic()-began
|
||||
sql('p2','INSERT INTO probe VALUES (2)');backrest('p2','check');backrest('p2','--type=full','backup')
|
||||
assert sql('dev','SELECT 1')=='1'
|
||||
print(f'PASS: automatic failover + proxy routing in {rto:.1f}s; dev unaffected; backup after failover',flush=True)
|
||||
run(['docker','start',prefix+'-p1'])
|
||||
wait_for(lambda: sql('p1','SELECT pg_is_in_recovery()')=='t')
|
||||
wait_for(lambda: sql('p1','SELECT count(*) FROM probe')=='2')
|
||||
assert sql('p2','SELECT NOT pg_is_in_recovery()')=='t'
|
||||
print('PASS: old primary rejoins as replica without stealing leadership',flush=True)
|
||||
backrest('p2','--pg1-path=/restore','--type=immediate','restore')
|
||||
# 恢复出的副本不连接 DCS,不向生产/测试 stanza 继续归档。
|
||||
cname=prefix+'-restore';run(['docker','run','-d','--name',cname,'--network','host','--user','999:999','-v',f'{pg/"restore"}:/restore','-v',f'{pg/"p2"}:/node','-v',f'{pg/"repo"}:/repo',IMAGE,'postgres','-D','/restore','-c','listen_addresses=127.0.2.20','-p','27432','-c','unix_socket_directories=/tmp','-c','archive_mode=off','-c','hot_standby=on']);containers.append(cname)
|
||||
def restored():return run(['docker','exec',cname,'psql','-X','-At','-h','/tmp','-p','27432','-U','postgres','postgres','-c','SELECT count(*) FROM probe']).stdout.strip()
|
||||
wait_for(lambda: restored()=='2')
|
||||
print('PASS: isolated restore contains both pre/post-failover rows',flush=True)
|
||||
backrest('p2','--pg1-path=/pitr','--set='+initial_backup,'--type=time',
|
||||
'--target='+recovery_target,'--target-action=promote','restore')
|
||||
cname=prefix+'-pitr'
|
||||
run(['docker','run','-d','--name',cname,'--network','host','--user','999:999',
|
||||
'-v',f'{pg/"pitr"}:/pitr','-v',f'{pg/"p2"}:/node','-v',f'{pg/"repo"}:/repo',IMAGE,
|
||||
'postgres','-D','/pitr','-c','listen_addresses=127.0.2.21','-p','27433',
|
||||
'-c','unix_socket_directories=/tmp','-c','archive_mode=off']);containers.append(cname)
|
||||
def pitr_rows():
|
||||
return run(['docker','exec',cname,'psql','-X','-At','-h','/tmp','-p','27433',
|
||||
'-U','postgres','postgres','-c','SELECT count(*) FROM probe']).stdout.strip()
|
||||
wait_for(lambda: pitr_rows()=='1')
|
||||
print('PASS: WAL point-in-time restore recovers pre-failover state from initial backup',flush=True)
|
||||
for process in processes[1:]:
|
||||
process.terminate(); process.wait(timeout=10)
|
||||
def write_fenced():
|
||||
try:
|
||||
sql('p2','INSERT INTO probe VALUES (3) ON CONFLICT DO NOTHING')
|
||||
return False
|
||||
except subprocess.CalledProcessError:
|
||||
return True
|
||||
wait_for(write_fenced)
|
||||
print('PASS: DCS majority loss eventually prevents primary writes (failsafe disabled)',flush=True)
|
||||
except Exception:
|
||||
for container in containers:
|
||||
result=subprocess.run(['docker','logs','--tail','15',container],capture_output=True,text=True)
|
||||
print(container,result.stdout,result.stderr)
|
||||
raise
|
||||
finally:
|
||||
for container in reversed(containers):subprocess.run(['docker','rm','-f',container],capture_output=True)
|
||||
for p in processes:
|
||||
p.terminate()
|
||||
try:p.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:p.kill();p.wait()
|
||||
for h in handles:h.close()
|
||||
subprocess.run(['docker','run','--rm','-v',f'{pg}:/fixture',IMAGE,'chown','-R',f'{os.getuid()}:{os.getgid()}','/fixture'],capture_output=True)
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""显式演练新 PG:停止 laptop 主库服务,验证代理自动切换,再回切。需 --run。"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location('pg_runner', ROOT / 'run.py')
|
||||
runner = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(runner)
|
||||
|
||||
|
||||
def command(args, **kw):
|
||||
return subprocess.run(args, capture_output=True, text=True, timeout=35, **kw)
|
||||
|
||||
|
||||
def wait_until(fn, description, timeout=100):
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
try:
|
||||
if fn():
|
||||
print(description, flush=True)
|
||||
return
|
||||
except (OSError, ValueError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
time.sleep(2)
|
||||
raise RuntimeError(description + ' timeout')
|
||||
|
||||
|
||||
def role(ip):
|
||||
with urllib.request.urlopen('http://' + ip + ':8008/patroni', timeout=3) as r:
|
||||
return json.load(r)['role']
|
||||
|
||||
|
||||
def main():
|
||||
if sys.argv[1:] != ['--run']:
|
||||
raise SystemExit('需显式 --run;会停止生产 laptop PG 服务并在验收后回切。')
|
||||
address = 'https://bao.ad.ddupan.top:8200'
|
||||
token = runner.spiffe_token(address)
|
||||
database = 'homelab_ha_probe_' + str(int(time.time()))
|
||||
try:
|
||||
request = urllib.request.Request(address + '/v1/kv/data/infra/postgresql/ayatori/prod',
|
||||
headers={'X-Vault-Token': token})
|
||||
with urllib.request.urlopen(request, timeout=10) as r:
|
||||
credentials = json.load(r)['data']['data']
|
||||
env = dict(os.environ, PGHOST='pg-prod.ad.ddupan.top', PGHOSTADDR='192.168.10.2',
|
||||
PGPORT='5432', PGDATABASE='postgres', PGUSER=credentials['username'],
|
||||
PGPASSWORD=credentials['password'], PGSSLMODE='verify-full',
|
||||
PGSSLROOTCERT=str(ROOT / 'ayatori/ca.crt'), PGCONNECT_TIMEOUT='3')
|
||||
|
||||
def sql(query, db='postgres'):
|
||||
result = command(['/usr/lib/postgresql/18/bin/psql', '-X', '-At', '-v', 'ON_ERROR_STOP=1'],
|
||||
input=query, env=dict(env, PGDATABASE=db))
|
||||
if result.returncode:
|
||||
raise RuntimeError('SQL 验证失败: ' + result.stderr)
|
||||
return result.stdout.strip()
|
||||
|
||||
def via(ip):
|
||||
try:
|
||||
return sql("SELECT host(inet_server_addr()) || ':' || "
|
||||
"(NOT pg_is_in_recovery() AND current_setting('transaction_read_only')='off')::text") == ip + ':true'
|
||||
except RuntimeError:
|
||||
return False
|
||||
|
||||
def rows(ip, count):
|
||||
args = ['sudo', '-n', '-u', 'pgprod', '/usr/lib/postgresql/18/bin/psql', '-X', '-At',
|
||||
'-h', '/run/homelab-postgresql-prod', '-d', database, '-c', 'SELECT count(*) FROM probe']
|
||||
if ip != '192.168.10.127':
|
||||
args = ['ssh', 'root@' + ip, shlex.join(args)]
|
||||
return command(args).stdout.strip() == str(count)
|
||||
|
||||
assert role('192.168.10.127') in ('master', 'primary')
|
||||
assert role('10.60.0.20') == 'replica'
|
||||
assert via('192.168.10.127')
|
||||
sql('CREATE DATABASE ' + database)
|
||||
print('探针数据库:', database, flush=True)
|
||||
sql('CREATE TABLE probe(id integer PRIMARY KEY); INSERT INTO probe VALUES(1)', database)
|
||||
wait_until(lambda: rows('10.60.0.20', 1), '初始数据已复制')
|
||||
started = time.monotonic()
|
||||
result = command(['sudo', '-n', 'systemctl', 'stop', 'homelab-postgresql-prod'])
|
||||
if result.returncode:
|
||||
raise RuntimeError('停止主库服务失败')
|
||||
wait_until(lambda: via('10.60.0.20'), '代理已自动切到 standby')
|
||||
sql('INSERT INTO probe VALUES(2)', database)
|
||||
print('自动恢复写入秒数:', round(time.monotonic() - started, 1), flush=True)
|
||||
command(['sudo', '-n', 'systemctl', 'start', 'homelab-postgresql-prod'], check=True)
|
||||
wait_until(lambda: role('192.168.10.127') == 'replica' and rows('192.168.10.127', 2),
|
||||
'旧主重新作为 replica 加入,数据一致')
|
||||
result = command(['sudo', '-n', '-u', 'pgprod', '/opt/homelab-patroni/bin/patronictl',
|
||||
'-c', '/etc/homelab-postgresql/prod/patroni.yml', 'switchover',
|
||||
'--leader', 'pg-pve1', '--candidate', 'pg-laptop', '--force'])
|
||||
if result.returncode:
|
||||
raise RuntimeError('计划回切失败: ' + result.stderr)
|
||||
wait_until(lambda: via('192.168.10.127'), '已计划回切 laptop')
|
||||
sql('INSERT INTO probe VALUES(3)', database)
|
||||
wait_until(lambda: role('10.60.0.20') == 'replica' and rows('10.60.0.20', 3), '回切后复制正常')
|
||||
sql('DROP DATABASE ' + database)
|
||||
print('PASS;专属探针数据库已删除', flush=True)
|
||||
finally:
|
||||
# 即使演练失败,也确保原主服务重新运行;Patroni 自行决定角色,不强行提升。
|
||||
command(['sudo', '-n', 'systemctl', 'start', 'homelab-postgresql-prod'])
|
||||
runner.bao_request(address, 'auth/token/revoke-self', {}, token)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
"""将真实 pgBackRest 备份恢复到临时目录,仅用私有 Unix socket 启动并验证。"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
def run(args, **kwargs):
|
||||
return subprocess.run(['sudo', '-n', '-u', 'pgprod', *args],
|
||||
check=True, capture_output=True, text=True, timeout=240, **kwargs)
|
||||
|
||||
|
||||
def main():
|
||||
if sys.argv[1:] != ['--run']:
|
||||
raise SystemExit('需显式 --run;仅恢复到新的临时目录,不接触原 PGDATA。')
|
||||
root = Path(tempfile.mkdtemp(prefix='homelab-pg-restore-', dir='/tmp'))
|
||||
data = root / 'data'
|
||||
started = False
|
||||
try:
|
||||
(root / 'pg_hba.conf').write_text('local all pgprod peer\n')
|
||||
(root / 'test.conf').write_text(
|
||||
f"data_directory='{data}'\nhba_file='{root}/pg_hba.conf'\n"
|
||||
f"unix_socket_directories='{root}'\nunix_socket_permissions=0700\n"
|
||||
"listen_addresses=''\nport=55432\nssl=off\narchive_mode=off\n"
|
||||
"shared_buffers=32MB\nmax_connections=80\nmax_wal_senders=5\n"
|
||||
"hot_standby=on\nprimary_conninfo=''\n")
|
||||
subprocess.run(['sudo', '-n', 'chown', '-R', 'pgprod:pgprod', str(root)], check=True)
|
||||
run(['pgbackrest', '--config=/etc/homelab-postgresql/prod/pgbackrest.conf', '--stanza=prod',
|
||||
'--pg1-path=' + str(data), '--type=immediate', '--target-action=promote',
|
||||
'--archive-mode=off', 'restore'])
|
||||
print('真实仓库备份已恢复到临时目录', flush=True)
|
||||
run(['/usr/lib/postgresql/18/bin/pg_ctl', '-D', str(data), '-l', str(root / 'server.log'),
|
||||
'-o', '-c config_file=' + str(root / 'test.conf'), '-w', '-t', '60', 'start'])
|
||||
started = True
|
||||
# pg_ctl ready 可早于 WAL recovery 完成;等待恢复结束并可写。
|
||||
for _ in range(60):
|
||||
result = run(['/usr/lib/postgresql/18/bin/psql', '-X', '-At', '-h', str(root), '-p', '55432',
|
||||
'-d', 'postgres', '-c',
|
||||
"SELECT NOT pg_is_in_recovery() AND NOT rolsuper AND rolcreatedb AND rolcreaterole "
|
||||
"FROM pg_roles WHERE rolname='ayatori'"])
|
||||
if result.stdout.strip() == 't':
|
||||
break
|
||||
time.sleep(1)
|
||||
else:
|
||||
raise RuntimeError('恢复后 SQL/管理角色验证未通过')
|
||||
print('PASS:恢复实例可写、Ayatori 管理角色属性正确;仅私有 Unix socket', flush=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(e.stderr, file=sys.stderr)
|
||||
raise
|
||||
finally:
|
||||
# pg_ctl start 超时也可能已经创建 postmaster,清理前必须先确认停止。
|
||||
if started or (data / 'postmaster.pid').exists():
|
||||
run(['/usr/lib/postgresql/18/bin/pg_ctl', '-D', str(data), '-m', 'immediate', '-w', 'stop'])
|
||||
subprocess.run(['sudo', '-n', 'chown', '-R', str(os.getuid()) + ':' + str(os.getgid()), str(root)], check=True)
|
||||
shutil.rmtree(root)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""验证短期会话边界:失败不回退、子进程失败也撤销;不使用真实秘密。"""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location('pg_runner', Path(__file__).parents[1] / 'run.py')
|
||||
runner = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(runner)
|
||||
|
||||
|
||||
class RunnerTests(unittest.TestCase):
|
||||
def test_revoke_on_child_failure(self):
|
||||
with patch.object(runner.sys, 'argv', ['run.py', '--spiffe', 'ansible', 'credentials.yml']), \
|
||||
patch.object(runner, 'spiffe_token', return_value='test-token'), \
|
||||
patch.object(runner, 'run_command', side_effect=RuntimeError('child failed')), \
|
||||
patch.object(runner, 'bao_request') as request:
|
||||
with self.assertRaises(RuntimeError):
|
||||
runner.main()
|
||||
self.assertEqual(request.call_args.args[1:], ('auth/token/revoke-self', {}, 'test-token'))
|
||||
|
||||
def test_no_fallback_after_login_failure(self):
|
||||
with patch.object(runner.sys, 'argv', ['run.py', '--spiffe', 'ansible', 'credentials.yml']), \
|
||||
patch.object(runner, 'spiffe_token', side_effect=SystemExit('login failed')), \
|
||||
patch.object(runner, 'run_command') as command:
|
||||
with self.assertRaises(SystemExit):
|
||||
runner.main()
|
||||
command.assert_not_called()
|
||||
|
||||
def test_revoke_on_success_and_preserve_exit_code(self):
|
||||
with patch.object(runner.sys, 'argv', ['run.py', '--spiffe', 'ansible', 'credentials.yml']), \
|
||||
patch.object(runner, 'spiffe_token', return_value='test-token'), \
|
||||
patch.object(runner, 'run_command', return_value=7), \
|
||||
patch.object(runner, 'bao_request') as request:
|
||||
self.assertEqual(runner.main(), 7)
|
||||
request.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user