"""Redis configuration state-machine tests with no live Redis/server access.""" import contextlib import importlib.util import io import json import os from pathlib import Path import shutil import subprocess import tempfile import unittest from unittest.mock import patch SOURCE = Path(__file__).resolve().parents[1]/'install-redis-persistence.py' OLD = {'appendonly': 'no', 'appendfsync': 'always', 'maxmemory': '0', 'maxmemory-policy': 'allkeys-lru'} WANTED = {'appendonly': 'yes', 'appendfsync': 'everysec', 'maxmemory': '268435456', 'maxmemory-policy': 'noeviction'} @unittest.skipUnless(os.name == 'posix' and getattr(os, 'geteuid', lambda: -1)() == 0, 'Disposable root Linux container required') class RedisPersistenceTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory(prefix='redis-persistence-test-') self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) script = SOURCE.read_text() for prefix in ('/etc/', '/var/', '/run/'): script = script.replace(prefix, str(self.root)+prefix) self.module = importlib.util.module_from_spec(importlib.util.spec_from_loader('redis_persistence', loader=None)) exec(compile(script, str(SOURCE), 'exec'), self.module.__dict__) for directory in ('etc/redis', 'var/lib/eusrr-deploy', 'var/lib/redis', 'run/lock'): (self.root/directory).mkdir(parents=True, exist_ok=True) (self.root/'etc/redis/redis.conf').write_text('original configuration\n') self.state = dict(OLD) self.calls = [] self.rewrites = 0 self.fail_stage = None self.verification_failed = False def fake_redis(self, *args): self.calls.append(args) if args == ('INFO', 'memory'): return 'used_memory:' + ('220000000' if self.fail_stage == 'memory' else '4194304') if args == ('INFO', 'persistence'): status = 'err' if self.fail_stage in ('aof', 'rollback') else 'ok' return f'aof_enabled:1\naof_rewrite_in_progress:0\naof_rewrite_scheduled:0\naof_last_bgrewrite_status:{status}\n' if args[:2] == ('CONFIG', 'GET'): if args[2] == 'bind': return {'bind': '127.0.0.1 -::1'} if args[2] == 'dir': return {'dir': str(self.root/'var/lib/redis')} if self.fail_stage == 'verification' and self.rewrites == 1 and not self.verification_failed: self.verification_failed = True return dict(self.state, appendfsync='unexpected') return dict(self.state) if args[:2] == ('CONFIG', 'SET'): key, value = args[2:] if self.fail_stage == 'set' and key == 'maxmemory' and value == WANTED[key]: raise RuntimeError('Injected CONFIG SET failure') if self.fail_stage == 'rollback' and key == 'appendonly' and value == OLD[key]: raise RuntimeError('Injected rollback failure') self.state[key] = value return 'OK' if args == ('CONFIG', 'REWRITE'): self.rewrites += 1 return 'OK' raise AssertionError(args) def run_installer(self): with patch.object(self.module, 'redis', side_effect=self.fake_redis), \ patch.object(self.module.time, 'sleep'), contextlib.redirect_stdout(io.StringIO()): self.module.main() def test_success_checks_aof_then_persists_and_verifies(self): self.run_installer() self.assertEqual(self.state, WANTED) self.assertEqual(self.rewrites, 1) self.assertLess(self.calls.index(('INFO', 'persistence')), self.calls.index(('CONFIG', 'REWRITE'))) self.assertFalse(any(args[0] in ('FLUSHALL', 'FLUSHDB', 'DEL', 'SHUTDOWN') for args in self.calls)) def test_set_aof_and_post_rewrite_verification_failures_all_restore(self): for stage in ('set', 'aof', 'verification'): with self.subTest(stage=stage): self.fail_stage = stage self.state = dict(OLD) self.rewrites = 0 self.verification_failed = False for directory in (self.root/'var/lib/eusrr-deploy').iterdir(): shutil.rmtree(directory) with self.assertRaises(RuntimeError): self.run_installer() self.assertEqual(self.state, OLD) self.assertGreaterEqual(self.rewrites, 1) def test_rollback_attempts_remaining_settings_and_reports_incomplete(self): self.fail_stage = 'rollback' with self.assertRaisesRegex(RuntimeError, 'Redis recovery incomplete'): self.run_installer() for key in ('appendfsync', 'maxmemory', 'maxmemory-policy'): self.assertEqual(self.state[key], OLD[key]) self.assertIn(('CONFIG', 'REWRITE'), self.calls) def test_high_memory_fails_before_mutation(self): self.fail_stage = 'memory' with self.assertRaisesRegex(SystemExit, 'too close'): self.run_installer() self.assertFalse(any(args[:2] == ('CONFIG', 'SET') for args in self.calls)) def test_low_disk_fails_before_mutation(self): with patch.object(self.module.shutil, 'disk_usage', return_value=shutil._ntuple_diskusage(10000000, 9000000, 1000000)): with self.assertRaisesRegex(SystemExit, 'Insufficient free space'): self.run_installer() self.assertFalse(any(args[:2] == ('CONFIG', 'SET') for args in self.calls)) def test_cli_raw_and_json_info_formats(self): for output in ('used_memory:123\r\n', json.dumps('used_memory:123\r\n')): with self.subTest(output=output), patch.object(self.module.subprocess, 'run', return_value=subprocess.CompletedProcess(['redis-cli'], 0, stdout=output)): self.assertEqual(self.module.redis('INFO', 'memory'), 'used_memory:123\r\n') if __name__ == '__main__': unittest.main()