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.
This commit is contained in:
Lyncoln
2026-09-13 01:57:04 -04:00
parent 488a1672d7
commit ec2077f979
41 changed files with 1925 additions and 331 deletions
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""One guarded RA stop/start cycle on the ELITEDESK lab standby only."""
import importlib.util
import json
import os
from pathlib import Path
import pwd
import socket
import subprocess
import sys
import tempfile
import time
import hashlib
spec = importlib.util.spec_from_file_location('lab', Path(__file__).with_name('test-elitedesk-plugin.py'))
lab = importlib.util.module_from_spec(spec)
spec.loader.exec_module(lab)
def owner_command(*args):
account = pwd.getpwnam('david')
return lab.run(['runuser', '-u', 'david', '--', 'env', 'HOME=' + account.pw_dir,
'XDG_RUNTIME_DIR=/run/user/' + str(account.pw_uid),
'timeout', '--kill-after=1s', '5s', *args], cwd=account.pw_dir)
def score():
return lab.run(['crm_attribute', '--promotion=pg-ha', '--node', 'DAVID-ELITEDESK',
'--lifetime=reboot', '--query', '--quiet'])
def verify_payload():
entries = lab.hashes(lab.ROOT / 'docs/lab-installed-plugin.sha256')
lab.require(len(entries) == 15, 'Expected the 15-file installed plugin snapshot')
for name, digest in entries.items():
path = Path(name)
lab.require(lab.PROVIDER in path.parents and not path.is_symlink()
and hashlib.sha256(path.read_bytes()).hexdigest() == digest,
'Installed code changed; inspect before testing: ' + name)
def main():
lab.require(os.geteuid() == 0, 'Run with sudo in your terminal; do not send passwords in chat')
lab.require(socket.gethostname() == 'DAVID-ELITEDESK', 'Only the ELITEDESK lab standby is supported')
os.umask(0o077)
attributes = lab.cluster() # Exact lab membership/resource; maintenance=true.
before = lab.observe() # Running standby streaming from DV6 with its own slot.
verify_payload()
evidence = Path(tempfile.mkdtemp(prefix='managed-container-cycle-lab-', dir=lab.BACKUP_ROOT))
print('Results:', evidence, flush=True)
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='DAVID-ELITEDESK',
**{'OCF_RESKEY_' + k: v for k, v in attributes.items()})
results = dict(before=before, actions=[], status='running')
def save():
(evidence / 'results.json').write_text(json.dumps(results, indent=2) + '\n')
def unchanged_cluster():
lab.require(lab.cluster() == attributes, 'Lab configuration or maintenance state changed; halt')
def action(name, expected=0):
unchanged_cluster()
# GNU timeout bounds the RA process group, not just its parent process.
result = subprocess.run(['timeout', '--kill-after=5s', '60s', str(lab.AGENT), name],
env=env, cwd='/', text=True, capture_output=True)
results['actions'].append(dict(action=name, rc=result.returncode, expected=expected))
(evidence / f'{len(results["actions"]):02d}-{name}.log').write_text(result.stdout + result.stderr)
save()
lab.require(result.returncode == expected,
f'{name}: expected rc={expected}, got {result.returncode}; see {evidence}')
print(f'PASS {name}: rc={result.returncode}', flush=True)
save()
try:
action('validate-all')
action('monitor')
lab.require(lab.observe() == before, 'Standby changed before stop')
action('stop')
active = owner_command('systemctl', '--user', 'show', 'postgres.service', '--property=ActiveState', '--value')
containers = owner_command('podman', 'ps', '--all', '--filter', 'name=^postgres$', '--format', '{{.State}}')
results['stopped'] = dict(service=active, container=containers or 'absent')
lab.require(active == 'inactive' and containers in ('', 'exited', 'stopped'),
'Service/container did not reach the expected stopped state')
action('monitor', 7)
results['stopped']['score'] = score()
lab.require(results['stopped']['score'] == '-INFINITY', 'Stopped standby retained an eligible score')
save()
print('PASS stopped state: service inactive, container stopped/absent, score=-INFINITY', flush=True)
verify_payload()
action('start') # Existing standby.signal guard remains in the RA.
deadline = time.monotonic() + 60
last_error = 'No streaming observation yet'
while time.monotonic() < deadline:
unchanged_cluster()
try:
after = lab.observe()
except RuntimeError as error:
last_error = str(error)
time.sleep(2)
continue
lab.require(after['system_id'] == before['system_id'], 'PostgreSQL system identifier changed')
lab.require(after['started'] != before['started'], 'Postmaster start time did not change')
results['after'] = after
action('monitor')
results['score'] = score()
save()
if results['score'] == '100':
break
last_error = 'Streaming resumed but strict standby score has not reached 100'
time.sleep(2)
else:
raise RuntimeError('Post-start verification deadline expired: ' + last_error)
action('status')
unchanged_cluster()
verify_payload()
results['status'] = 'passed'
save()
print('PASS cycle: same database, new postmaster, streaming from DV6, score=100.', flush=True)
print('Maintenance remains true. No promotion, demotion, rejoin, fencing, or peer actions ran.', flush=True)
except BaseException as error:
results['status'] = 'failed'
results['error'] = str(error)
save()
print('HALTED: no automatic retry, recovery, or data replacement. Inspect results:', evidence, flush=True)
raise
if __name__ == '__main__':
try:
main()
except Exception as error:
print('STOP:', error, file=sys.stderr)
sys.exit(1)
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Install the plugin candidate and run non-disruptive checks on the lab standby.
Run with sudo from the operator's terminal. Never supplies a password, changes
maintenance mode, or runs stop/promote/demote/rejoin. Logs are root-only.
"""
import hashlib
import json
import os
from pathlib import Path
import pwd
import shutil
import socket
import signal
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
ROOT = Path(__file__).resolve().parents[1]
PROVIDER = Path('/usr/lib/ocf/resource.d/loxthelion')
AGENT = PROVIDER / 'managed-container'
MODULES = PROVIDER / 'managed-container.d'
BASELINE = ROOT / 'docs/lab-installed-before-plugin.sha256'
BACKUP_ROOT = Path('/var/backups')
def run(args, env=None, cwd=None):
clean_env = dict(PATH='/usr/sbin:/usr/bin:/sbin:/bin', HOME='/root', LC_ALL='C')
result = subprocess.run(args, env=env if env is not None else clean_env, cwd=cwd,
text=True, capture_output=True, timeout=90)
if result.returncode:
raise RuntimeError(f'{Path(args[0]).name} failed (rc={result.returncode}): {result.stderr.strip()}')
return result.stdout.strip()
def require(condition, message):
if not condition:
raise RuntimeError(message)
def hashes(manifest):
return dict((name, digest) for digest, name in
(line.split(' ', 1) for line in manifest.read_text().splitlines() if line))
def cluster():
cib = ET.fromstring(run(['cibadmin', '--query']))
require(run(['crm_attribute', '--type', 'crm_config', '--name', 'cluster-name', '--query', '--quiet'])
== 'postgres-ra-test', 'This is not the postgres-ra-test lab')
require(run(['crm_attribute', '--type', 'crm_config', '--name', 'maintenance-mode', '--query', '--quiet'])
== 'true', 'Lab maintenance-mode must already be true; nothing changed')
require({n.get('uname') for n in cib.findall('./configuration/nodes/node')} ==
{'DAVID-ELITEDESK', 'DAVID-DV6'}, 'Unexpected cluster membership; inspect before proceeding')
resources = cib.findall("./configuration/resources/clone[@id='pg-ha-clone']/primitive[@id='pg-ha']")
require(len(resources) == 1, 'Expected pg-ha under pg-ha-clone')
resource = resources[0]
require((resource.get('class'), resource.get('provider'), resource.get('type')) ==
('ocf', 'loxthelion', 'managed-container'), 'Unexpected resource agent')
attributes = {}
for group in resource.findall('instance_attributes'):
require(not group.get('id-ref') and group.find('rule') is None,
'Referenced/rule-based parameters require manual review')
for pair in group.findall('nvpair'):
name, value = pair.get('name'), pair.get('value')
require(name not in attributes and name and value is not None, 'Ambiguous resource parameters')
attributes[name] = value
expected = dict(service_user='david', service_name='postgres.service', container_name='postgres',
pg_os_user='postgres', db_user='david', db_name='david')
require(all(attributes.get(k, v) == v for k, v in expected.items()), 'Lab service/database parameters changed')
return dict(expected, **{k: v for k, v in attributes.items() if k not in expected})
def observe():
account = pwd.getpwnam('david')
query = """SELECT json_build_object('recovery',pg_is_in_recovery(),
'started',pg_postmaster_start_time()::text,
'system_id',(SELECT system_identifier::text FROM pg_control_system()),
'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));"""
output = run(['runuser', '-u', 'david', '--', 'env', 'HOME=' + account.pw_dir,
'XDG_RUNTIME_DIR=/run/user/' + str(account.pw_uid),
'timeout', '--kill-after=1s', '5s', 'podman', 'exec', '--user', 'postgres',
'--env', 'PGOPTIONS=-c statement_timeout=2000', 'postgres',
'psql', '-X', '-v', 'ON_ERROR_STOP=1', '-U', 'david', '-d', 'david', '-Atqc', query],
cwd=account.pw_dir)
state = json.loads(output)
require(state['recovery'] is True and state['receiver'] == 'streaming'
and state['sender'] == '192.168.1.2' and state['slot'] == 'ha_david_elitedesk',
'Expected a streaming ELITEDESK standby of lab DV6; refusing installation/start')
return state
def main():
require(os.geteuid() == 0, 'Run this script with sudo in your terminal; do not send passwords in chat')
require(socket.gethostname() == 'DAVID-ELITEDESK', 'This script is only for DAVID-ELITEDESK')
def interrupted(signum, frame):
raise RuntimeError(f'Interrupted by signal {signum}')
signal.signal(signal.SIGTERM, interrupted)
signal.signal(signal.SIGINT, interrupted)
os.umask(0o077)
os.environ['PATH'] = '/usr/sbin:/usr/bin:/sbin:/bin'
attributes = cluster()
before = observe()
for name, digest in hashes(BASELINE).items():
path = PROVIDER / name
require(not path.is_symlink() and hashlib.sha256(path.read_bytes()).hexdigest() == digest,
f'Installed baseline changed: {name}; review before replacing')
manifest = hashes(ROOT / 'notes/REFACTOR-SHA256SUMS')
candidates = [ROOT / 'refactored/managed-container'] + sorted(
p for p in (ROOT / 'refactored/managed-container.d').rglob('*') if p.suffix in ('.sh', '.py') and p.is_file())
require(len(candidates) == 15, 'Unexpected candidate file layout')
for path in candidates:
require(not path.is_symlink() and hashlib.sha256(path.read_bytes()).hexdigest()
== manifest.get(str(path.relative_to(ROOT))), f'Candidate checksum mismatch: {path.name}')
# Copy verified code into a root-owned staging directory before executing it.
backup = Path(tempfile.mkdtemp(prefix='managed-container-plugin-lab-', dir=BACKUP_ROOT))
print('Backup and results:', backup, flush=True)
shutil.copy2(AGENT, backup / 'managed-container')
shutil.copytree(MODULES, backup / 'managed-container.d', symlinks=True)
stage = Path(tempfile.mkdtemp(prefix='.mc-plugin-stage-', dir=PROVIDER))
for source in candidates:
destination = stage / source.relative_to(ROOT / 'refactored')
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, destination)
destination.chmod(0o755 if destination.name == 'managed-container' else 0o644)
require(hashlib.sha256(destination.read_bytes()).hexdigest() ==
manifest[str(source.relative_to(ROOT))], 'Source changed while staging')
for directory in (stage / 'managed-container.d').rglob('*'):
if directory.is_dir(): directory.chmod(0o755)
(stage / 'managed-container.d').chmod(0o755)
env = dict(PATH=os.environ['PATH'], OCF_ROOT='/usr/lib/ocf', OCF_RESOURCE_INSTANCE='pg-ha',
OCF_RESKEY_CRM_meta_on_node='DAVID-ELITEDESK',
**{'OCF_RESKEY_' + k: v for k, v in attributes.items()})
results = dict(before=before, actions=[])
def action(executable, name):
output = run([str(executable), name], env=env, cwd='/')
results['actions'].append(dict(agent=str(executable), action=name, rc=0))
print(f'PASS {executable.parent.name}/{name}: rc=0', flush=True)
return output
staged = stage / 'managed-container'
metadata = action(staged, 'meta-data')
require(len(ET.fromstring(metadata).findall('./parameters/parameter')) == 16, 'Unexpected plugin metadata')
(backup / 'candidate-metadata.xml').write_text(metadata + '\n')
action(staged, 'validate-all')
require(cluster() == attributes, 'Cluster configuration changed before installation')
require(observe() == before, 'PostgreSQL state changed before installation')
retired = stage / 'previous-modules'
modules_moved = False
try:
os.replace(MODULES, retired)
modules_moved = True
os.replace(stage / 'managed-container.d', MODULES)
os.replace(staged, AGENT)
run(['restorecon', '-RF', str(AGENT), str(MODULES)])
action(AGENT, 'validate-all')
action(AGENT, 'monitor')
action(AGENT, 'status')
require(cluster() == attributes and observe() == before, 'Standby/cluster changed before idempotent start')
action(AGENT, 'start') # Already healthy standby: exercises the no-op path.
action(AGENT, 'monitor')
require(cluster() == attributes, 'Cluster configuration changed during checks')
results['after'] = observe()
require(results['after'] == before, 'Database identity/start time or replication changed during checks')
results['score'] = run(['crm_attribute', '--promotion=pg-ha', '--node', 'DAVID-ELITEDESK',
'--lifetime=reboot', '--query', '--quiet'])
require(results['score'] in ('100', '-INFINITY'), 'Unexpected standby score')
# Under write load a connected standby can legitimately fail strict catch-up.
for source in candidates:
installed = PROVIDER / source.relative_to(ROOT / 'refactored')
require(hashlib.sha256(installed.read_bytes()).hexdigest() ==
manifest[str(source.relative_to(ROOT))], 'Installed payload mismatch')
(backup / 'results.json').write_text(json.dumps(results, indent=2) + '\n')
(backup / 'installed-SHA256SUMS').write_text(''.join(
manifest[str(p.relative_to(ROOT))] + ' ' + str(PROVIDER / p.relative_to(ROOT / 'refactored')) + '\n'
for p in candidates))
except BaseException:
if modules_moved:
print('Check failed: restoring previous agent and modules; maintenance mode is not changed.', flush=True)
if MODULES.exists(): os.replace(MODULES, stage / 'failed-modules')
os.replace(retired, MODULES)
shutil.copyfile(backup / 'managed-container', stage / 'restore-agent')
(stage / 'restore-agent').chmod(0o755)
os.replace(stage / 'restore-agent', AGENT)
run(['restorecon', '-RF', str(AGENT), str(MODULES)])
(backup / 'results.json').write_text(json.dumps(results, indent=2) + '\n')
print('Staging/evidence retained:', stage, flush=True)
raise
shutil.rmtree(stage)
print('PASS: installed; PostgreSQL was not restarted; maintenance remains true.', flush=True)
print('Current score:', results['score'], '(strict standby catch-up can fluctuate)', flush=True)
print('No stop, promote, demote, rejoin, fencing, or peer-node actions were run.', flush=True)
if __name__ == '__main__':
try:
main()
except Exception as error:
print('STOP:', error, file=sys.stderr)
sys.exit(1)
+250
View File
@@ -0,0 +1,250 @@
#!/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))