#!/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.')