Files
ocf-managed-container/tests/verify_refactor.py
T

149 lines
7.7 KiB
Python

#!/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')
current_functions = functions('\n'.join(p.read_text() for p in MODULES))
for name, definition in functions(SOURCE).items():
assert current_functions[name] == definition, name
assert set(current_functions) - set(functions(SOURCE)) == {
'mc_promotion_score_set', 'mc_promotion_score_from_monitor',
'mc_promotion_monitor', 'mc_promotion_action', 'mc_promotion_validate', 'mc_pg_promotion_eligible',
'mc_promotion_metadata', 'mc_pg_standby_observation', 'mc_pg_standby_score',
}
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
print('PASS all 29 original function bodies and defaults/globals identical; nine scoring helpers added')
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)
extended = ET.fromstring(metadata[1])
parameters = extended.find('parameters')
added = parameters.find("parameter[@name='disconnected_max_age']")
assert added is not None and added.find('content').get('default') == '300'
parameters.remove(added)
def normalize(element):
return (element.tag, element.attrib, (element.text or '').strip(),
[normalize(child) for child in element])
assert normalize(extended) == normalize(ET.fromstring(metadata[0]))
xml = ET.fromstring(metadata[0])
assert len(xml.findall('./parameters/parameter')) == 12
assert len(xml.findall('./actions/action')) == 8
print('PASS original metadata preserved plus freshness parameter 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.')