882 lines
46 KiB
Python
882 lines
46 KiB
Python
import ast
|
|
import contextlib
|
|
import json
|
|
import ntpath
|
|
import os
|
|
from pathlib import Path
|
|
import posixpath
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from types import SimpleNamespace
|
|
import unittest
|
|
from unittest import mock
|
|
from urllib.parse import quote
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / 'app'))
|
|
|
|
import paths
|
|
import postgres_runtime as runtime
|
|
|
|
|
|
class NativePostgresPathTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.environment = {}
|
|
self.os = SimpleNamespace(
|
|
name='posix', path=posixpath, environ=self.environment,
|
|
getenv=self.environment.get, fspath=os.fspath,
|
|
getcwd=lambda: '/unrelated',
|
|
)
|
|
self.enterContext(mock.patch.object(paths, 'os', self.os))
|
|
self.enterContext(mock.patch.object(runtime, 'os', self.os))
|
|
self.enterContext(mock.patch.object(runtime, 'IS_WINDOWS', False))
|
|
self.enterContext(mock.patch.object(runtime, 'canonical_path', side_effect=posixpath.normpath))
|
|
self.reject = self.enterContext(mock.patch.object(runtime, 'reject_reparse_components', side_effect=lambda path: path))
|
|
self.trust = self.enterContext(mock.patch.object(runtime, 'require_trusted_native_executable', side_effect=lambda path: path))
|
|
|
|
def test_optional_bin_dir_expands_and_preserves_old_fallback(self):
|
|
config = paths.apply_path_config({'global': {
|
|
'root_dir': '/opt/truf', 'project_dir': '/opt/truf/app',
|
|
'runtime_dir': '/data/runtime-linux', 'postgres_data_dir': '/data/postgres-linux',
|
|
}})
|
|
self.assertNotIn('postgres_bin_dir', config['global'])
|
|
self.assertEqual(paths.resolve_postgres_bin_dir(config['global']), '/data/runtime-linux/postgres/pgsql/bin')
|
|
config['global']['postgres_bin_dir'] = '{root_dir}/native/../native/bin'
|
|
paths.apply_path_config(config)
|
|
self.assertEqual(config['global']['postgres_bin_dir'], '/opt/truf/native/bin')
|
|
config['global']['postgres_bin_dir'] = '/usr/lib/postgresql/16/bin'
|
|
resolved = runtime.postgres_runtime_paths(config)
|
|
for name in ('postgres', 'pg_ctl', 'pg_isready', 'pg_controldata', 'initdb', 'psql'):
|
|
self.assertEqual(resolved[name], '/usr/lib/postgresql/16/bin/' + name)
|
|
self.assertEqual(resolved['data_dir'], '/data/postgres-linux')
|
|
self.assertEqual(resolved['identity_path'], '/data/runtime-linux/postgres/cluster_identity.json')
|
|
|
|
def test_windows_fallback_and_executable_suffix_are_unchanged(self):
|
|
self.os.name = 'nt'
|
|
self.os.path = ntpath
|
|
config = {'global': {'runtime_dir': r'E:\fixture\runtime'}}
|
|
resolved = runtime.postgres_runtime_paths(config)
|
|
self.assertEqual(resolved['pg_ctl'], r'E:\fixture\runtime\postgres\pgsql\bin\pg_ctl.exe')
|
|
self.trust.assert_not_called()
|
|
self.reject.assert_not_called()
|
|
|
|
def test_executable_link_is_rejected_before_canonicalization(self):
|
|
def reject(path):
|
|
if path.endswith('/postgres') and '/bin/' in path:
|
|
raise OSError('linked executable')
|
|
return path
|
|
|
|
self.reject.side_effect = reject
|
|
with self.assertRaisesRegex(OSError, 'linked executable'):
|
|
runtime.postgres_runtime_paths({'global': {
|
|
'runtime_dir': '/data/runtime-linux', 'postgres_bin_dir': '/usr/lib/postgresql/16/bin',
|
|
}})
|
|
|
|
def test_every_native_utility_is_verified_before_execution_and_pg_environment_is_scrubbed(self):
|
|
self.environment.update({'PGHOSTADDR': 'foreign', 'PGPASSWORD': 'secret', 'PATH': '/usr/bin'})
|
|
with mock.patch.object(runtime.subprocess, 'run') as run:
|
|
for name in ('postgres', 'pg_ctl', 'pg_isready', 'pg_controldata', 'initdb', 'psql'):
|
|
executable = '/usr/lib/postgresql/16/bin/' + name
|
|
runtime._run_bounded([executable, '--version'], 1)
|
|
self.trust.assert_called_with(executable)
|
|
self.assertEqual(run.call_args.args[0][0], executable)
|
|
self.assertEqual(run.call_args.kwargs['env'], {'PATH': '/usr/bin', 'LC_ALL': 'C', 'LANG': 'C'})
|
|
runtime._run_bounded(['/usr/lib/postgresql/16/bin/psql'], 1, environment_overrides={'PGPASSFILE': '/private/pass'})
|
|
self.assertEqual(run.call_args.kwargs['env']['PGPASSFILE'], '/private/pass')
|
|
|
|
def test_untrusted_utility_never_executes(self):
|
|
self.trust.side_effect = OSError('native file is writable')
|
|
with mock.patch.object(runtime.subprocess, 'run') as run:
|
|
with self.assertRaisesRegex(OSError, 'writable'):
|
|
runtime._run_bounded(['/usr/lib/postgresql/16/bin/postgres', '--version'], 1)
|
|
run.assert_not_called()
|
|
|
|
def test_identity_verification_rejects_untrusted_native_code_even_with_matching_hashes(self):
|
|
config = {'global': {
|
|
'runtime_dir': '/data/runtime-linux', 'postgres_data_dir': '/data/postgres-linux',
|
|
'postgres_bin_dir': '/usr/lib/postgresql/16/bin',
|
|
}}
|
|
resolved = runtime.postgres_runtime_paths(config)
|
|
executable_keys = ('postgres', 'pg_ctl', 'pg_isready', 'pg_controldata')
|
|
identity = {
|
|
'schema': 1, 'private_file_ready': True, 'data_directory': resolved['data_dir'],
|
|
'database': 'truf', 'user': 'truf', 'port': 5432, 'pg_major': 16,
|
|
'system_identifier': '12345', 'created_at': 'fixture',
|
|
'executables': {key: resolved[key] for key in executable_keys},
|
|
'executable_sha256': {key: 'a' * 64 for key in executable_keys},
|
|
}
|
|
self.trust.side_effect = OSError('native executable is writable')
|
|
with mock.patch.object(runtime, 'read_private_json', return_value=identity), \
|
|
mock.patch.object(self.os.path, 'isfile', return_value=True), \
|
|
mock.patch.object(runtime, 'sha256_file', return_value='a' * 64), \
|
|
mock.patch.object(runtime, '_run_bounded') as run:
|
|
with self.assertRaisesRegex(OSError, 'writable'):
|
|
runtime.verify_cluster_identity(config, verify_offline_system_identifier=False)
|
|
run.assert_not_called()
|
|
|
|
def test_windows_utility_does_not_use_posix_trust_policy(self):
|
|
self.os.name = 'nt'
|
|
self.environment['PGHOST'] = 'legacy'
|
|
with mock.patch.object(runtime.subprocess, 'run') as run:
|
|
runtime._run_bounded(['pg_ctl.exe', 'status'], 1)
|
|
self.trust.assert_not_called()
|
|
self.assertEqual(run.call_args.kwargs['env']['PGHOST'], 'legacy')
|
|
|
|
def test_normal_posix_start_explicitly_disables_unix_socket_defaults(self):
|
|
backend = runtime.PostgresBackend.__new__(runtime.PostgresBackend)
|
|
backend.paths = {'log_path': '/data/runtime-linux/postgres/postgres.log'}
|
|
backend._verified_identity = {
|
|
'data_directory': '/data/postgres-linux', 'port': 5432,
|
|
'executables': {'pg_ctl': '/usr/lib/postgresql/16/bin/pg_ctl'},
|
|
}
|
|
with mock.patch.object(backend, 'probe', return_value=runtime.ProbeResult(runtime.ProbeKind.STOPPED)), \
|
|
mock.patch.object(runtime, 'IS_WINDOWS', False), \
|
|
mock.patch.object(runtime, '_run_bounded', return_value=SimpleNamespace(returncode=0)) as run:
|
|
self.assertTrue(backend.start().accepted)
|
|
command = run.call_args.args[0]
|
|
self.assertIn('unix_socket_directories=', command[command.index('-o') + 1])
|
|
|
|
|
|
class OwnedChild:
|
|
def __init__(self, case, name, pid):
|
|
self.case = case
|
|
self.name = name
|
|
self.pid = pid
|
|
self.running = True
|
|
self.wait_errors = []
|
|
self.returncode = 0
|
|
self.signals = []
|
|
|
|
def poll(self):
|
|
return None if self.running else self.returncode
|
|
|
|
def wait(self, timeout):
|
|
self.case.assertTrue(self.case.lock_held)
|
|
self.case.events.append(self.name + '-wait')
|
|
if self.wait_errors:
|
|
raise self.wait_errors.pop(0)
|
|
self.running = False
|
|
return self.returncode
|
|
|
|
def send_signal(self, value):
|
|
self.case.assertTrue(self.case.lock_held)
|
|
self.case.assertEqual(value, signal.SIGINT)
|
|
self.signals.append(value)
|
|
|
|
|
|
class InitializeEmptyTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = Path(self.enterContext(tempfile.TemporaryDirectory()))
|
|
self.config = {'global': {
|
|
'root_dir': str(self.temp / 'application'),
|
|
'project_dir': str(self.temp / 'application' / 'app'),
|
|
'runtime_dir': str(self.temp / 'runtime'),
|
|
'postgres_data_dir': str(self.temp / 'postgres-linux'),
|
|
'postgres_bin_dir': str(self.temp / 'native' / 'bin'),
|
|
}}
|
|
self.paths = runtime.postgres_runtime_paths(self.config)
|
|
for key in ('postgres_dir', 'data_dir'):
|
|
Path(self.paths[key]).mkdir(parents=True)
|
|
self.os = SimpleNamespace(**vars(os))
|
|
self.os.name = 'posix'
|
|
self.os.geteuid = lambda: 10001
|
|
self.os.environ = {'PGHOSTADDR': 'must-not-inherit', 'PGPASSWORD': 'must-not-inherit'}
|
|
self.enterContext(mock.patch.object(runtime, 'os', self.os))
|
|
self.enterContext(mock.patch.object(runtime, 'postgres_runtime_paths', return_value=self.paths))
|
|
self.private = self.enterContext(mock.patch.object(runtime, 'require_private_directory', side_effect=lambda path, **_: path))
|
|
self.enterContext(mock.patch.object(runtime, 'private_file_ready', return_value=True))
|
|
self.trust = self.enterContext(mock.patch.object(runtime, 'require_trusted_native_executable', side_effect=lambda path: path))
|
|
self.values = {'database': 'truf', 'user': 'truf', 'port': 55432}
|
|
self.password = "fixture:'\\password"
|
|
self.enterContext(mock.patch.object(runtime, 'configured_cluster_values', return_value=self.values))
|
|
self.dsn = f'postgresql://truf:{quote(self.password, safe="")}@127.0.0.1:55432/truf'
|
|
self.enterContext(mock.patch.object(runtime, 'canonical_database_url', return_value=self.dsn))
|
|
self.listener = self.enterContext(mock.patch.object(runtime, '_listener_present', return_value=False))
|
|
self.port_socket = self.enterContext(mock.patch.object(runtime.socket, 'socket'))
|
|
self.major = self.enterContext(mock.patch.object(runtime, '_bundled_postgres_major', return_value=16))
|
|
self.enterContext(mock.patch.object(runtime, '_system_identifier', return_value='12345'))
|
|
self.lock_held = False
|
|
self.events = []
|
|
|
|
@contextlib.contextmanager
|
|
def authority(*_, **kwargs):
|
|
self.assertEqual(kwargs['endpoint_dsn'], self.dsn)
|
|
self.assertFalse(self.lock_held)
|
|
self.lock_held = True
|
|
self.events.append('lock')
|
|
try:
|
|
yield
|
|
finally:
|
|
self.lock_held = False
|
|
self.events.append('unlock')
|
|
|
|
self.lock = self.enterContext(mock.patch.object(runtime, 'ClusterAuthorityLock', side_effect=authority))
|
|
self.initdb = OwnedChild(self, 'initdb', 101)
|
|
self.postgres = OwnedChild(self, 'postgres', 202)
|
|
self.backend = mock.Mock()
|
|
self.backend._prepare_logging.side_effect = lambda: Path(self.paths['log_path']).touch()
|
|
self.backend._open_postmaster.return_value = mock.Mock(pid=self.postgres.pid)
|
|
self.enterContext(mock.patch.object(runtime, 'PostgresBackend', return_value=self.backend))
|
|
self.commands = []
|
|
self.private_paths = []
|
|
|
|
def spawn(command, **kwargs):
|
|
self.assertTrue(self.lock_held)
|
|
self.assertFalse(any(key.startswith('PG') for key in kwargs['env']))
|
|
self.assertNotIn(self.password, repr(command))
|
|
self.commands.append(command)
|
|
self.assertTrue(kwargs['start_new_session'])
|
|
if command[0] == self.paths['initdb']:
|
|
password_path = next(value.split('=', 1)[1] for value in command if value.startswith('--pwfile='))
|
|
self.assertEqual(Path(password_path).read_text(encoding='utf-8'), self.password + '\n')
|
|
if os.name != 'nt':
|
|
self.assertEqual(os.stat(password_path).st_mode & 0o777, 0o600)
|
|
self.private_paths.append(password_path)
|
|
Path(self.paths['data_dir'], 'PG_VERSION').write_text('16\n', encoding='ascii')
|
|
for name in ('pg_hba.conf', 'pg_ident.conf'):
|
|
auth_path = Path(self.paths['data_dir'], name)
|
|
auth_path.write_text('# initdb fixture\n', encoding='ascii')
|
|
os.chmod(auth_path, 0o600)
|
|
if os.name != 'nt' and os.geteuid() == 0:
|
|
os.chown(auth_path, 10001, 10001)
|
|
return self.initdb
|
|
self.assertEqual(command[0], self.paths['postgres'])
|
|
self.assertFalse(self.initdb.running)
|
|
return self.postgres
|
|
|
|
self.spawn = self.enterContext(mock.patch.object(runtime.subprocess, 'Popen', side_effect=spawn))
|
|
self.sql = []
|
|
self.sql_failure = False
|
|
self.control_state = 'shut down'
|
|
|
|
def utility(command, **kwargs):
|
|
self.assertTrue(self.lock_held)
|
|
if command[0] == self.paths['pg_isready']:
|
|
return SimpleNamespace(returncode=0, stdout='accepting')
|
|
if command[0] == self.paths['pg_controldata']:
|
|
self.assertFalse(self.postgres.running)
|
|
return SimpleNamespace(returncode=0, stdout='Database cluster state: ' + self.control_state + '\n')
|
|
self.assertEqual(command[0], self.paths['psql'])
|
|
self.assertIn('-X', command)
|
|
self.assertIn('-w', command)
|
|
self.assertIn('--set=ON_ERROR_STOP=1', command)
|
|
passfile = kwargs['environment_overrides']['PGPASSFILE']
|
|
self.assertTrue(Path(passfile).is_file())
|
|
escaped = self.password.replace('\\', '\\\\').replace(':', '\\:')
|
|
self.assertTrue(Path(passfile).read_text(encoding='utf-8').endswith(':' + escaped + '\n'))
|
|
self.private_paths.append(passfile)
|
|
statement = command[command.index('-c') + 1]
|
|
self.sql.append(statement)
|
|
if 'json_build_object' in statement:
|
|
return SimpleNamespace(returncode=0, stdout=json.dumps({
|
|
'data_directory': self.paths['data_dir'], 'system_identifier': '12345',
|
|
'user': self.values['user'], 'port': self.values['port'],
|
|
'listen_addresses': '', 'unix_socket': True,
|
|
}))
|
|
return SimpleNamespace(returncode=1 if self.sql_failure else 0, stdout='fixture SQL failure' if self.sql_failure else '')
|
|
|
|
self.run = self.enterContext(mock.patch.object(runtime, '_run_bounded', side_effect=utility))
|
|
self.identity = {'pg_major': 16, 'system_identifier': '12345'}
|
|
|
|
def bind(config):
|
|
self.assertTrue(self.lock_held)
|
|
self.assertFalse(self.postgres.running)
|
|
self.events.append('bind')
|
|
Path(self.paths['identity_path']).write_text(json.dumps(self.identity), encoding='ascii')
|
|
return self.identity
|
|
|
|
self.bind = self.enterContext(mock.patch.object(runtime, 'bootstrap_cluster_identity', side_effect=bind))
|
|
self.enterContext(mock.patch.object(runtime.time, 'sleep'))
|
|
|
|
def test_initialization_is_locked_private_socket_only_stopped_and_bound_once(self):
|
|
self.assertEqual(runtime.initialize_empty(self.config), self.identity)
|
|
self.assertEqual(self.events[-3:], ['postgres-wait', 'bind', 'unlock'])
|
|
self.lock.assert_called_once_with(self.config, endpoint_dsn=self.dsn)
|
|
self.assertEqual(self.postgres.signals, [signal.SIGINT])
|
|
self.assertEqual(self.initdb.signals, [])
|
|
initdb, postgres = self.commands
|
|
self.assertIn('--encoding=UTF8', initdb)
|
|
self.assertIn('--locale=C', initdb)
|
|
self.assertIn('--auth-local=scram-sha-256', initdb)
|
|
self.assertIn('--auth-host=scram-sha-256', initdb)
|
|
self.assertIn('--username=truf', initdb)
|
|
self.assertNotIn('--no-sync', initdb)
|
|
self.assertIn('listen_addresses=', postgres)
|
|
self.assertIn('unix_socket_permissions=0700', postgres)
|
|
self.assertTrue(any(item.startswith('unix_socket_directories="') for item in postgres))
|
|
self.assertTrue(any('CREATE DATABASE "truf" OWNER "truf"' in sql for sql in self.sql))
|
|
self.assertTrue(any('REVOKE CREATE ON SCHEMA public FROM PUBLIC' in sql for sql in self.sql))
|
|
if not runtime.IS_WINDOWS:
|
|
hba = Path(self.paths['data_dir'], 'pg_hba.conf').read_text(encoding='ascii')
|
|
ident = Path(self.paths['data_dir'], 'pg_ident.conf').read_text(encoding='ascii')
|
|
self.assertTrue(hba.startswith(
|
|
'# BEGIN TRUF HOST AGENT AUTHORITY\n'
|
|
'local truf truf peer map=truf_host_agent\n'
|
|
'# END TRUF HOST AGENT AUTHORITY\n'
|
|
))
|
|
self.assertIn('truf_host_agent root truf\n', ident)
|
|
self.assertIn('truf_host_agent truf truf\n', ident)
|
|
self.assertTrue(all(not Path(path).exists() for path in self.private_paths))
|
|
self.assertTrue(all(call.kwargs == {'create': False} for call in self.private.call_args_list))
|
|
with self.assertRaisesRegex(runtime.ClusterIdentityError, 'existing cluster identity'):
|
|
runtime.initialize_empty(self.config)
|
|
self.assertEqual(self.spawn.call_count, 2)
|
|
self.assertEqual(self.bind.call_count, 1)
|
|
|
|
@unittest.skipIf(os.name == 'nt', 'POSIX ownership checks required')
|
|
def test_managed_peer_authority_is_exact_idempotent_and_conflict_closed(self):
|
|
self.os.geteuid = os.geteuid
|
|
hba = Path(self.paths['data_dir'], 'pg_hba.conf')
|
|
ident = Path(self.paths['data_dir'], 'pg_ident.conf')
|
|
for path in (hba, ident):
|
|
path.write_text('# original\n', encoding='ascii')
|
|
os.chmod(path, 0o600)
|
|
runtime._configure_host_agent_peer_authority(self.paths, self.values)
|
|
first = (hba.read_bytes(), ident.read_bytes())
|
|
runtime._configure_host_agent_peer_authority(self.paths, self.values)
|
|
self.assertEqual((hba.read_bytes(), ident.read_bytes()), first)
|
|
hba.write_text(
|
|
'# BEGIN TRUF HOST AGENT AUTHORITY\nconflict\n'
|
|
'# END TRUF HOST AGENT AUTHORITY\n',
|
|
encoding='ascii',
|
|
)
|
|
with self.assertRaisesRegex(runtime.ClusterIdentityError, 'conflicts'):
|
|
runtime._configure_host_agent_peer_authority(self.paths, self.values)
|
|
|
|
def test_sql_identifiers_are_quoted_not_interpolated_as_commands(self):
|
|
self.values.update(user='owner"quoted', database='database"quoted')
|
|
runtime.initialize_empty(self.config)
|
|
self.assertTrue(any('CREATE DATABASE "database""quoted" OWNER "owner""quoted"' in sql for sql in self.sql))
|
|
self.assertTrue(any('ALTER ROLE "owner""quoted"' in sql for sql in self.sql))
|
|
|
|
def test_existing_identity_refuses_before_any_pg_tool(self):
|
|
Path(self.paths['identity_path']).write_text('untouched', encoding='ascii')
|
|
with self.assertRaisesRegex(runtime.ClusterIdentityError, 'existing cluster identity'):
|
|
runtime.initialize_empty(self.config)
|
|
self.assertEqual(Path(self.paths['identity_path']).read_text(), 'untouched')
|
|
self.spawn.assert_not_called()
|
|
self.run.assert_not_called()
|
|
self.bind.assert_not_called()
|
|
|
|
def test_nonempty_pgdata_is_not_adopted_or_repaired(self):
|
|
Path(self.paths['data_dir'], 'PG_VERSION').write_text('16\n', encoding='ascii')
|
|
with self.assertRaisesRegex(runtime.ClusterIdentityError, 'nonempty PGDATA'):
|
|
runtime.initialize_empty(self.config)
|
|
self.spawn.assert_not_called()
|
|
self.run.assert_not_called()
|
|
self.bind.assert_not_called()
|
|
|
|
def test_postmaster_and_all_supervisor_metadata_refuse_initialization(self):
|
|
runtime_dir = Path(self.config['global']['runtime_dir'])
|
|
for path in (
|
|
Path(self.paths['data_dir'], 'postmaster.pid'),
|
|
runtime_dir / 'control' / 'supervisor.instance.json',
|
|
runtime_dir / 'logs' / 'supervisor.instance.json',
|
|
runtime_dir / 'logs' / 'supervisor.pid',
|
|
):
|
|
with self.subTest(path=path):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text('untouched', encoding='ascii')
|
|
with self.assertRaisesRegex(runtime.ClusterIdentityError, 'refusing bootstrap'):
|
|
runtime.initialize_empty(self.config)
|
|
self.assertEqual(path.read_text(), 'untouched')
|
|
path.unlink()
|
|
self.spawn.assert_not_called()
|
|
self.bind.assert_not_called()
|
|
|
|
def test_occupied_listener_or_bound_tcp_port_refuses_initialization(self):
|
|
self.listener.return_value = True
|
|
with self.assertRaisesRegex(runtime.ClusterIdentityError, 'listener'):
|
|
runtime.initialize_empty(self.config)
|
|
self.listener.return_value = False
|
|
self.port_socket.return_value.__enter__.return_value.bind.side_effect = OSError('address in use')
|
|
with self.assertRaisesRegex(runtime.ClusterIdentityError, 'occupied or unavailable'):
|
|
runtime.initialize_empty(self.config)
|
|
self.spawn.assert_not_called()
|
|
|
|
def test_root_and_windows_are_rejected_before_lock_or_tool(self):
|
|
self.os.geteuid = lambda: 0
|
|
with self.assertRaisesRegex(runtime.ClusterIdentityError, 'non-root POSIX'):
|
|
runtime.initialize_empty(self.config)
|
|
self.os.geteuid = lambda: 10001
|
|
self.os.name = 'nt'
|
|
with self.assertRaisesRegex(runtime.ClusterIdentityError, 'non-root POSIX'):
|
|
runtime.initialize_empty(self.config)
|
|
self.lock.assert_not_called()
|
|
self.spawn.assert_not_called()
|
|
|
|
def test_explicit_independent_prepared_data_directory_is_required(self):
|
|
del self.config['global']['postgres_data_dir']
|
|
with self.assertRaisesRegex(runtime.ClusterIdentityError, 'explicit resolved absolute'):
|
|
runtime.initialize_empty(self.config)
|
|
self.config['global']['postgres_data_dir'] = self.config['global']['runtime_dir']
|
|
self.paths['data_dir'] = runtime.canonical_path(self.config['global']['runtime_dir'])
|
|
with self.assertRaisesRegex(runtime.ClusterIdentityError, 'independent'):
|
|
runtime.initialize_empty(self.config)
|
|
self.lock.assert_not_called()
|
|
self.spawn.assert_not_called()
|
|
|
|
def test_private_directory_failure_never_repairs_or_spawns(self):
|
|
self.private.side_effect = OSError('private directory absent or unsafe')
|
|
with self.assertRaisesRegex(OSError, 'absent or unsafe'):
|
|
runtime.initialize_empty(self.config)
|
|
self.lock.assert_not_called()
|
|
self.spawn.assert_not_called()
|
|
|
|
def test_lock_conflict_or_untrusted_tool_precedes_initialization(self):
|
|
self.lock.side_effect = BlockingIOError('owned by another maintenance process')
|
|
with self.assertRaises(BlockingIOError):
|
|
runtime.initialize_empty(self.config)
|
|
self.spawn.assert_not_called()
|
|
|
|
def test_untrusted_tools_and_wrong_major_are_rejected(self):
|
|
self.trust.side_effect = OSError('native executable untrusted')
|
|
with self.assertRaisesRegex(OSError, 'untrusted'):
|
|
runtime.initialize_empty(self.config)
|
|
self.trust.side_effect = lambda path: path
|
|
self.major.return_value = 17
|
|
with self.assertRaisesRegex(runtime.ClusterIdentityError, 'PostgreSQL 16'):
|
|
runtime.initialize_empty(self.config)
|
|
self.spawn.assert_not_called()
|
|
|
|
def test_initdb_timeout_retains_owner_without_killing_or_proceeding(self):
|
|
self.initdb.wait_errors = [subprocess.TimeoutExpired('initdb', 120)]
|
|
with self.assertRaises(subprocess.TimeoutExpired):
|
|
runtime.initialize_empty(self.config)
|
|
self.assertEqual(self.events, ['lock', 'initdb-wait', 'initdb-wait', 'unlock'])
|
|
self.assertEqual(self.initdb.signals, [])
|
|
self.assertEqual(self.spawn.call_count, 1)
|
|
self.bind.assert_not_called()
|
|
|
|
def test_sql_failure_compensates_and_retains_lock_and_socket_until_exit_confirmed(self):
|
|
self.sql_failure = True
|
|
self.postgres.wait_errors = [subprocess.TimeoutExpired('postgres', 60)]
|
|
|
|
def held_sleep(_):
|
|
self.assertTrue(self.lock_held)
|
|
self.assertTrue(self.postgres.running)
|
|
self.assertTrue(all(Path(path).exists() for path in self.private_paths))
|
|
|
|
with mock.patch.object(runtime.time, 'sleep', side_effect=held_sleep), \
|
|
mock.patch('builtins.print') as output:
|
|
with self.assertRaisesRegex(runtime.ClusterIdentityError, 'SQL setup failed'):
|
|
runtime.initialize_empty(self.config)
|
|
self.assertEqual(self.events[-3:], ['postgres-wait', 'postgres-wait', 'unlock'])
|
|
self.assertTrue(any('FAILED_HOLD' in call.args[0] for call in output.call_args_list))
|
|
self.assertFalse(self.postgres.running)
|
|
self.bind.assert_not_called()
|
|
self.assertFalse(Path(self.paths['identity_path']).exists())
|
|
|
|
def test_mismatched_postmaster_never_receives_a_guessed_pid_stop(self):
|
|
self.backend._open_postmaster.return_value.pid = 999
|
|
with self.assertRaisesRegex(runtime.ClusterIdentityError, 'directly owned child'):
|
|
runtime.initialize_empty(self.config)
|
|
self.assertEqual(self.postgres.signals, [signal.SIGINT])
|
|
self.assertFalse(self.sql)
|
|
self.bind.assert_not_called()
|
|
|
|
def test_unclean_control_state_does_not_bind_identity(self):
|
|
self.control_state = 'in production'
|
|
with self.assertRaisesRegex(runtime.ClusterIdentityError, 'cleanly stopped'):
|
|
runtime.initialize_empty(self.config)
|
|
self.bind.assert_not_called()
|
|
|
|
|
|
class InitializationEntrypointTests(unittest.TestCase):
|
|
def test_cli_dispatches_to_lock_owning_initializer_after_preflight(self):
|
|
config = {'global': {}}
|
|
with mock.patch.object(sys, 'argv', ['postgres_runtime.py', 'initialize-empty', '--config', 'fixture.yaml']), \
|
|
mock.patch.object(runtime, '_load_config', return_value=config), \
|
|
mock.patch.object(runtime, 'preflight_lifecycle_paths') as preflight, \
|
|
mock.patch.object(runtime, 'load_postgres_environment'), \
|
|
mock.patch.object(runtime, 'canonical_database_url', return_value='fixture-dsn'), \
|
|
mock.patch.object(runtime, 'initialize_empty', return_value={'pg_major': 16, 'system_identifier': '12345'}) as initialize, \
|
|
mock.patch.object(runtime, 'postgres_runtime_paths', return_value={'identity_path': 'fixture-identity'}), \
|
|
mock.patch.object(runtime, 'ClusterAuthorityLock') as outer_lock, \
|
|
mock.patch('builtins.print'):
|
|
runtime.main()
|
|
preflight.assert_called_once()
|
|
initialize.assert_called_once_with(config)
|
|
outer_lock.assert_not_called()
|
|
|
|
def test_container_refusal_and_full_layout_gate_precede_application_imports(self):
|
|
tree = ast.parse((ROOT / 'app' / 'postgres_runtime.py').read_text(encoding='utf-8'))
|
|
expected = ast.parse(
|
|
"import sys\nimport os\nif __name__ == '__main__':\n"
|
|
" if sys.platform != 'linux' or not os.path.isfile('/.dockerenv') "
|
|
"or os.path.abspath(__file__) != '/opt/truf/app/postgres_runtime.py':\n"
|
|
" raise SystemExit('Docker development copy: runtime control is disabled outside the prepared container. See DOCKER_MIGRATION.md.')\n"
|
|
' import runpy\n'
|
|
" runpy.run_path('/opt/truf/app/container_runtime.py')['require_container']()\n"
|
|
).body
|
|
self.assertEqual([ast.dump(node) for node in tree.body[:3]], [ast.dump(node) for node in expected])
|
|
|
|
def test_bounded_maintenance_stop_requires_proof_and_never_closes_a_supplied_backend(self):
|
|
for completed, stopped in ((False, False), (True, False), (False, True), (1, 1), (True, True)):
|
|
backend = mock.Mock()
|
|
backend.probe.return_value = runtime.ProbeResult(runtime.ProbeKind.READY)
|
|
backend.stop.return_value = runtime.StopResult(completed, stopped)
|
|
with self.subTest(completed=completed, stopped=stopped):
|
|
if completed is True and stopped is True:
|
|
self.assertEqual(runtime.maintenance_stop({}, backend), backend.stop.return_value)
|
|
else:
|
|
with self.assertRaisesRegex(runtime.ClusterIdentityError, 'stop did not complete'):
|
|
runtime.maintenance_stop({}, backend)
|
|
backend.stop.assert_called_once_with()
|
|
backend.probe.assert_not_called()
|
|
backend.close.assert_not_called()
|
|
|
|
def test_bootstrap_cannot_replace_an_existing_identity(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
identity_path = Path(directory, 'identity.json')
|
|
identity_path.write_text('original', encoding='ascii')
|
|
with mock.patch.object(runtime, 'postgres_runtime_paths', return_value={'identity_path': str(identity_path)}), \
|
|
mock.patch.object(runtime, '_run_bounded') as run, \
|
|
mock.patch.object(runtime, 'write_private_json_exclusive') as publish:
|
|
with self.assertRaisesRegex(runtime.ClusterIdentityError, 'existing cluster identity'):
|
|
runtime.bootstrap_cluster_identity({})
|
|
self.assertEqual(identity_path.read_text(), 'original')
|
|
run.assert_not_called()
|
|
publish.assert_not_called()
|
|
|
|
|
|
class MaintenanceEntrypointTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.config = {'global': {'runtime_dir': 'fixture-runtime'}}
|
|
self.dsn = 'postgresql://fixture:fixture@127.0.0.1:55432/fixture'
|
|
self.events = []
|
|
self.lock_held = False
|
|
self.closed = False
|
|
self.stopped = False
|
|
self.ready = False
|
|
self.pg_live = True
|
|
self.launch_owned = True
|
|
self.start_error = None
|
|
self.start_result = runtime.StartResult(True)
|
|
self.probes = []
|
|
self.stops = []
|
|
self.sleep_errors = []
|
|
self.close_errors = []
|
|
self.print_errors = []
|
|
self.cancel_when_locked = False
|
|
self.backend = mock.Mock(spec=runtime.PostgresBackend)
|
|
self.backend.owns_start = False
|
|
self.backend.start_settle_timeout_sec = 0.1
|
|
self.retained = object()
|
|
self.backend._expected_process = self.retained
|
|
|
|
def start():
|
|
self.events.append(('start', self.lock_held, self.closed))
|
|
if self.launch_owned:
|
|
self.backend.owns_start = True
|
|
self.pg_live = True
|
|
if self.start_error is not None:
|
|
raise self.start_error
|
|
return self.start_result
|
|
|
|
def probe():
|
|
value = self.probes.pop(0) if self.probes else runtime.ProbeResult(runtime.ProbeKind.READY)
|
|
if callable(value):
|
|
value = value()
|
|
if isinstance(value, BaseException):
|
|
raise value
|
|
if value.kind == runtime.ProbeKind.READY:
|
|
self.ready = True
|
|
self.events.append(('ready', self.lock_held, self.closed))
|
|
return value
|
|
|
|
def stop():
|
|
self.events.append(('stop', self.lock_held, self.closed, self.backend._expected_process is self.retained))
|
|
value = self.stops.pop(0) if self.stops else runtime.StopResult(True, True, 'verified stopped')
|
|
if callable(value):
|
|
value = value()
|
|
if isinstance(value, BaseException):
|
|
raise value
|
|
if isinstance(value, runtime.StopResult) and value.completed is True and value.stopped is True:
|
|
self.stopped = True
|
|
self.pg_live = False
|
|
self.backend.owns_start = False
|
|
self.events.append(('proof', self.lock_held, self.closed))
|
|
return value
|
|
|
|
def close():
|
|
self.events.append(('close', self.lock_held, self.stopped, self.ready))
|
|
if self.close_errors:
|
|
raise self.close_errors.pop(0)
|
|
self.closed = True
|
|
|
|
self.backend.start.side_effect = start
|
|
self.backend.probe.side_effect = probe
|
|
self.backend.stop.side_effect = stop
|
|
self.backend.close.side_effect = close
|
|
self.construct = self.enterContext(mock.patch.object(runtime, 'PostgresBackend', return_value=self.backend))
|
|
self.enterContext(mock.patch.object(runtime, '_load_config', return_value=self.config))
|
|
self.enterContext(mock.patch.object(runtime, 'preflight_lifecycle_paths'))
|
|
self.enterContext(mock.patch.object(runtime, 'load_postgres_environment'))
|
|
self.enterContext(mock.patch.object(runtime, 'canonical_database_url', return_value=self.dsn))
|
|
self.verify = self.enterContext(mock.patch.object(runtime, 'verify_cluster_identity', side_effect=AssertionError('verification belongs inside the owned backend')))
|
|
self.lookup = self.enterContext(mock.patch.object(runtime, 'open_process'))
|
|
self.native = self.enterContext(mock.patch.object(runtime, '_run_bounded'))
|
|
self.kill = self.enterContext(mock.patch.object(runtime.os, 'kill'))
|
|
self.original_handlers = {signal.SIGINT: object(), signal.SIGTERM: object()}
|
|
self.handlers = dict(self.original_handlers)
|
|
|
|
def set_handler(signum, handler):
|
|
old = self.handlers[signum]
|
|
self.handlers[signum] = handler
|
|
phase = 'restore-signal' if handler is self.original_handlers[signum] else 'install-signal'
|
|
self.events.append((phase, signum, self.lock_held))
|
|
return old
|
|
|
|
self.enterContext(mock.patch.object(runtime.signal, 'signal', side_effect=set_handler))
|
|
|
|
@contextlib.contextmanager
|
|
def authority(*args, **kwargs):
|
|
self.events.append(('lock',))
|
|
self.lock_held = True
|
|
if self.cancel_when_locked:
|
|
self.handlers[signal.SIGTERM](signal.SIGTERM, None)
|
|
try:
|
|
yield
|
|
finally:
|
|
self.events.append(('unlock', self.stopped, self.closed, self.ready))
|
|
self.lock_held = False
|
|
|
|
self.authority = authority
|
|
self.lock = self.enterContext(mock.patch.object(runtime, 'ClusterAuthorityLock', side_effect=authority))
|
|
|
|
def sleep(_):
|
|
self.events.append(('sleep', self.lock_held, self.closed))
|
|
if self.sleep_errors:
|
|
raise self.sleep_errors.pop(0)
|
|
|
|
self.enterContext(mock.patch.object(runtime.time, 'sleep', side_effect=sleep))
|
|
|
|
def output(message, **kwargs):
|
|
self.events.append(('output', message))
|
|
if 'FAILED_HOLD' in message and self.print_errors:
|
|
raise self.print_errors.pop(0)
|
|
|
|
self.output = self.enterContext(mock.patch('builtins.print', side_effect=output))
|
|
|
|
def run_cli(self, action):
|
|
if action == 'maintenance-start':
|
|
self.pg_live = False
|
|
with mock.patch.object(sys, 'argv', ['postgres_runtime.py', action, '--config', 'fixture.yaml']):
|
|
return runtime.main()
|
|
|
|
def assert_stopped_before_release(self):
|
|
self.assertTrue(self.stopped)
|
|
self.assertFalse(self.pg_live)
|
|
self.assertTrue(self.closed)
|
|
self.assertFalse(self.lock_held)
|
|
self.construct.assert_called_once_with(self.config)
|
|
self.lock.assert_called_once_with(self.config, endpoint_dsn=self.dsn)
|
|
self.backend.close.assert_called_once_with()
|
|
self.assertTrue(all(event[1:3] == (True, True) for event in self.events if event[0] == 'close'))
|
|
self.assertTrue(all(event[1:] == (True, False, True) for event in self.events if event[0] == 'stop'))
|
|
phases = [event[0] for event in self.events]
|
|
self.assertLess(phases.index('proof'), phases.index('close'))
|
|
self.assertLess(phases.index('close'), phases.index('unlock'))
|
|
self.assertLess(phases.index('unlock'), phases.index('restore-signal'))
|
|
self.assertEqual(self.handlers, self.original_handlers)
|
|
self.verify.assert_not_called()
|
|
self.lookup.assert_not_called()
|
|
self.native.assert_not_called()
|
|
self.kill.assert_not_called()
|
|
|
|
def test_stop_retains_same_backend_and_locks_through_repeated_failures_and_interrupts(self):
|
|
self.stops = [
|
|
runtime.StopResult(False, False, 'first stop failure'),
|
|
KeyboardInterrupt(), SystemExit(0), runtime.StopResult(True, False, 'still live'),
|
|
runtime.StopResult(True, True, 'verified stopped'),
|
|
]
|
|
self.sleep_errors = [KeyboardInterrupt(), SystemExit(0)]
|
|
self.print_errors = [KeyboardInterrupt(), SystemExit(0)]
|
|
with self.assertRaisesRegex(SystemExit, 'first stop failure') as caught:
|
|
self.run_cli('maintenance-stop')
|
|
self.assertNotEqual(caught.exception.code, 0)
|
|
self.assertEqual(self.backend.stop.call_count, 5)
|
|
self.assertTrue(any(event[0] == 'output' and 'FAILED_HOLD' in event[1] for event in self.events))
|
|
self.assert_stopped_before_release()
|
|
|
|
def test_system_exit_zero_is_sticky_failure_not_a_successful_cleanup_exit(self):
|
|
self.stops = [SystemExit(0), KeyboardInterrupt(), SystemExit(0)]
|
|
with self.assertRaisesRegex(SystemExit, 'SystemExit: 0') as caught:
|
|
self.run_cli('maintenance-stop')
|
|
self.assertNotEqual(caught.exception.code, 0)
|
|
self.assertEqual(self.backend.stop.call_count, 4)
|
|
self.assert_stopped_before_release()
|
|
|
|
def test_truthy_or_incomplete_stop_results_are_not_stop_proof(self):
|
|
self.stops = [
|
|
SimpleNamespace(completed=True, stopped=True),
|
|
runtime.StopResult(False, True), runtime.StopResult(True, False),
|
|
runtime.StopResult(1, 1),
|
|
]
|
|
with self.assertRaisesRegex(SystemExit, 'stop did not complete'):
|
|
self.run_cli('maintenance-stop')
|
|
self.assertEqual(self.backend.stop.call_count, 5)
|
|
self.assert_stopped_before_release()
|
|
|
|
def test_stop_identity_failure_remains_inert_and_retained_until_proof(self):
|
|
self.stops = [
|
|
runtime.StopResult(False, False, 'foreign identity; stop refused'),
|
|
OSError('identity temporarily unavailable'),
|
|
]
|
|
with self.assertRaisesRegex(SystemExit, 'foreign identity'):
|
|
self.run_cli('maintenance-stop')
|
|
self.assert_stopped_before_release()
|
|
|
|
def test_uncertain_start_compensates_before_sticky_failure_can_release_locks(self):
|
|
self.start_result = runtime.StartResult(True, 'launch outcome uncertain', uncertain=True)
|
|
self.probes = [
|
|
runtime.ProbeResult(runtime.ProbeKind.RECOVERING),
|
|
runtime.ProbeResult(runtime.ProbeKind.OWNED_START_UNCERTAIN, 'first start failure'),
|
|
]
|
|
self.stops = [runtime.StopResult(False, False, 'not stopped'), KeyboardInterrupt(), SystemExit(0)]
|
|
with self.assertRaisesRegex(SystemExit, 'first start failure'):
|
|
self.run_cli('maintenance-start')
|
|
self.backend.start.assert_called_once_with()
|
|
self.assertEqual(self.backend.stop.call_count, 4)
|
|
self.assert_stopped_before_release()
|
|
|
|
def test_interrupt_during_start_is_compensated_on_the_exact_owned_backend(self):
|
|
self.start_error = KeyboardInterrupt()
|
|
self.stops = [SystemExit(0), runtime.StopResult(False, False, 'uncertain')]
|
|
with self.assertRaisesRegex(SystemExit, 'KeyboardInterrupt'):
|
|
self.run_cli('maintenance-start')
|
|
self.backend.start.assert_called_once_with()
|
|
self.assert_stopped_before_release()
|
|
|
|
def test_rejected_but_possibly_launched_start_still_requires_compensation(self):
|
|
self.start_result = runtime.StartResult(False, 'start result was lost')
|
|
self.stops = [runtime.StopResult(False, False)]
|
|
with self.assertRaisesRegex(SystemExit, 'start result was lost'):
|
|
self.run_cli('maintenance-start')
|
|
self.assert_stopped_before_release()
|
|
|
|
def test_lost_stop_result_cannot_clear_the_latched_compensation_requirement(self):
|
|
self.probes = [runtime.ProbeResult(runtime.ProbeKind.OWNED_START_UNCERTAIN, 'start failed')]
|
|
|
|
def lost_stop_result():
|
|
self.backend.owns_start = False
|
|
raise KeyboardInterrupt()
|
|
|
|
self.stops = [lost_stop_result, runtime.StopResult(False, False, 'still unconfirmed')]
|
|
with self.assertRaisesRegex(SystemExit, 'start failed'):
|
|
self.run_cli('maintenance-start')
|
|
self.assertEqual(self.backend.stop.call_count, 3)
|
|
self.assert_stopped_before_release()
|
|
|
|
def test_prelaunch_foreign_rejection_never_adopts_or_stops_that_cluster(self):
|
|
self.launch_owned = False
|
|
self.start_result = runtime.StartResult(False, 'foreign before launch', foreign_or_config_error=True)
|
|
with self.assertRaisesRegex(SystemExit, 'foreign before launch'):
|
|
self.run_cli('maintenance-start')
|
|
self.backend.stop.assert_not_called()
|
|
self.backend.close.assert_called_once_with()
|
|
self.assertFalse(self.backend.owns_start)
|
|
self.assertFalse(self.lock_held)
|
|
self.lookup.assert_not_called()
|
|
self.native.assert_not_called()
|
|
self.kill.assert_not_called()
|
|
|
|
def test_successful_start_releases_for_migration_without_stopping_ready_postgres(self):
|
|
self.probes = [runtime.ProbeResult(runtime.ProbeKind.RECOVERING), runtime.ProbeResult(runtime.ProbeKind.READY)]
|
|
self.assertIsNone(self.run_cli('maintenance-start'))
|
|
self.assertTrue(self.ready)
|
|
self.assertTrue(self.pg_live)
|
|
self.assertFalse(self.stopped)
|
|
self.assertFalse(self.lock_held)
|
|
self.construct.assert_called_once_with(self.config)
|
|
self.backend.start.assert_called_once_with()
|
|
self.backend.stop.assert_not_called()
|
|
self.backend.close.assert_called_once_with()
|
|
self.assertEqual(self.handlers, self.original_handlers)
|
|
|
|
def test_sigterm_after_ready_is_flag_only_and_compensates_before_release(self):
|
|
def ready_then_cancel():
|
|
self.assertIsNone(self.handlers[signal.SIGTERM](signal.SIGTERM, None))
|
|
self.assertIsNone(self.handlers[signal.SIGTERM](signal.SIGTERM, None))
|
|
return runtime.ProbeResult(runtime.ProbeKind.READY)
|
|
|
|
self.probes = [ready_then_cancel]
|
|
self.stops = [runtime.StopResult(False, False), KeyboardInterrupt()]
|
|
with self.assertRaisesRegex(SystemExit, 'shutdown requested'):
|
|
self.run_cli('maintenance-start')
|
|
self.assert_stopped_before_release()
|
|
|
|
def test_sigterm_during_stop_cannot_interrupt_verified_cleanup(self):
|
|
def cancelled_stop():
|
|
self.assertIsNone(self.handlers[signal.SIGTERM](signal.SIGTERM, None))
|
|
self.assertIsNone(self.handlers[signal.SIGINT](signal.SIGINT, None))
|
|
return runtime.StopResult(True, True, 'stopped after cancellation')
|
|
|
|
self.stops = [cancelled_stop]
|
|
with self.assertRaisesRegex(SystemExit, 'shutdown requested'):
|
|
self.run_cli('maintenance-stop')
|
|
self.backend.stop.assert_called_once_with()
|
|
self.assert_stopped_before_release()
|
|
|
|
def test_cancel_before_launch_does_not_start_or_adopt_a_postmaster(self):
|
|
self.cancel_when_locked = True
|
|
with self.assertRaisesRegex(SystemExit, 'shutdown requested'):
|
|
self.run_cli('maintenance-start')
|
|
self.backend.start.assert_not_called()
|
|
self.backend.stop.assert_not_called()
|
|
self.backend.close.assert_called_once_with()
|
|
self.assertFalse(self.lock_held)
|
|
|
|
def test_status_interrupt_after_readiness_still_compensates(self):
|
|
def output(message, **kwargs):
|
|
if 'authenticated-ready' in message:
|
|
raise SystemExit(0)
|
|
|
|
self.output.side_effect = output
|
|
self.stops = [KeyboardInterrupt(), runtime.StopResult(False, False)]
|
|
with self.assertRaisesRegex(SystemExit, 'SystemExit: 0'):
|
|
self.run_cli('maintenance-start')
|
|
self.assert_stopped_before_release()
|
|
|
|
def test_failed_ready_handoff_close_requires_compensation_before_failure_return(self):
|
|
self.close_errors = [SystemExit(0)]
|
|
self.stops = [runtime.StopResult(False, False), KeyboardInterrupt()]
|
|
with self.assertRaisesRegex(SystemExit, 'SystemExit: 0'):
|
|
self.run_cli('maintenance-start')
|
|
self.assertTrue(self.stopped)
|
|
self.assertFalse(self.pg_live)
|
|
self.assertEqual(self.backend.close.call_count, 2)
|
|
close_events = [event for event in self.events if event[0] == 'close']
|
|
self.assertEqual(close_events, [('close', True, False, True), ('close', True, True, True)])
|
|
self.construct.assert_called_once_with(self.config)
|
|
self.lock.assert_called_once_with(self.config, endpoint_dsn=self.dsn)
|
|
|
|
def test_default_helper_owns_backend_until_failed_stop_is_confirmed(self):
|
|
self.stops = [runtime.StopResult(False, False, 'initial stop failed'), KeyboardInterrupt()]
|
|
with self.authority(), self.assertRaisesRegex(runtime.ClusterIdentityError, 'initial stop failed'):
|
|
runtime.maintenance_stop(self.config)
|
|
self.assertTrue(self.stopped)
|
|
self.assertFalse(self.pg_live)
|
|
self.construct.assert_called_once_with(self.config)
|
|
self.backend.close.assert_called_once_with()
|
|
self.assertTrue(all(event[1:3] == (True, True) for event in self.events if event[0] == 'close'))
|
|
|
|
def test_default_start_helper_compensates_failure_before_closing(self):
|
|
self.probes = [runtime.ProbeResult(runtime.ProbeKind.OWNED_START_UNCERTAIN, 'uncertain launch')]
|
|
self.stops = [KeyboardInterrupt(), SystemExit(0)]
|
|
with self.authority(), self.assertRaisesRegex(runtime.ClusterIdentityError, 'uncertain launch'):
|
|
runtime.maintenance_start(self.config)
|
|
self.assertTrue(self.stopped)
|
|
self.assertFalse(self.pg_live)
|
|
self.construct.assert_called_once_with(self.config)
|
|
self.backend.close.assert_called_once_with()
|
|
self.assertTrue(all(event[1:3] == (True, True) for event in self.events if event[0] == 'close'))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|