149 lines
7.7 KiB
Python
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.')
|