Files
ocf-managed-container/scripts/test-lab-roles.py
T
Lyncoln ec2077f979 Freeze lab-tested PostgreSQL plugin engine
Extract PostgreSQL, Podman runtime and systemd lifecycle adapters while preserving existing lifecycle and scoring behavior. Update RPM layout and add guarded operator-run lab harnesses.

Record successful manual stop/start, promotion, demotion and rewind-only rejoin with pinned installed hashes. The unfenced lab does not validate automatic failover, Pacemaker-managed transitions or divergent-data rewind.

Validation: 103 offline tests, original-function differential checks, checksum manifest and whitespace checks passed.
2026-09-13 01:57:04 -04:00

251 lines
15 KiB
Python

#!/usr/bin/env python3
"""Operator-run, one-stage-at-a-time role tests for the two-node lab.
No installation, automatic recovery, fencing, or maintenance-mode changes.
See docs/lab-role-tests.md before running a mutating stage.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import pwd
import shlex
import socket
import subprocess
import tempfile
import time
import xml.etree.ElementTree as ET
PROVIDER = Path('/usr/lib/ocf/resource.d/loxthelion')
AGENT = PROVIDER / 'managed-container'
NODES = {'DAVID-ELITEDESK': '192.168.1.6', 'DAVID-DV6': '192.168.1.2'}
PINNED = {'DAVID-DV6': {'managed-container': 'a4b775a0ddcabdb0558da9b34c7fe2a883c07d0ef308c1c7224526abf53f63a0',
'managed-container.d/auth.sh': '93191771d3cb8aefadb2a16d2f89d64a8b6c584071a473439be818237bdf7f17',
'managed-container.d/common.sh': '97e93d9164e854e5f3688cf9d3cc656b001eb30ef3c2073c907450ca43588f3d',
'managed-container.d/container.sh': 'ac6bc363a2301f1491d162ef609103dbfbceb3b099a3556614c6165952309374',
'managed-container.d/demote.sh': '0af3bb362a303017372c631354ba94ef9e723bf14e64d2eff91b6662e54a5be0',
'managed-container.d/notify.sh': '6a98fcdda82cea9365db95b4ee779be5c567b3d846f48b90fbc8c254af697670',
'managed-container.d/postgres.sh': 'd2779ce6e7d1f426ffca2e41bbb8de77503005dc38d658d69f184020365796eb',
'managed-container.d/promote.sh': '3085ec9b4410905ab9d4370786ed776eac3470a0415aa1b4f608b083945aeb58',
'managed-container.d/promotion_state.py': '5166fc3370f49ab14cd975ded4b03a74b9bd7dbff5762b41a15552c42f934ea7',
'managed-container.d/rejoin.sh': '21beea72bcb7f24a51c17ea48666ac87df9436702ad77b166b45f4647e28e8ad',
'managed-container.d/replication.sh': '6b2021887e3b4cd6a12490e7a8dff98320b2133b472ab25a22a0eead46137547'},
'DAVID-ELITEDESK': {'managed-container': '01eb9b161c8e3db289f09bf63953be779921e15bdf8b1a4d8ce7e60032b826e4',
'managed-container.d/applications/postgresql/auth.sh': '10932919c4339b553c7c9dbf30b45a917c51c0a08f362e12678003d58b12fa82',
'managed-container.d/applications/postgresql/demote.sh': 'da4e58cadf1df3953cb97a0512e3931ee1b9d3ee61c39a1ad1a6047ef537addc',
'managed-container.d/applications/postgresql/notify.sh': '6a98fcdda82cea9365db95b4ee779be5c567b3d846f48b90fbc8c254af697670',
'managed-container.d/applications/postgresql/plugin.sh': 'd3a989de97ceb61a56828f006d5ccb87b7c34fc155a944585bedf928785021fc',
'managed-container.d/applications/postgresql/postgres.sh': 'fa222e6767249999767eb82fac2d85e7e35404da240517a8b630571e77282053',
'managed-container.d/applications/postgresql/promote.sh': '3085ec9b4410905ab9d4370786ed776eac3470a0415aa1b4f608b083945aeb58',
'managed-container.d/applications/postgresql/promotion_state.py': '5166fc3370f49ab14cd975ded4b03a74b9bd7dbff5762b41a15552c42f934ea7',
'managed-container.d/applications/postgresql/rejoin.sh': '28b0e44554a88ccb87cd16a68315c045dd37d354aceb2fa25da7c7399a659b23',
'managed-container.d/applications/postgresql/replication.sh': '6b2021887e3b4cd6a12490e7a8dff98320b2133b472ab25a22a0eead46137547',
'managed-container.d/common.sh': 'a482ef3cc1048e6a4f427e76dd2dce173ffd00760dbed1afa601a3fd05b0ad99',
'managed-container.d/container.sh': '3a27ac9c63c61310da8b8711fa2c4400b37ea8eae2ee2a79e28bc63180b06c7c',
'managed-container.d/engine.sh': '53978e2367394695d61c6fb38f2ee46814474c5a4e0448d2692f1a3ce583af93',
'managed-container.d/lifecycles/systemd.sh': '6c92c095ce10ac95d843fdc1c49e31c19491ca22fdac960affd8649606d76d82',
'managed-container.d/runtimes/podman.sh': 'dc5820067a78c4a1c531426902793aae97a03fd64912b017b3b102031dcc1e04'}}
QUERY = """SELECT json_build_object(
'recovery',pg_is_in_recovery(), 'paused',pg_is_wal_replay_paused(),
'system_id',(SELECT system_identifier::text FROM pg_control_system()),
'started',pg_postmaster_start_time()::text,
'receiver',(SELECT status FROM pg_stat_wal_receiver),
'sender',(SELECT sender_host FROM pg_stat_wal_receiver),
'slot',(SELECT slot_name FROM pg_stat_wal_receiver),
'replay',pg_last_wal_replay_lsn()::text);"""
# pg_is_wal_replay_paused() is only callable during recovery.
QUERY = QUERY.replace('pg_is_wal_replay_paused()',
'CASE WHEN pg_is_in_recovery() THEN pg_is_wal_replay_paused() ELSE false END')
def require(ok, message):
if not ok:
raise RuntimeError(message)
def run(args, **kwargs):
result = subprocess.run(args, text=True, capture_output=True, timeout=90,
env=dict(PATH='/usr/sbin:/usr/bin:/sbin:/bin', LC_ALL='C'), **kwargs)
require(result.returncode == 0, f'{args[0]} failed: {result.stderr.strip()}')
return result.stdout.strip()
def owner_args(*args):
account = pwd.getpwnam('david')
return ['runuser', '-u', 'david', '--', 'env', 'HOME=' + account.pw_dir,
'XDG_RUNTIME_DIR=/run/user/' + str(account.pw_uid), *args]
def cluster():
cib = ET.fromstring(run(['cibadmin', '--query']))
def prop(name):
return run(['crm_attribute', '--type', 'crm_config', '--name', name, '--query', '--quiet'])
require(prop('cluster-name') == 'postgres-ra-test' and prop('maintenance-mode') == 'true',
'Expected postgres-ra-test with maintenance=true; nothing changed')
require({n.get('uname') for n in cib.findall('./configuration/nodes/node')} == set(NODES),
'Unexpected cluster membership')
resource = cib.find("./configuration/resources/clone[@id='pg-ha-clone']/primitive[@id='pg-ha']")
require(resource is not None and (resource.get('class'), resource.get('provider'), resource.get('type'))
== ('ocf', 'loxthelion', 'managed-container'), 'Unexpected resource')
attrs = {}
for group in resource.findall('instance_attributes'):
require(not group.get('id-ref') and group.find('rule') is None, 'Unsupported parameter rules')
for pair in group.findall('nvpair'):
key, value = pair.get('name'), pair.get('value')
require(key and value is not None and key not in attrs, 'Ambiguous parameters')
attrs[key] = value
defaults = dict(service_user='david', service_name='postgres.service', container_name='postgres',
pg_os_user='postgres', db_user='david', db_name='david',
application='postgresql', container_runtime='podman', lifecycle_manager='systemd')
require(all(attrs.get(k, v) == v for k, v in defaults.items()), 'Unexpected service/application settings')
return dict(defaults, **attrs)
def snapshot(node):
# Executes only read operations as david on either host. Never inspects container environment.
code = """import hashlib,json,pathlib,socket,subprocess
def cmd(args):
p=subprocess.run(['timeout','--kill-after=1s','5s']+args,text=True,capture_output=True)
if p.returncode: raise RuntimeError('Read-only observation failed: '+args[0])
return p.stdout.strip()
files=FILES
base=pathlib.Path('/usr/lib/ocf/resource.d/loxthelion')
assert all(not (base/n).is_symlink() for n in files)
print(json.dumps(dict(node=socket.gethostname(),
hashes={n:hashlib.sha256((base/n).read_bytes()).hexdigest() for n in files},
service=cmd(['systemctl','--user','show','postgres.service','--property=ActiveState','--value']),
container=cmd(['podman','inspect','--format','{{.Id}}','postgres']),
db=json.loads(cmd(['podman','exec','--user','postgres','--env','PGOPTIONS=-c statement_timeout=2000',
'postgres','psql','-X','-v','ON_ERROR_STOP=1','-U','david','-d','david','-Atqc',QUERY])))))
""".replace('FILES', repr(list(PINNED[node]))).replace('QUERY', repr(QUERY))
if node == socket.gethostname():
args = owner_args('python3', '-c', code)
else:
args = owner_args('ssh', '-F', '/dev/null', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5',
'-o', 'StrictHostKeyChecking=yes', 'david@' + NODES[node],
'python3 -c ' + shlex.quote(code))
value = json.loads(run(args, cwd=pwd.getpwnam('david').pw_dir))
require(value['node'] == node and value['hashes'] == PINNED[node], 'Node or installed code differs: ' + node)
require(value['service'] == 'active', 'Maintenance service not active: ' + node)
return value
def standby(db, source):
return (db['recovery'] is True and db['paused'] is False and db['receiver'] == 'streaming'
and db['sender'] == NODES[source])
def guard(stage, node, local, peer):
require(local['system_id'] == peer['system_id'], 'Database system identifiers differ')
other = next(n for n in NODES if n != node)
if stage == 'demote':
require(local['recovery'] is False and standby(peer, node), 'Need local primary and streaming peer standby')
elif stage == 'promote':
require(local['recovery'] is True and local['paused'] is False and standby(peer, node),
'Both nodes must be in recovery and the demoted peer must stream from this node')
elif stage == 'rejoin':
require(node == 'DAVID-ELITEDESK' and standby(local, other) and peer['recovery'] is False,
'Rejoin test requires ELITEDESK streaming from primary DV6')
def main(stage):
node = socket.gethostname()
require(os.geteuid() == 0 and node in NODES, 'Run with sudo on an expected lab node')
os.umask(0o077)
peer = next(n for n in NODES if n != node)
attrs = cluster()
before, remote = snapshot(node), snapshot(peer)
guard(stage, node, before['db'], remote['db'])
if stage == 'inspect':
print(json.dumps(dict(local=before, peer=remote, maintenance=True), indent=2))
return
evidence = Path(tempfile.mkdtemp(prefix='managed-container-roles-lab-', dir='/var/backups'))
print('Results:', evidence, flush=True)
result = dict(stage=stage, node=node, before=before, peer_before=remote, actions=[], status='running')
env = dict(PATH='/usr/sbin:/usr/bin:/sbin:/bin', OCF_ROOT='/usr/lib/ocf', OCF_RESOURCE_INSTANCE='pg-ha',
OCF_RESKEY_CRM_meta_on_node=node, **{'OCF_RESKEY_' + k: v for k, v in attrs.items()})
def save():
(evidence / 'results.json').write_text(json.dumps(result, indent=2) + '\n')
def action(name, expected=0, extra=None):
require(cluster() == attrs, 'Cluster configuration or maintenance changed')
response = subprocess.run(['timeout', '--kill-after=5s', '120s', str(AGENT), name],
env=dict(env, **(extra or {})), cwd='/', text=True, capture_output=True)
result['actions'].append(dict(action=name, rc=response.returncode, expected=expected))
(evidence / f'{len(result["actions"]):02d}-{name}.log').write_text(response.stdout + response.stderr)
save()
require(response.returncode == expected, f'{name} returned {response.returncode}; expected {expected}')
print(f'PASS {name}: rc={response.returncode}', flush=True)
save()
try:
action('validate-all')
action('monitor', 8 if before['db']['recovery'] is False else 0)
# Reobserve immediately before mutation. No action retry or inferred fencing certificate.
current, remote = snapshot(node), snapshot(peer)
require(current['container'] == before['container'] and
all(current['db'][k] == before['db'][k] for k in ('system_id', 'started', 'recovery')),
'Local incarnation/role changed during preflight')
guard(stage, node, current['db'], remote['db'])
if stage == 'demote':
action('notify', extra=dict(OCF_RESKEY_CRM_meta_notify_type='pre',
OCF_RESKEY_CRM_meta_notify_operation='demote',
OCF_RESKEY_CRM_meta_notify_demote_uname=node,
OCF_RESKEY_CRM_meta_notify_promote_uname=peer))
action('demote')
elif stage == 'promote':
action('promote')
elif stage == 'rejoin':
# This stage tests rewind/recovery of a healthy standby, not divergent former-primary repair.
run(owner_args('timeout', '--kill-after=5s', '60s', 'podman', 'exec', '--user', 'postgres',
'postgres', 'sh', '-c',
'exec "$(pg_config --bindir)/pg_ctl" -D "$PGDATA" stop -m fast -w'))
action('monitor', 7)
require(run(['crm_attribute', '--promotion=pg-ha', '--node', node,
'--lifetime=reboot', '--query', '--quiet']) == '-INFINITY',
'Stopped PostgreSQL retained an eligible score')
require(snapshot(peer)['db']['recovery'] is False, 'DV6 is no longer primary')
action('rejoin', extra=dict(OCF_RESKEY_rejoin_source_host=NODES[peer],
OCF_RESKEY_replication_slot='ha_david_elitedesk',
OCF_RESKEY_application_name=node,
OCF_RESKEY_rejoin_allow_basebackup='false'))
deadline = time.monotonic() + 60
while True:
after, remote = snapshot(node), snapshot(peer)
require(after['db']['system_id'] == before['db']['system_id'], 'Local database identity changed')
require(after['container'] == before['container'], 'Maintenance container was replaced')
require(remote['db']['system_id'] == before['db']['system_id'], 'Peer database identity changed')
if stage == 'promote':
require(after['db']['recovery'] is False and standby(remote['db'], node), 'Unexpected roles after promotion')
expected_score = '1000'
else:
require(standby(after['db'], peer), 'Local node did not return to streaming recovery')
require(after['db']['started'] != before['db']['started'], 'Expected a new postmaster')
require(remote['db']['recovery'] is (stage == 'demote'), 'Unexpected peer role')
expected_score = '100'
action('monitor', 8 if stage == 'promote' else 0)
value = run(['crm_attribute', '--promotion=pg-ha', '--node', node, '--lifetime=reboot', '--query', '--quiet'])
if value == expected_score:
break
require(time.monotonic() < deadline, 'Expected promotion score did not converge')
time.sleep(2)
result.update(after=after, peer_after=remote, score=value, status='passed')
require(cluster() == attrs, 'Cluster configuration changed')
save()
print(f'PASS {stage}: verified roles, streaming, same database/container, score={value}.', flush=True)
print('Maintenance remains true. No fencing or automatic recovery ran.', flush=True)
except BaseException as error:
result.update(status='failed', error=str(error))
save()
print('HALTED. Do not continue to the next stage. Inspect:', evidence, flush=True)
raise
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('stage', choices=['inspect', 'demote', 'promote', 'rejoin'])
try:
main(parser.parse_args().stage)
except Exception as error:
raise SystemExit('STOP: ' + str(error))