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,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()