Add PostgreSQL HA OCF agent modular baseline
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline structural and behavior comparisons. Never invokes live HA actions."""
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REFERENCE = ROOT / 'reference/managed-container'
|
||||
AGENT = ROOT / 'refactored/managed-container'
|
||||
MODULES = sorted((AGENT.parent / 'managed-container.d').glob('*.sh'))
|
||||
SOURCE = REFERENCE.read_text()
|
||||
PATTERN = r'^([a-z_]+)\(\)\n\{\n.*?^\}'
|
||||
|
||||
|
||||
def functions(source):
|
||||
matches = list(re.finditer(PATTERN, source, re.M | re.S))
|
||||
result = {m[1]: m[0] for m in matches}
|
||||
assert len(result) == len(matches), 'Duplicate functions'
|
||||
return result
|
||||
|
||||
|
||||
def run(code):
|
||||
return subprocess.run(['bash', '-c', code], capture_output=True, text=True)
|
||||
|
||||
|
||||
def compare_case(name, setup, action, expected):
|
||||
results = []
|
||||
for definitions in (SOURCE[:SOURCE.index('case "$1" in')], modular_definitions):
|
||||
result = run(definitions + '\n' + setup + '\n' + action)
|
||||
assert result.returncode == expected, (name, result.returncode, result.stderr)
|
||||
results.append((result.returncode, result.stdout, result.stderr))
|
||||
assert results[0] == results[1], (name, results)
|
||||
print('PASS', name)
|
||||
|
||||
|
||||
expected_hash = '5eae1f3dba0586dc06105e4dd7a9b8809a5c9961452abe7654dcfce77c3c5ce2'
|
||||
for path in (REFERENCE, ROOT / 'reference/managed-container.pre-refactor-5eae1f3'):
|
||||
assert hashlib.sha256(path.read_bytes()).hexdigest() == expected_hash
|
||||
print('PASS both reference hashes')
|
||||
|
||||
assert functions(SOURCE) == functions('\n'.join(p.read_text() for p in MODULES))
|
||||
assert len(functions(SOURCE)) == 29
|
||||
common = (AGENT.parent / 'managed-container.d/common.sh').read_text()
|
||||
defaults = SOURCE[SOURCE.index('OCF_RESKEY_service_user_default='):SOURCE.index('\n###############################################################################\n# Metadata')].strip()
|
||||
assert defaults in common
|
||||
assert SOURCE[SOURCE.index('case "$1" in'):] == AGENT.read_text()[AGENT.read_text().index('case "$1" in'):]
|
||||
print('PASS all 29 function bodies, defaults/globals and dispatcher identical')
|
||||
|
||||
for path in [AGENT, *MODULES]:
|
||||
subprocess.run(['bash', '-n', str(path)], check=True)
|
||||
print('PASS syntax for entrypoint and all nine modules')
|
||||
|
||||
metadata = []
|
||||
for path in (REFERENCE, AGENT):
|
||||
result = subprocess.run([str(path), 'meta-data'], cwd='/tmp', capture_output=True, text=True)
|
||||
assert result.returncode == 0 and not result.stderr, result
|
||||
metadata.append(result.stdout)
|
||||
assert metadata[0] == metadata[1]
|
||||
xml = ET.fromstring(metadata[0])
|
||||
assert len(xml.findall('./parameters/parameter')) == 12
|
||||
assert len(xml.findall('./actions/action')) == 8
|
||||
print('PASS metadata byte equality and XML parsing from unrelated cwd')
|
||||
|
||||
# Sourcing production modules in a fresh shell tests their real loading order.
|
||||
# No dispatcher executes. All operational boundaries are overridden below.
|
||||
modular_definitions = SOURCE[:SOURCE.index('\n###############################################################################')]
|
||||
for name in ('common', 'container', 'postgres', 'auth', 'replication', 'notify', 'promote', 'demote', 'rejoin'):
|
||||
modular_definitions += '\n. ' + "'" + str(AGENT.parent / 'managed-container.d' / (name + '.sh')).replace("'", "'\\''") + "'"
|
||||
base = '''
|
||||
ocf_log() { printf 'log %s\\n' "$*"; }
|
||||
ocf_exit_reason() { printf 'reason %s\\n' "$*" >&2; }
|
||||
service_running() { return 0; }
|
||||
container_running() { return 0; }
|
||||
pg_running() { return 0; }
|
||||
pg_ready() { return 0; }
|
||||
pg_role() { printf 't\\n'; }
|
||||
# Unexpected access to the real rootless boundary fails the scenario.
|
||||
as_service_user() { printf 'UNEXPECTED external access\\n' >&2; return 99; }
|
||||
'''
|
||||
for name, override, rc in [
|
||||
('healthy standby', '', 0),
|
||||
('healthy primary', "pg_role() { printf 'f\\n'; }", 8),
|
||||
('service stopped', 'service_running() { return 1; }', 7),
|
||||
('container stopped', 'container_running() { return 1; }', 7),
|
||||
('postgres stopped', 'pg_running() { return 1; }', 7),
|
||||
('postgres unready', 'pg_ready() { return 1; }', 1),
|
||||
('role query failure', 'pg_role() { return 1; }', 1),
|
||||
('invalid role', "pg_role() { printf 'unknown\\n'; }", 1),
|
||||
]:
|
||||
compare_case('monitor: ' + name, base + override, 'managed_monitor', rc)
|
||||
compare_case('start: healthy standby', base, 'managed_start', 0)
|
||||
compare_case('start: refuse primary', base + "pg_role() { printf 'f\\n'; }", 'managed_start', 1)
|
||||
compare_case('start: refuse missing standby.signal', base + '\npg_running() { return 1; }\npg_command() { return 1; }', 'managed_start', 1)
|
||||
compare_case('demote: already standby', base, 'managed_demote', 0)
|
||||
compare_case('demote: no fresh target', base + '\npg_role() { echo f; }\ndemote_target() { return 1; }', 'managed_demote', 1)
|
||||
compare_case('rejoin: reject running postgres', base + '\nREJOIN_SOURCE_HOST=192.0.2.1\nREPLICATION_SLOT=ha_test', 'managed_rejoin', 1)
|
||||
compare_case('promote: existing primary reconciles', base + '''
|
||||
pg_role() { echo f; }
|
||||
ensure_local_ha_slots() { echo slots; }
|
||||
ensure_rewind_privileges() { echo grants; }
|
||||
''', 'managed_promote', 0)
|
||||
compare_case('stop: already stopped', base + '\nservice_running() { return 1; }\ncontainer_running() { return 1; }', 'managed_stop', 0)
|
||||
|
||||
# validate_all invokes only these stubbed system boundaries. test() redirects
|
||||
# only the /run/user directory predicate; all other checks use the builtin.
|
||||
validation = base + '''
|
||||
id() { echo 1000; }
|
||||
service_uid() { echo 1000; }
|
||||
command() { if [ "$1" = -v ]; then return 0; fi; builtin command "$@"; }
|
||||
[() { if [[ "$1" == ! && "$2" == -d && "$3" == /run/user/* ]]; then return 1; fi; builtin [ "$@"; }
|
||||
as_service_user() { return 0; }
|
||||
'''
|
||||
compare_case('validate-all: success', validation, 'validate_all', 0)
|
||||
compare_case('validate-all: missing user', validation + '\nid() { return 1; }', 'validate_all', 6)
|
||||
compare_case('validate-all: missing command', validation + '\ncommand() { return 1; }', 'validate_all', 5)
|
||||
compare_case('validate-all: missing runtime dir', validation + '\n[() { if [[ "$1" == ! && "$2" == -d ]]; then return 0; fi; builtin [ "$@"; }', 'validate_all', 1)
|
||||
compare_case('validate-all: missing service', validation + '\nas_service_user() { return 1; }', 'validate_all', 6)
|
||||
compare_case('validate-all: podman failure', validation + '\nas_service_user() { if [[ "$1" == podman ]]; then echo failed >&2; return 1; fi; return 0; }', 'validate_all', 1)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='mc-loader-') as directory:
|
||||
copied = Path(directory) / 'managed-container'
|
||||
copied.write_text(AGENT.read_text())
|
||||
result = subprocess.run(['bash', str(copied), 'meta-data'], capture_output=True, text=True)
|
||||
assert result.returncode == 6 and 'Unable to load resource-agent module' in result.stderr
|
||||
print('PASS missing module fails with configured error')
|
||||
|
||||
text = AGENT.read_text() + '\n'.join(p.read_text() for p in MODULES)
|
||||
assert '.pgpass' not in text and 'PGPASSFILE' not in text
|
||||
assert not re.search(r'^\s*(?:source|\.)\s+.*(?:\.env|env-file)', text, re.M)
|
||||
print('PASS no pgpass or host env-file sourcing introduced')
|
||||
print('All offline comparisons passed; live HA transitions were not exercised.')
|
||||
Reference in New Issue
Block a user